Design · 004-query-endpoint
Position query endpoint
How we will build it — the first entry point, and a feature that never mentions the decision.
build-out
Created
Read time
A GET /positions/{instrument} served by the JDK's own com.sun.net.httpserver — no web framework
— reading through PositionService.current and so through the persistence adapter. The contract is an OpenAPI
sidecar authored first (P-7). Main arrives here: it opens the H2 file, wires the bus, the adapter, the two
consumers and the HTTP server. Biggest risk: a read path that accidentally writes — the test asserts zero events and an
unchanged store across every request.
1 · Principles check
Verified against v0.1.0. No exception, no violation.
| Principle | Compliance | Notes |
|---|---|---|
| P-1 · Clarity over cleverness | One handler, one JSON writer, no framework or reflection. | |
| P-2 · Small, reversible changes | A new package and a Main; nothing existing changes. | |
| P-3 · Explicit over implicit | Routes are registered by hand; the port and DB path are named environment variables with defaults. | |
| P-4 · One set of conventions, enforced | 001's floor. | |
| P-5 · Record the why | Three decisions below. | |
| P-6 · Tests accompany behavior | PositionsEndpointTest drives a real server on an ephemeral port (SC-001). | |
| P-7 · Contract-first interfaces | OpenAPI 3.1 sidecar at specs/004-query-endpoint/contracts/positions.openapi.yaml, declared in §3, before the handler exists. | |
| P-8 · Library-first boundaries | Main is wiring only; HttpApi is a thin adapter over the use case. | |
| P-9 · Secure by default | Binds localhost by default; no credentials in code. Authentication is deferred to the gateway by the spec. | |
| P-10 · Least privilege | The handler holds a PositionService only — it cannot reach a connection. | |
| P-11 · Semantic versioning | The contract carries info.version: 0.1.0, in step with the build. | |
| P-12 · Supply-chain hygiene | Zero new dependencies — the HTTP server and JSON writing are JDK + 40 lines. |
Governing decisions acknowledged
This plan adds nothing under src/main/java/xyz/briancorbin/pk/hex/persistence/, yet the plan-constraint names
002-downstream-consumers/D-007 (ADR-0007 — only the persistence adapter touches the data store), because its scope
src/**/persistence/** reduces to the static prefix src and every path in §9
lives under it. Acknowledged on the merits anyway: the endpoint reads through the port and the existing adapter, holds no
connection, routes only GET, and its test asserts no PositionChanged is emitted by any request — it
adds no writer. The over-eager trigger is recorded as a tooling gap (meta-repo inbox), not worked around by narrowing §9.
2 · Technical context
- Language(s)
- Java 21 on the Gradle build (001/D-001).
- Frameworks
- None —
com.sun.net.httpserver.HttpServerfrom thejdk.httpservermodule (D-001); Gradle'sapplicationplugin for./gradlew run. - Architecture
- Hexagonal (001/D-002):
hex.httpis an inbound adapter over the use case. - Storage
- None new; reads reach the H2 store through the existing adapter (003) — spec NFR-001.
- Testing
- JUnit 5 (001/D-003); the endpoint test uses
java.net.http.HttpClientagainst a server on port 0. - Coverage
- Not in scope (standard profile).
- Perf targets
- Not in scope — the spec names none; a single-threaded JDK server is adequate for a worked example.
- Constraints
- Read-only: only
GETis routed on/positions/; anything else is405(spec FR-002).
Toolchain / enforcement
No new category. build.gradle gains the application plugin and mainClass = 'xyz.briancorbin.pk.Main';
spectastic enforce is unchanged. The H2 file Main opens by default lives under data/, which
*.mv.db already ignores.
3 · Data model & contracts
The spec's Position view is the wire shape {"instrument": "AAPL", "quantity": 100} —
001's Position record, serialised. Nothing is stored; the projection is computed per request from
PositionService.current.
| Conceptual entity | Physical shape | Notes |
|---|---|---|
| Position view | application/json object {instrument: string, quantity: int64} | 404 {"error": "unknown instrument"} when nothing was ever booked; 405 for any method but GET. |
One resource, one verb. A consumer may rely on: a known instrument returns 200 with the current signed quantity as
a JSON integer; an unknown one returns 404 with an error string, never an empty body; and no request on
this surface — whatever the method — changes a position. The contract is authored at the proposed location
(specs/004-query-endpoint/contracts/) while the design is in flight and promoted to api/ when the
bundle lands.
5 · Grounding & evidence
Facts this plan rests on. No spike was needed: the one runtime question — is the JDK's HTTP server available to a Java 21 compile with no dependency — was settled by 001's spike.
| Claim the design rests on | Source | Status | Finding / note |
|---|---|---|---|
com.sun.net.httpserver.HttpServer compiles on the Java 21 toolchain with no dependency |
001 §5 — spike row 4 (jdk.httpserver) |
Grounded once in 001 so the stack was settled before this feature. | |
The read path is PositionService.current(instrument) → PositionRepository.find → the adapter's SELECT |
src/main/java/xyz/briancorbin/pk/hex/app/PositionService.java — current; JdbcPositionRepositoryAdapter.java — find |
Already exists; the handler adds no data path (spec NFR-001). current returns a flat position for an unknown instrument, so the handler distinguishes 404 by repository.find being empty — exposed as PositionService.lookup returning Optional. | |
Every write emits exactly one PositionChanged (002), so “no request mutates a position” is observable as zero events |
src/main/java/xyz/briancorbin/pk/hex/persistence/InMemoryPositionRepository.java — write |
The endpoint test subscribes a recorder and asserts it stays empty across every request — the observable, not the property. | |
A request-response <spec-contract> must carry path=; only shape="none" may omit it |
@spectastic/schema · rules/contract-declaration-shape.ts:80-84 |
Rules out declaring the HTTP surface without a contract; the OpenAPI sidecar follows (D-002). | |
| OpenAPI 3.1.0 is the format the interview seeds for request/response | .claude/commands/spectastic.design.md — “Contract format … request/response → OpenAPI” |
Nothing in the project argues for another format; no crown was needed. | |
HttpServer.create(new InetSocketAddress("localhost", 0), 0) binds an ephemeral port readable from getAddress().getPort() |
JDK 21 API — com.sun.net.httpserver.HttpServer#create, #getAddress |
Lets the test run with no fixed port; confirmed when PositionsEndpointTest runs. |
6 · Approach
hex.http.HttpApi wraps an HttpServer: start(), stop(), port(), and a
context per handler. PositionsHandler takes the instrument from the path, refuses anything but GET with
405, asks PositionService.lookup, and writes 200 {instrument, quantity} or
404 {error} through a ten-line Json helper. Main reads PK_PORT (default 8080) and
PK_DB (default data/pk), opens H2Database, builds a SyncEventBus, the JDBC adapter, the
RiskExposureView and PnlRevaluer, the PositionService, and starts the API. It imports no
java.sql.
7 · Alternatives considered
The HTTP stack was the only pick with contenders. Criteria: dependencies added, code a reader must hold, and whether the example's zero-framework domain stays believable.
| Option | Dependencies | Code to read | Fits the example | Total |
|---|---|---|---|---|
JDK com.sun.net.httpserver — chosen | 3 — none | 2 — ~60 lines of handler + JSON | 3 | 8 |
| Javalin 6 | 1 — Jetty tree | 3 | 1 | 5 |
| Spring Boot | 0 | 2 | 0 — the domain would stop being JDK-only in spirit | 2 |
8 · Decisions
D-001 · The JDK's HTTP server, no web framework
- Status
Accepted - Context
- Two routes (this and 006's health) and one JSON object. 001's spike compiled
HttpServeron the toolchain with no dependency; P-12 asks that adding one be a reviewed decision — this is the review. - Decision drivers
- zero dependencies · code volume · the JDK-only story
- Considered options
- Decision
HttpServerbehindhex.http.HttpApi; aJsonhelper for the two object shapes; Gradleapplicationplugin for./gradlew run.- Consequences
- +
./gradlew runstarts with one jar on the classpath (H2). − Anything beyond a handful of routes would want a framework; that is a later decision if it comes.
D-002 · Contract first: an OpenAPI 3.1 sidecar, promoted to api/ when the bundle lands
- Status
Accepted - Context
- P-7 requires a checked-in contract before the code behind an interface, and the validator will not accept a request/response declaration without a path (
contract-declaration-shape.ts:80). The interview seeds OpenAPI for request/response. - Decision
specs/004-query-endpoint/contracts/positions.openapi.yamlnow; declared effective pathapi/positions.openapi.yaml, coordinate namepositions; moved to the effective path on the bundle's last tick. The handler is written to it, not the other way round.- Consequences
- + A consumer (the reconciliation service, 010) has a target before the code exists. − Two copies exist for a moment at promotion; the move is a single
git mv.
D-003 · Read-only by construction: GET-only routing over PositionService.lookup
- Status
Accepted - Context
- Spec FR-002 forbids mutation; NFR-001 wants the existing adapter.
PositionService.currentexists; it flattens an unknown instrument to zero, so the handler needs anOptional-returninglookupto answer 404. - Decision
- Add
Optional<Position> lookup(String)to the use case (delegating tofind); the handler holds only the service; non-GET →405before any lookup. - Consequences
- + The handler cannot write: it has nothing to write with. + The test observes “no write” as zero events and an unchanged store. − One more method on the use case.
9 · Project structure
Only the new or changed paths.
build.gradle # + application plugin, mainClass
specs/004-query-endpoint/contracts/positions.openapi.yaml # the contract, proposed location (→ api/positions.openapi.yaml)
src/main/java/xyz/briancorbin/pk/Main.java # wiring: env PK_PORT, PK_DB; H2 · bus · adapter · consumers · API
src/main/java/xyz/briancorbin/pk/hex/http/HttpApi.java # HttpServer lifecycle; start/stop/port
src/main/java/xyz/briancorbin/pk/hex/http/PositionsHandler.java # GET /positions/{instrument} → 200 | 404; else 405
src/main/java/xyz/briancorbin/pk/hex/http/Json.java # the two object shapes, escaped
src/main/java/xyz/briancorbin/pk/hex/app/PositionService.java # + lookup(String): Optional<Position>
src/test/java/xyz/briancorbin/pk/hex/http/PositionsEndpointTest.java # SC-001: 200 / 404 / 405; zero events, store unchanged
10 · Risks & mitigations
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| The handler and the contract drift apart | M | M | The test's expected bodies are copied from the contract's examples; a schema-validating test is deferred with the contract tooling. |
A path like /positions/ or /positions/a/b hits an unexpected branch | M | L | Anything that is not exactly one non-empty segment is 404. |
| Port 8080 is taken on a developer machine | M | L | PK_PORT; the test never uses a fixed port. |
11 · Complexity tracking
Hand-written routing and JSON are the only complexity beyond the spec, and they replace a dependency tree; justified in D-001.
The store's contents are read as the adapter's role (pk_adapter), because NFR-001 asks for the existing adapter
and 003's adapter owns that connection. The reader role is for probes that bypass the port (006).
12 · Open questions
None outstanding — the stack, the contract format and the read path were anchored against 001's spike, the validator's own rule and the existing use case.
13 · Change log
- Status flipped Draft → Accepted on 18 Sep 2026 — zero remaining unchecked tasks (9/9); ./gradlew build green with PositionsEndpointTest (SC-001) verified passing and the process exercised with curl per author pre-confirmation for 003–007; contract promoted to api/positions.openapi.yaml. Sibling bundle (REQ-LIFECYCLE-005).
- Initial plan — JDK HTTP server, OpenAPI 3.1 sidecar authored first, GET-only routing over a new
lookup; six facts verified against source, no spike needed.