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

12 KiB
Raw Blame History

WebSocket platform: how the design maps onto this repository

The design models the platform as eighteen Gradle modules under modules/websocket. This repository's fail-closed registry (src/config/architecture/modules.json) owns the leaf list, so those modules are packages inside the registered :adapter:inbound:websocket leaf — the same resolution the JPA, GraphQL and web platforms reached.

That is only honest if the boundaries are machine-checked, so WebSocketStableModule declares each module's package, its purity grade and its exact allowed edges, and WebSocketModuleBoundaryTest scans the source tree and fails when the two disagree in either direction. Promoting a package to its own Gradle leaf later is a registry edit rather than an archaeology exercise.

Where the module map deviates from the design, and why

Design places it in Here Reason
WebSocketSubprotocolName in websocket-protocol core The connection context must name the negotiated token, and the context is core — leaving the type in protocol made core depend on protocol while protocol already depended on core. The boundary test refused the cycle. The negotiation policy stays in protocol.
StrictWebSocketJsonCodec in websocket-protocol codec (its own FRAMEWORK_BOUND module) protocol is CORE here, and a Jackson import in a CORE module is refused. Splitting is better than relaxing the rule: the envelope's field rules stay testable with no mapper, and everything that touches a parser sits in one package a reviewer can read end to end.
budget -> core core -> budget budget imports nothing from core — numbers depend on nothing. The endpoint profile, which is core, has to name a budget. The declared direction was simply backwards.
stomp module The pre-existing STOMP-over-SockJS channel predates this platform and still ships. Declared so the boundary is complete rather than excused; it has no edge to any platform module and none to it.

What the design's rules actually prevent

A few of the design's requirements read as style and are not. These are the ones worth keeping.

websocket-core-api names no framework. Stricter here than in the HTTP platform, because a connection is a long-lived object owned by a container and reaching for the container's own session type is tempting from everywhere. A CORE module has no WebSocketSession, so the same decision serves both runtimes and is testable without a server. The detector's framework list was missing tools.jackson (this repo runs Jackson 3, not 2) — a CORE module could have imported a mapper unnoticed. Fixed in both this leaf and the web leaf.

No Java class name on the wire. A FQCN publishes the package layout, breaks every client on a rename, and makes the receiver's type resolution an attack surface. WebSocketMessageType refuses anything that looks like one; the manifest binds published names to records, and records cannot run code while being populated.

The payload is an encoded string, not a Map. A map accepts any shape, defers validation to whichever handler reaches for a missing key, makes an entity trivially serializable onto the wire, and brings unbounded nesting with it.

Handlers cannot write. WebSocketHandlerContext has no session and no write method. This is what makes ordering, backpressure and the drain sequence guarantees rather than conventions — a handler that could write directly would bypass the queue, and every promise would hold only for the handlers that cooperated.

Two findings from building it

Tomcat's graceful shutdown does not close WebSocket connections. It waits for in-flight requests, and an established WebSocket is not a request — so the shutdown completes with the connections still open and they die when the socket is torn down. The client sees a 1006, indistinguishable from a network failure, which sends it into its most aggressive reconnect path at exactly the moment the fleet is restarting. PlatformWebSocketHandler therefore implements SmartLifecycle and closes its own connections with 1001 going away, at a phase that runs before the web server stops. Found by TomcatWebSocketAbuseIT, which failed on its first run.

Both runtimes on one classpath is a silent outage. Spring Boot deduces one application type from what is present, and picks the servlet one. A deployment that declared reactive endpoints and shipped both starts, reports healthy, and never answers. WebSocketStackExclusivity reads what the classpath will actually produce and fails startup with a sentence explaining it.

Lanes

cd src
./gradlew :adapter:inbound:websocket:test                  # unit, boundary, architecture, Tomcat runtime
./gradlew :adapter:inbound:websocket:websocketJettyTest    # the second servlet container
./gradlew :adapter:inbound:websocket:websocketNginxTest    # real Nginx; needs Docker, fails without it

The Nginx lane carries a deliberately broken configuration alongside the correct one (nginx-no-upgrade.conf) so the lane proves its own assertions can fail. A contract that only ever runs against a correct configuration cannot tell whether it is checking anything.

The Stable behaviours that had no code

A late audit compared every named type in the Stable plan against the source tree rather than against memory, and found six that nothing implemented. They are listed because the way they were missed is more useful than the fact that they were: each is a behaviour under failure, and the plan named it inside a task whose other half was already built — so the task read as done.

Behaviour Where it lives now
A queue observation an operator can read, separating a configured drop from a lossless overflow outbound/OutboundQueueSnapshot + OutboundQueue.snapshot()
A timed-out correlation id that is remembered, so a late answer is not attached to a reused id handler/LateResponseTombstone
Pooled-buffer retention on the reactive stack, bounded and counted webflux/WebSocketDataBufferPolicy + WebSocketDataBufferLifecycle
What a proxy in front of this platform has to do release/WebSocketNginxProxyProfile
What a rolling restart has to demonstrate, in order release/WebSocketRollingRestartScenario
What the platform must show before promotion release/WebSocketStableReleaseGate

