Files
clean-architecture-backend-…/docs/web/repository-adaptation.md
T

23 KiB

Web platform — repository adaptation

The inbound HTTP API execution platform design (docs/web-superpowers-package/docs/superpowers/specs/2026-08-13-web-inbound-http-api-execution-platform-design.md) was written against a standalone repository. Four of its assumptions do not hold here, and this document records what each became and why, so a reader comparing the design to the tree is not left guessing whether a difference is a decision or a mistake.

1. Twenty-three Gradle modules became sub-packages of one leaf

The design lays the platform out as modules/web/web-core-api, web-contract, web-mvc and so on. This repository's src/config/architecture/modules.json is a fail-closed registry that owns the leaf list, and adding twenty-three leaves to it is an architecture decision the design cannot make on the repository's behalf. The JPA and GraphQL platforms reached the same fork and resolved it the same way, so this is the established answer rather than a new one.

The substitution only holds if the boundaries are enforced, because a Gradle dependency gate cannot see inside a leaf. WebStableModule declares each module's identity, package, purity grade and allowed edges; WebModuleBoundaryTest scans the real source tree and fails when the tree and the declaration disagree in either direction — an undeclared edge, an undeclared package, or a declared module with no source. Four negative fixtures prove each rule can fail.

Each enum constant is already shaped like a leaf specification, so promoting a module to its own Gradle path later is a registry edit rather than an archaeology exercise.

Design module Package under dev.caskeleton.adapter.inbound.web Status
web-core-api core planned (Task 2+)
web-contract contract planned
web-validation validation planned
web-error error present
web-pagination pagination present
web-idempotency idempotency present
web-idempotency-jpa idempotency.jpa planned
web-idempotency-redis idempotency.redis planned
web-versioning versioning planned
web-security-integration auth, authz present
web-observability observability present
web-openapi openapi planned
web-mvc mvc planned
web-webflux webflux planned
web-admin admin planned
web-operation-jpa operation.jpa planned
web-operation-messaging operation.messaging planned
web-spring-boot-starter-mvc autoconfigure.mvc planned
web-spring-boot-starter-webflux autoconfigure.webflux planned
web-testkit-* testkit, testkit.mvc, testkit.webflux, testkit.contract planned
conditional, cursor, http, filter, envelope, config, controller, ratelimit, settings present, pre-dates the design
fileserver.*, notification.* present, feature integrations rather than platform modules

A module is added to WebStableModule at the moment its package gains its first file, never before: a declared module with no source is a claim about a rename that has not happened.

2. Root package

The design uses io.backend.skeleton.web. This repository's package root is dev.caskeleton.adapter.inbound.web, and the registry, the ArchUnit rules and the composition root's component scan are all written against it.

3. Gradle DSL

The design's snippets are Kotlin DSL. Every build file in this repository is Groovy DSL, and src/build-logic carries the shared convention plugins the leaf inherits. The build logic is translated, not copied.

4. Spring Boot baseline

The design names "Spring Boot 4.1 BOM". This repository's baseline is 4.0.8 — see .vscode/settings.json for why the 4.1 minor line is a planned migration rather than a currency fix. Where the design depends on a 4.1-only API the difference is recorded at the call site rather than silently absorbed.

5. Sample application

The design's examples/web-platform-sample is the existing sample-portfolio leaf, which already carries the OpenAPI drift gate and the contract lanes the design's sample is specified to provide.

6. Idempotency: the design's store SPI is already owned by application-core

The design gives web-idempotency its own IdempotencyStore, IdempotencyRecord, IdempotencyState, IdempotencyScope and RequestFingerprint. This repository already has all five in application-core/idempotency, with a JPA adapter (PostgreSqlOwnerSafeIdempotencyStore, IdempotencyStoreAdapter) and a Redis cache behind them — which is what the design's Tasks 39 and 40 ask for, built in the direction the registry permits.

Implementing the design's SPI literally would have required adapter-outbound-persistence-jpa → adapter-inbound-web, an edge the registry forbids and one that points the wrong way regardless: the store is an application concern that two transports could share, not something the HTTP layer owns.

So the web leaf keeps only the parts that are genuinely HTTP and have no application-core equivalent, and everything else binds to the existing port:

Design type Here
IdempotencyStore, IdempotencyRecord, IdempotencyState, IdempotencyScope application-core/idempotency (already present)
RequestFingerprint application-core/idempotency — the web factory produces it
ResponseSnapshot application-core/idempotency/StoredResponse
IdempotencyKey web: the Idempotency-Key header's grammar and bounds
FingerprintHeaderPolicy web: which HTTP headers change what a request means
DeterministicCommandEncoder web: canonical JSON, because the application port's ofSha256(byte[]) hashes raw bytes
SemanticRequestFingerprintFactory web: builds the application type from operation, path identifiers, canonical body and selected headers

