12 KiB
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 47–48, 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) isFRAMEWORK_BOUND. STOMP here is Spring Messaging, and folding it intoadvancedwould 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
advancedby longest-prefix, which is what letsadvanced.codec.cborexist 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/.