The three release types share a module identity (RELEASE, CORE) that names no other module. That is deliberate: a gate importing the parts it gates would be satisfiable by construction — the evidence and the checklist would come from the same source. They are predicates over facts a release engineer supplies, so a missing runtime stays a missing runtime.

WebSocketStableReleaseGate overlaps advanced/release/AdvancedPromotionGate in shape and differs in one condition that matters. Advanced capabilities are off unless a deployment names them, so a broken one affects whoever enabled it; Stable is what every deployment gets, so its evidence has to cover every runtime it claims — Tomcat, Jetty, Reactor Netty and Nginx — rather than the one the author happened to test. It also refuses to promote an artifact containing an Advanced type. WS-ARCH-6 already refuses that as a source edge; nothing in it notices a type that arrived through packaging, and the effect is identical — Stable that does not build without Advanced is a naming convention.

Not yet implemented

Tasks 4748, the browser matrix. The design asks for a protocol test client driven through Chromium, Firefox and WebKit. Not built: it needs Playwright and a browser download per engine, which is a dependency and a network requirement this template does not otherwise carry, and a browser lane that silently skips when the browsers are absent is worth less than no lane. The properties it would cover that the Java client does not — that a browser cannot set headers on the WebSocket constructor, and that it surfaces close codes to page script — are the reasons ONE_TIME_TICKET and the standard close codes exist, and both are asserted at the unit level.

Advanced capabilities

The Advanced expansion plan asks for fifteen Gradle modules under modules/websocket-advanced/. They are packages under advanced.** in this leaf, for the same reason the Stable platform is — src/config/architecture/modules.json is fail-closed and owns the leaf list, and a fifteen-leaf addition to satisfy a directory layout is a change to the registry, not to the architecture. The separation the design wanted is enforced by WebSocketStableModule and by WS-ARCH-6, which fails the build when a Stable class names an Advanced one. A feature flag decides whether a bean exists; it does nothing about a compile-time edge, and ArchUnit does.

Three of the fifteen needed their own module identity rather than sharing advanced:

  • advanced-stomp (advanced.stomp) is FRAMEWORK_BOUND. STOMP here is Spring Messaging, and folding it into advanced would have relaxed that module's purity for every capability in it.
  • advanced-stomp-rabbit (advanced.stomp.rabbit) is separate again. The adapter parses a protocol; the relay opens a TCP connection to somebody else's broker and makes every delivery depend on it. Different blast radius, so a deployment can refuse one and keep the other.
  • Everything else resolves to advanced by longest-prefix, which is what lets advanced.codec.cbor exist without its own edge set.

What was adapted rather than copied

Task 8's presence record shape. The design specifies (actorFingerprint, activeConnectionCount, state, lastObservedAt) with a four-state observation model. Implemented with WebSocketActorReference in place of a bare fingerprint string — it carries the fingerprint and refuses to be constructed from raw identity, which is the property the design was buying with the field name. The four states are implemented as specified; PresenceState.STALE and OFFLINE are distinct because collapsing them reports every user as disconnected during a Redis partition, when the connections are fine and the index went dark.

Task 14's Protobuf codec. The descriptor compatibility gate and the profile are implemented and tested. The encode/decode path is not: a Protobuf codec without generated message classes has nothing to encode, and generating them requires a .proto source this template does not have and should not invent. DescriptorCompatibilityGate is the part with the failure mode worth guarding — a changed or reused field number reinterprets bytes already on the wire, and during a rolling deploy both descriptor versions are live, so the receiver reads the wrong field without erroring.

Task 15's CBOR codec. Same shape, same reason: CborCodecProfile fixes canonical encoding and the duplicate-key policy, which are the two settings that decide whether two systems reading the same bytes agree. WebSocketCborCodec implements the encode/decode path on top of it, and SchemaParity is what stops it publishing a different catalog than JSON.

jackson-dataformat-cbor and protobuf-java are compileOnly plus testImplementation, not implementation. Both jars change an adopter's behaviour by their mere presence — Spring Boot registers a cborMapper bean for the first and Spring registers a Protobuf message converter for the second — so an adopting composition root would acquire both without enabling either capability. The sibling web leaf shipped exactly that mistake and it broke the composition root outright; see docs/web/repository-adaptation.md. WebSocketBinaryCodecBackend turns an absent backend into a sentence naming the missing coordinate, and BinaryCodecBackendScopeTest reads build.gradle and fails if either returns to implementation.

Task 12's relay configuration. RabbitBrokerRelayConfiguration contributes only the broker, not the destination prefixes. Two WebSocketMessageBrokerConfigurer beans each setting the application prefix produce whichever ran last, silently — the same failure StompBrokerExclusivity exists to catch between the two STOMP channels.

Verification

cd src
./gradlew :adapter:inbound:websocket:test                  # 509 tests, Advanced included
./gradlew :adapter:inbound:websocket:websocketJettyTest
./gradlew :adapter:inbound:websocket:websocketNginxTest

See docs/adr/ADR-WS-002-resume-and-cluster.md, docs/adr/ADR-WS-003-stomp-and-broker-relay.md, docs/websocket/advanced-support-matrix.md and docs/websocket/runbooks/.