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 uniform Mono<ResponseEntity<T>> shape of ADR-0007 — and multi-step writes run inside a reactive @ReactiveTransactional boundary.

  • 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_case as the house convention, and each generated client re-exposes them in its own idiom: the TypeScript client camelCases them in its *FromJSON converters (test_resultstestResults), 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 manual curl — must use the snake_case contract names. The convention has exceptions: eight fields are camelCase in 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 return Mono<ResponseEntity<...>>; reactive types flow down to the repository layer via jOOQ reactive operations (ADR-0007).

  • odd-platform-api/.../service/DataEntityServiceImpl.java:197@ReactiveTransactional marks 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 from odd-platform-specification/openapi.yaml (openApiGenerate { inputSpec = "…/openapi.yaml" }; compileJava.dependsOn tasks.openApiGenerate).

  • odd-platform-ui/openapi-config.yaml — the frontend generates a typescript-fetch client from the same odd-platform-specification/openapi.yaml into odd-platform-ui/src/generated-sources; odd-platform-ui/package.json — React 18 + TypeScript 5 + Vite.

  • odd-platform-specification/components.yaml — field names are snake_case by convention (≈195; e.g. test_results, page_info, entity_classes) with eight camelCase exceptions (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.ts maps json['test_results']testResults); the Java models bind the wire names with @JsonProperty("…") (snake-named bindings such as access_token, entity_classes, external_name).

See also

Last updated