The last three are the substance the design adds over what was here. The application port's RequestFingerprint.ofSha256(byte[]) digests the raw request body, and raw bytes are not stable across a client library that reorders JSON members or a proxy that reformats — the design names that explicitly, and the semantic factory is the fix.

7. Durable operations: the design's web-operation-jpa cannot exist here

The design gives the web platform its own persistence module — modules/web/web-operation-jpa — holding a JPA store for long-running operations, with the port (DurableOperationStore) in web-core-api beside WebProblem.

src/config/architecture/modules.json forbids both halves of that:

  • :adapter:inbound:web may depend only on domain-core, application-core and shared-contract, so the web leaf cannot take a JPA dependency.
  • :adapter:outbound:persistence-jpa is an outbound adapter and cannot depend on an inbound one, so a JPA store cannot see a web type.

This is the same shape as §6 and takes the same resolution — the one the repository already used for idempotency, where IdempotencyStorePort lives in application-core and IdempotencyStoreAdapter in persistence-jpa:

Design type Here
DurableOperationStore application-core/operation/DurableOperationStorePort
OperationSubmission application-core/operation/DurableOperationSubmission
the stored operation application-core/operation/DurableOperation
the lease application-core/operation/OperationLease
the stored failure application-core/operation/OperationFailure — a code and an already-safe message, never an exception
JpaDurableOperationStore, JpaOperationEntity, V002__web_operation.sql persistence-jpa/operation/*, db/migration/postgresql/V11__durable_operation.sql
OperationResource, OperationStatus, OperationProgress, OperationId web operationasync — the HTTP projection
web operationasync/OperationResourceFactory, which is the projection itself

The split is not only a registry workaround; it puts each half where its invariants belong. A problem document is an HTTP concept and has no business being persisted, so the stored failure is a code plus a safe message and the factory turns it into a WebProblem through the same catalog and the same sanitiser as every synchronous error. An async failure therefore cannot publish something a synchronous one would have redacted, and a worker cannot extend the published code vocabulary by writing a row — an unrecognised code becomes INTERNAL_ERROR.

The state machine is enforced three times on purpose: in DurableOperation's constructor, in the CHECK constraints of V11__durable_operation.sql, and in the WHERE clause of every statement that changes a row. The constructor catches application bugs, the constraints catch any other writer, and the predicates make each transition atomic — a worker whose lease lapsed while it was still working cannot record a result over the worker that took over, because its UPDATE matches no row and it learns that from the affected count.

8. Budget enforcement: what the edge stops and what the application stops

The design (Task 49) requires that the difference between an edge proxy's limits and the application's own be written down rather than discovered during an incident. Two limits on the same dimension always exist, they are never equal, and which one fires decides what the caller sees.

Dimension Edge (nginx) Application Who answers when crossed
Request line / URI large_client_header_buffers (default 8k) maxUriBytes Edge first — nginx answers 414 with its own HTML
Request headers large_client_header_buffers maxHeaderBytes Edge first — nginx 400, no problem document
Request body client_max_body_size (default 1m) maxBodyBytes (platform ceiling 8 MiB) Whichever is smaller; nginx answers 413 with HTML
Query parameter count not enforced maxQueryParameters Application, always
JSON depth / array size not enforced maxJsonDepth, maxArrayElements Application, always
Execution time proxy_read_timeout (default 60s) maxExecutionTime (ceiling 2 min) Edge first if the platform's is larger
Response size not enforced maxResponseBytes (ceiling 32 MiB) Application, always

Two consequences worth stating, because both are counterintuitive:

An edge limit below the application's means clients never see a problem document. nginx answers its own HTML error page, so a client that branches on ProblemCode gets a body it cannot parse. Where a dimension matters to clients, the edge limit must be set above the application's so the application is the one that refuses.

An edge limit above the application's is not redundant. It is the only thing standing between the process and a body large enough to matter before the application's own meter has counted it. Both belong; only their ordering is a decision.

The enforcement itself never materializes a body to measure it. WebBudgetMeter counts bytes as they move — through a wrapped ServletInputStream on the servlet side and a doOnNext on the buffer flux on the reactive side — and throws at the byte that crosses. A check that read the body in order to size it would be the heap exhaustion the check exists to prevent, so the contract asserts directly that an oversized body never reaches the handler in full.

On the way out the split is between committed and not:

  • Not committed — the response is reset, which discards the partial body and the meter's count of it, and the overrun is answered as a problem document. Resetting only the buffer was the first implementation and it failed: the meter still held the count of the discarded bytes, so the small problem document was refused too and the client got the container's error page.
  • Committed — there is no status left to send. The exception propagates, the connection ends mid-document, and the client sees a truncated response. That is worse for the client than a clean error and better than a short response it would accept as complete.

Statuses are never restated. BudgetProblemMapper.statusFor reads ProblemCatalog through the violation's code; an earlier draft kept its own violation-to-status table and disagreed with the catalog on two entries, which requireStatusAgreement turned into a 500. One table, no drift.

9. The Nginx lane: what a real proxy caught that no unit test could

Task 55 asks for the proxy contract to be verified against a real Nginx. It is implemented as a separate nginxProxyTest source set with a Testcontainers-managed nginx:1.27-alpine, run by ./gradlew :adapter:inbound:web:webNginxProxyTest. Its own lane because it is the only one that needs Docker; folded into test, every developer's check would depend on a container runtime, and the usual end of that is an @Disabled nobody notices.

TLS is terminated in configuration, not in the container. The design describes an ssl listener. What the application can observe about TLS is exactly one thing — that the edge set X-Forwarded-Proto: https authoritatively — and a proxy that terminates TLS and one that declares the scheme produce an identical request upstream. Generating a certificate per run would add a second failure mode to a lane whose subject is header handling. The lane asserts the observable property; it does not assert that Nginx can do TLS.

The defect the lane found on its first run. The first configuration set the forwarded headers once at the server level and added only X-Forwarded-Prefix per location. Six of the ten cases failed. Nginx's inheritance rule for array directives is replacement: a single proxy_set_header inside a location discards every proxy_set_header inherited from server. So none of the security headers were sent, the application fell back to the upstream's own Host, and a client's X-Forwarded-Host would have been trusted.

That configuration reads as correct, is a shape found throughout the wild, and no test of the application could detect it — the application's forwarded-header handling is thoroughly unit-tested and every one of those tests still passed. The bug lived entirely in the seam. The headers now live in proxy_headers.conf and are included by each location.

Bound Where it fires What the client sees
client_max_body_size 2m Nginx, before the application Nginx's HTML 413, no problem document
application maxBodyBytes the application RFC 9457 problem, REQUEST_TOO_LARGE
unknown prefix Nginx 404; only /api/ and /dev-api/ are routed at all

The first row is the §8 table's consequence made concrete: an edge limit below the application's means clients never see a problem document for that dimension.

Advanced capabilities

The Advanced expansion plan asks for eleven Gradle modules under modules/web-advanced/. They are packages under advanced.** in this leaf, for the reason the Stable platform is one leaf: src/config/architecture/modules.json is fail-closed and owns the leaf list, and eleven entries to satisfy a directory layout is a registry change rather than an architecture one.

What the design wanted from the separation is enforced instead by two machine checks:

  • WEB-ARCH-ADV in WebArchitectureRules.stableDoesNotDependOnAdvanced() fails the build when a Stable class names an Advanced type. A feature flag decides whether a bean is created; it does nothing about a compile-time edge, and one such edge makes Stable unbuildable without Advanced.
  • WebStableModule declares eleven Advanced module identities with their own purity and edge sets, checked by WebModuleBoundaryTest. Nine of the eleven are CORE — pure policy with no framework import — which is stricter than the design's Gradle layout would have been.

Two modules are FRAMEWORK_BOUND and had to be: advanced-patch and advanced-codec. Jackson's tree model is the reason for the first — a merge patch's null-means-delete has no representation in a Java object, so the applier works on nodes — and XMLInputFactory is the reason for the second. A third, advanced-stream-encoding, is separated from the pure advanced-stream for the same reason: framing serializes, and serializing binds to Jackson.

What was adapted rather than copied

WebStreamEnvelope.Error is named Failure. A nested type called Error shadows java.lang.Error inside its own file, so an unrelated catch (Error e) there would catch the wrong thing. Error Prone's JavaLangClash refuses it outright, and the rename is the only difference from the design's sealed hierarchy.

WebStreamErrorPolicy's factory methods are startOver / resumeFromPosition / nothingToDo. The design's names collided with the record's own accessors, which Java rejects.

The codec backends are compile-only, and finding out why cost a broken composition root. WebCborMapperFactory and WebXmlMapperFactory are implemented, along with the parts that carry the failure modes: SecureXmlInputFactory (the DTD and external-entity defaults, both of which produce no error when they fire), CodecBudget (per representation, because a megabyte of CBOR can declare an array of a billion elements in a handful of bytes), and RepresentationNegotiationPolicy (three gates, because an Accept header is not evidence the route was tested against that codec).

jackson-dataformat-cbor and jackson-dataformat-xml were declared implementation first, so that a missing backend could not surface as a NoClassDefFoundError at the first request that negotiated one. That reasoning was wrong about what the jars do. Spring Boot's Jackson auto-configuration registers an xmlMapper and a cborMapper bean the moment each backend is on the runtime classpath, and Spring registers an XML message converter with it. Two things followed, and only the first was noisy:

  • The composition root held three ObjectMapper beans — webStrictObjectMapper, xmlMapper, cborMapper — so every @Autowired ObjectMapper became ambiguous and the application would not start. Six app-bootstrap tests failed with UnsatisfiedDependencyException.
  • Every deployment silently began accepting application/xml request bodies. An XXE surface, acquired by adding a dependency, on a capability that is supposed to be off unless a deployment names it.

So both are compileOnly plus testImplementation: the factories compile, their tests run against real backends, and the runtime classpath belongs to the deployment that enables the capability. WebRepresentation.available() turns an absent backend into a sentence naming the missing coordinate. RepresentationBackendScopeTest reads build.gradle and fails if either coordinate returns to implementation, because nothing else catches it — the codec's own tests pass either way, and the failure only appears in whatever composes this leaf.

The framing and the writers are separate, and both exist. NdjsonFraming and JsonSequenceFraming hold the contract; MvcStreamWriter, WebFluxStreamWriter and WebFluxSseAdapter are what actually put it on a response. There is no MVC SSE writer: this repository's NO_SSE_EMITTER ArchUnit rule forbids SseEmitter outright, so the servlet stack streams NDJSON and JSON-seq and SSE is reactive-only. An earlier pass stopped after the framing, which left the platform advertising SSE while nothing could serve it — the same "control reached by nothing" shape this leaf has caught five other times.

MessagingReplayBridge is WebStreamReplaySource, an interface this module implements nowhere. The design's requirement is that the web module store no durable event history; an implementation here would be the thing it forbids.

What the execution layer had to get right

Three of these were only found by building the adapter rather than the policy.

A merged heartbeat means a finished source never completes. Flux.interval is infinite, so source.mergeWith(heartbeat) holds the connection for the full maxStreamAge after the last item — thirty minutes of keepalives on a stream that ended. WebFluxSseAdapter therefore ends at the terminal envelope (takeUntil), and supplies one for a source that finished without emitting its own.

onBackpressureBuffer(n) does not close anything. It propagates demand, so a subscriber that stops requesting simply stops the source and the bound never fires. That is correct for a well-behaved source and useless as a slow-consumer policy, because the sources this carries push whether or not anybody asked. The working composition is onBackpressureBuffer(n) followed by onBackpressureError(), verified by mutation: removing the second half makes both slow-consumer tests fail. The merge prefetch is set to the same n, because its default of 256 would otherwise be a larger backlog sitting behind the configured one.

The blocking bridge's permit cannot be released in doFinally. That fires on the subscriber's cancel signal, which arrives while the callable is still blocked on a thread — releasing there hands the permit to another caller while the first still holds the database connection it was accounting for. Acquire and release are in the same try/finally inside the callable, and the consequence, stated rather than hidden, is that cancellation does not interrupt a blocking call.

On the servlet side the equivalent is that there is no disconnect event at all: the first sign a client is gone is a write that throws, which is why the heartbeat is the probe rather than a courtesy and why a failed beat is recorded as disconnect evidence.

One pre-existing flake was fixed

HttpThrottleFixture.awaitSlotTaken polled for a 503 while each probe spent quota, and refilled the quota only after the loop. On a machine loaded enough that the holding request took a while to occupy the slot, the quota ran out first and every remaining probe answered 429 — so the loop never saw its 503 and failed on the deadline, which reads as a capacity bug and is a fixture bug. It now refills before every probe and fails loudly if a probe is refused for quota immediately after a refill. Found because the new virtual-thread and blocking-bridge tests load the machine enough to trigger it.

Verification

cd src
./gradlew :adapter:inbound:web:test              # 861 tests, Advanced included
./gradlew :adapter:inbound:web:webAdvancedTest   # 147, the Advanced lane on its own
./gradlew :adapter:inbound:web:webCrossStackParityTest
./gradlew :adapter:inbound:web:webNginxProxyTest # Docker

See docs/web/advanced-capabilities.md, docs/web/streaming-contract.md, docs/web/patch-contract.md, docs/web/virtual-thread-profile.md, docs/web/openapi-32-compatibility.md, and docs/adr/ADR-WEB-ADV-00{1,2,3}-*.md.