ADR-0007: Uniform reactive controller pipeline
ODD Platform's reactive controllers share one shape — methods return Mono<ResponseEntity<T>> via ResponseEntity::ok, and error translation is centralised in a single advice class.
Status
Accepted. Reconstructed from the codebase on 2026-05-30; the decision is live in the source today.
Context
ODD Platform's API server is built on Spring WebFlux — every endpoint is reactive. A reactive controller method can be shaped many ways: it can return a bare body type, a Mono<T>, a Flux<T>, or a Mono<ResponseEntity<T>>; it can translate its own errors into HTTP status codes, or defer that to a shared handler. Left unconstrained, a large team ends up with a mix of all of these, and a reader cannot predict the shape of an endpoint they have not seen.
A platform this size benefits from one answer: a single response shape every controller follows, and a single place where exceptions become HTTP responses.
Decision
Every controller method returns Mono<ResponseEntity<T>>, and the success path maps the result with .map(ResponseEntity::ok) (or .thenReturn(ResponseEntity.noContent().build()) for void / delete results). Controllers do not translate their own exceptions — there is no per-controller @ExceptionHandler.
Error translation is centralised in a single @RestControllerAdvice class, ControllerAdvice. It maps the platform's exception hierarchy to status codes in one place: BadUserRequestException → 400, NotFoundException → 404, UniqueConstraintException and CascadeDeleteException → 400, validation (WebExchangeBindException) → 400, GenAIException → 500, and a catch-all Exception → 500. Each is rendered into a consistent ErrorResponse body carrying a message, an error code, and resolvable / retryable flags.
A controller method is therefore a pure delegate: take the request, call the service, wrap the result with ResponseEntity::ok. Everything about how an error becomes an HTTP response lives in one class.
Consequences
Every endpoint has a predictable shape. A reader who has seen one controller method knows the form of all of them; a contributor writing a new one follows the same
flatMap(service::call).map(ResponseEntity::ok)pipeline.Error responses are consistent across the whole surface — the same body schema, status mapping, and resolvable/retryable semantics, because they all come from
ControllerAdvice. Adding a new exception type means adding one handler there, not editing controllers.A controller needing a non-200 success (for example a delete returning 204) opts in explicitly with
noContent()/thenReturn(...); the default isResponseEntity::ok.The trade-off: a controller's source does not show how its exceptions map to HTTP — that knowledge lives in
ControllerAdvice. The uniformity is bought by accepting that error mapping is read in one central place rather than next to each method.
Evidence
odd-platform-api/.../controller/exception/ControllerAdvice.java— the single@RestControllerAdvice:@ExceptionHandlermethods forBadUserRequestException(400),NotFoundException(404),UniqueConstraintException(400),CascadeDeleteException(400),WebExchangeBindException(400),GenAIException(500), andException(500), each rendered intoErrorResponse.odd-platform-api/.../controller/DataEntityController.java— the platform's largest controller: 40 methods returningMono<ResponseEntity<...>>(34 via.map(ResponseEntity::ok), 3 via.thenReturn(ResponseEntity...)), with no controller-level mapping annotations and no@ExceptionHandler.odd-platform-api/.../controller/SearchController.java— 7 methods, allMono<ResponseEntity<...>>ending in.map(ResponseEntity::ok); no controller-level error handling.TermController.javashows the same shape across 23 methods.Across the module, every controller returning
Mono<ResponseEntity<...>>uses the standardResponseEntitybuilders (ok/noContent/status) — there is no controller carrying its own exception translation.
See also
ADR-0001 — Contract-first HTTP layer — the controllers implement generated
*Apiinterfaces; the uniformMono<ResponseEntity<T>>return type is the shape those generated signatures take.ADR-0002 — Centralised path-matcher authorization — the companion "one place" decision for authorization; together they keep controllers as thin delegates.
Last updated