skip to content

As a tech lead, how do you decide between returning DTOs, ResponseEntity, @ResponseStatus, and centralized shaping (ResponseBodyAdvice/@ControllerAdvice) to keep response handling consistent across a large codebase?

level: principalimportance: nice to knowfreq 32%

answer

  1. DTO default → @ResponseStatus fixed → ResponseEntity variable
  2. centralize errors: @RestControllerAdvice + ProblemDetail
  3. ResponseBodyAdvice for envelope, but many gotchas
  4. exclude String/Resource/streaming from wrapping
  5. enforce via ArchUnit + docs

basics

~20 s

Default to plain DTOs for simple 200s; use @ResponseStatus for fixed non-200 statuses; use ResponseEntity when status or headers vary per request. Centralize cross-cutting shaping (envelopes, error format) in @ControllerAdvice / ResponseBodyAdvice / an ExceptionHandler, not per-controller.

solid answer

~40 s

The goal is a uniform contract with minimal per-handler noise. Establish a convention: return DTOs directly for the common 200-with-body case; use @ResponseStatus for handlers whose status is always fixed (201/204/202) and that need no custom headers; reach for ResponseEntity only when status or headers vary at runtime (found/not-found, ETag, Location, Cache-Control). Push everything cross-cutting into central components: a single @RestControllerAdvice with @ExceptionHandler methods (or Spring 6 ProblemDetail/ErrorResponse) for the error contract, and ResponseBodyAdvice if you wrap all successful bodies in a common envelope. This keeps controllers thin and the contract consistent. Be cautious with global envelope wrapping — it complicates streaming, file downloads, and ResponseEntity bodies, and can surprise clients — so many teams prefer standard status codes plus ProblemDetail over a custom envelope. Document the convention and enforce via review/ArchUnit.

code

java · 18 lines
java
// Central error contract — one place, consistent shape (Spring 6 ProblemDetail)
@RestControllerAdvice
class ApiExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    ProblemDetail onNotFound(ResourceNotFoundException ex) {
        ProblemDetail pd = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        pd.setTitle("Resource not found");
        pd.setType(URI.create("https://errors.example.com/not-found"));
        return pd; // controllers no longer set 404 themselves
    }
}

// Controllers stay thin: DTO for 200, @ResponseStatus for fixed, ResponseEntity for variable
@ResponseStatus(HttpStatus.NO_CONTENT)
@DeleteMapping("/items/{id}")
void delete(@PathVariable Long id) { items.delete(id); }

go deeper

for a junior

Understands the individual mechanisms but not the org-level tradeoffs.

for a middle

Can pick per-handler return type but tends to repeat cross-cutting logic per controller.

for a senior

Centralizes error handling with @ControllerAdvice and knows ResponseBodyAdvice exists and its risks.

for a principal

Sets and enforces a codebase-wide convention, weighs envelope-vs-ProblemDetail, and accounts for streaming/String/error edge cases and tooling.

## The decision framework At scale, the failure mode is *inconsistency*: some endpoints wrap bodies, some don't; error shapes differ; statuses are ad hoc. The lead's job is to pick defaults and centralize the cross-cutting parts. **Per-handler return type — decision order:** 1. **Plain DTO** — the default for "always 200, body, no special headers." Least boilerplate, most readable. 2. **`@ResponseStatus`** — when the status is a **fixed non-200** and no custom headers are needed (e.g. `201` on create, `204` on delete). Declarative and clean, especially on `void` handlers. 3. **`ResponseEntity`** — when status and/or headers **vary at runtime**: 200 vs 404, conditional 304, `Location`, `ETag`, `Cache-Control`, content-disposition for downloads. **Cross-cutting concerns — centralize, don't repeat:** - **Error contract**: one `@RestControllerAdvice` with `@ExceptionHandler` methods that return a consistent shape. On Spring 6+/Boot 3, prefer **`ProblemDetail`** (RFC 9457) and `ResponseEntityExceptionHandler`/`ErrorResponse` so framework and app errors share a format. This removes try/catch and status decisions from every controller. - **Uniform success envelope** (e.g. `{ data, meta }`): implement once via **`ResponseBodyAdvice<T>`** (`beforeBodyWrite`) rather than wrapping in each handler. But weigh the cost (below). - **Common headers** (correlation id, cache policy): a `Filter`/`HandlerInterceptor` or `ResponseBodyAdvice`, not per-handler `.header(...)`. ## Tradeoffs of global envelope wrapping (`ResponseBodyAdvice`) Pros: uniform client-facing shape, single place to add metadata. Cons and gotchas: - **`ResponseEntity` bodies** still pass through `beforeBodyWrite`; you must handle the case where the body is already an envelope or is `Void`. - **`String` return values** use `StringHttpMessageConverter`; wrapping them in an object envelope can break because the converter differs — you must guard `supports(...)`/converter type. - **File downloads / streaming / `Resource` / `StreamingResponseBody`** must be **excluded** or you corrupt binary output. - **Error bodies** (`ProblemDetail`) shouldn't be double-wrapped. - Adds a hidden layer that complicates debugging and client codegen (OpenAPI) unless modeled explicitly. Many mature teams therefore **avoid custom envelopes**, leaning on standard **HTTP status codes + `ProblemDetail`** which tooling understands natively. ## Consistency enforcement - Document the convention (which return type when) in the team's backend guide. - Enforce structurally: **ArchUnit** rules (e.g. controllers must not catch-and-map exceptions locally; must return approved types), and code review. - Keep controllers **thin**: mapping + status/headers only; business logic and error translation live elsewhere. ## Testing implications - `ResponseEntity` and DTOs are trivially unit-testable (plain values). - Central advice needs slice tests (`@WebMvcTest`) asserting the error/envelope shape once, rather than repeating assertions per controller. ## Summary heuristic > DTO by default → `@ResponseStatus` for fixed status → `ResponseEntity` for variable status/headers → `@ControllerAdvice`/`ProblemDetail`/`ResponseBodyAdvice` for anything cross-cutting. Prefer standard status + ProblemDetail over bespoke envelopes unless a strong product reason exists.

  • Why might a global ResponseBodyAdvice that wraps every body in {data:...} break some endpoints?
    String returns use StringHttpMessageConverter (wrapping yields a type mismatch), and Resource/StreamingResponseBody/file downloads would be corrupted. You must guard supports()/converter type and exclude binary and error (ProblemDetail) responses.
  • What's the modern alternative to a custom error envelope in Spring 6/Boot 3?
    ProblemDetail (RFC 9457) via ResponseEntityExceptionHandler/ErrorResponse — a standardized error shape that framework and application errors share, understood by tooling and clients without custom parsing.
  • How do you keep the convention from eroding across many teams?
    Document it, centralize error/envelope handling, and enforce structurally with ArchUnit rules plus code review so controllers can't reintroduce ad-hoc status/error handling.

saying these in an interview costs you the question

  • Duplicating try/catch and status mapping in every controller instead of centralizing
  • Blindly wrapping all bodies in an envelope without excluding String/Resource/streaming/errors
  • Inventing a custom error format when ProblemDetail already standardizes it

context