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 is ResponseEntity::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: @ExceptionHandler methods for BadUserRequestException (400), NotFoundException (404), UniqueConstraintException (400), CascadeDeleteException (400), WebExchangeBindException (400), GenAIException (500), and Exception (500), each rendered into ErrorResponse.

  • odd-platform-api/.../controller/DataEntityController.java — the platform's largest controller: 40 methods returning Mono<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, all Mono<ResponseEntity<...>> ending in .map(ResponseEntity::ok); no controller-level error handling. TermController.java shows the same shape across 23 methods.

  • Across the module, every controller returning Mono<ResponseEntity<...>> uses the standard ResponseEntity builders (ok / noContent / status) — there is no controller carrying its own exception translation.

See also

Last updated