ADR-0072: The platform is a contract-first, reactive, two-language stack
ODD Platform is a reactive Spring WebFlux + R2DBC backend and a React/TypeScript SPA, with one OpenAPI contract generating both the server interfaces and the browser client.
Status
Accepted. Reconstructed from the codebase on 2026-05-31; the decision is live in the source today.
Context
The platform is a long-lived server with a rich browser UI and a high-fan-in ingestion surface. Two cross-cutting choices shape almost every file: how the server handles concurrency (a thread per request, or a reactive event loop), and how the Java server and the TypeScript browser agree on the shape of the API between them. Left implicit, both get re-decided per feature and drift — a blocking call slips into a reactive chain, or a UI type falls out of sync with the server's DTO and the mismatch is only found at runtime.
Decision
The platform commits to three coupled choices that together define the stack:
Reactive end-to-end. The backend is Spring WebFlux (not servlet MVC); data access is R2DBC (reactive PostgreSQL, through jOOQ's reactive operations); and the request path is non-blocking from controller to database. Every controller method returns
Mono/Flux— the uniformMono<ResponseEntity<T>>shape of ADR-0007 — and multi-step writes run inside a reactive@ReactiveTransactionalboundary.Contract-first. A single OpenAPI document is the source of truth for the HTTP API. The backend generates its controller interfaces (
*Api) from it — controllers are thin implementations (ADR-0001), scoped per resource by OpenAPI tag (ADR-0008) — and the frontend generates its API client from the same document. Neither side hand-writes the wire types.Two languages, one bridge. The server is Java/Spring; the UI is a React 18 + TypeScript single-page app built with Vite. They share no runtime — only the generated contract. The one artefact that must stay in sync is the OpenAPI document, and code generation on both sides keeps each honest to it.
Consequences
The API cannot silently drift between server and client. A change to the OpenAPI document regenerates both the Java interfaces and the TypeScript client, so a breaking change surfaces as a compile error on whichever side lags — not as a runtime 4xx discovered in the browser.
Reactivity is a whole-stack commitment, not a per-endpoint choice. The payoff is high concurrency on few threads — a good fit for the fan-in ingestion surface and for keeping PostgreSQL the only dependency (ADR-0071). The cost is that any blocking call on the request path (a synchronous driver, a blocking library) stalls the event loop, so contributors must keep the whole chain non-blocking and use the reactive idioms throughout.
Backend and frontend meet at the contract. A new endpoint is added to the OpenAPI document first, then implemented on each side; the contract leads, the code follows. This is a discipline as much as a tooling choice.
The wire field names are the contract's, not the client's — and the casing is not uniform. The contract names fields in
snake_caseas the house convention, and each generated client re-exposes them in its own idiom: the TypeScript client camelCases them in its*FromJSONconverters (test_results→testResults), and the Java models bind them with@JsonProperty("snake_case"). Code that goes through a generated client never sees the wire names; code that touches the raw wire — a test intercepting an HTTP response, a debugging proxy, a manualcurl— must use thesnake_casecontract names. The convention has exceptions: eight fields arecamelCasein the contract itself (authType,projectVersion,usedCount,hasNext,maxSize,fileName,dataEntityId,dataEntityName), so those reach the wire as written. The casing cannot be normalised without breaking every existing client, so the inconsistency is documented here rather than changed.The trade-offs the reactive choice carries at the edges are recorded in its instance ADRs — the uniform pipeline that hides per-endpoint error mapping (ADR-0007), and the transaction-boundary asymmetry where list-shaped reads stay outside a transaction while multi-step writes are wrapped in one.
Evidence
gradle/libs.versions.toml:45(spring-boot-starter-webflux, bundled at:120) +odd-platform-api/build.gradle:25(libs.bundles.r2dbc) +gradle/libs.versions.toml:72(r2dbc-postgresql) — the backend is reactive WebFlux over reactive Postgres; there is no servlet (spring-boot-starter-web) stack on the dependency set.odd-platform-api/.../controller/IngestionController.java:38(representative of every controller) — methods returnMono<ResponseEntity<...>>; reactive types flow down to the repository layer via jOOQ reactive operations (ADR-0007).odd-platform-api/.../service/DataEntityServiceImpl.java:197—@ReactiveTransactionalmarks the reactive transaction boundary for multi-step writes (≈40 usages across the service layer).odd-platform-api-contract/build.gradle:9-13,44— the backend generates its API code fromodd-platform-specification/openapi.yaml(openApiGenerate { inputSpec = "…/openapi.yaml" };compileJava.dependsOn tasks.openApiGenerate).odd-platform-ui/openapi-config.yaml— the frontend generates atypescript-fetchclient from the sameodd-platform-specification/openapi.yamlintoodd-platform-ui/src/generated-sources;odd-platform-ui/package.json— React 18 + TypeScript 5 + Vite.odd-platform-specification/components.yaml— field names aresnake_caseby convention (≈195; e.g.test_results,page_info,entity_classes) with eightcamelCaseexceptions (usedCount,hasNext,dataEntityName,dataEntityId,maxSize,fileName,projectVersion,authType). The generated TypeScript client camelCases the wire names in its converters (odd-platform-ui/src/generated-sources/models/DataQualityResults.tsmapsjson['test_results']→testResults); the Java models bind the wire names with@JsonProperty("…")(snake-named bindings such asaccess_token,entity_classes,external_name).
See also
ADR-0001 — Contract-first HTTP layer — the backend half of the contract: controllers are thin implementations of generated
*Apiinterfaces.ADR-0007 — Uniform reactive controller pipeline — the shape every reactive controller method takes.
ADR-0008 — OpenAPI tags scope the generated API interfaces — how the single contract is partitioned into per-resource interfaces.
ADR-0071 — PostgreSQL is the only required runtime dependency — R2DBC gives this reactive stack non-blocking access to that one datastore.
ADR-0070 — Ingestion is one wire contract shared by pull and push producers — the ingestion API is the other contract-first surface, served by this reactive stack.
Last updated