> For the complete documentation index, see [llms.txt](https://docs.opendatadiscovery.org/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.opendatadiscovery.org/developer-guides/architecture-decision-log/adr-0007-uniform-reactive-controller-pipeline.md).

# ADR-0007: Uniform reactive controller pipeline

## 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, framework `ResponseStatusException`s → their own embedded status (an unmatched route stays 404, invalid request input stays 400; added in 0.28.0 — before that the catch-all re-branded them 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), `ResponseStatusException` (pass-through — the framework's own 4xx/5xx kept, 4xx logged at WARN), 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

* [ADR-0001 — Contract-first HTTP layer](/developer-guides/architecture-decision-log/adr-0001-openapi-generated-controller-interfaces.md) — the controllers implement generated `*Api` interfaces; the uniform `Mono<ResponseEntity<T>>` return type is the shape those generated signatures take.
* [ADR-0002 — Centralised path-matcher authorization](/developer-guides/architecture-decision-log/adr-0002-centralised-path-matcher-authorization.md) — the companion "one place" decision for authorization; together they keep controllers as thin delegates.
