Goodbye ResponseEntity: 4 Declarative Patterns for Cleaner Spring Boot APIs

The article shows how overusing ResponseEntity in Spring Boot controllers makes code noisy and mixes business logic with HTTP handling, and introduces four declarative alternatives—@ResponseStatus on methods or exceptions, direct object returns, global ProblemDetail via @ControllerAdvice, and a ResponseBodyAdvice wrapper—to produce cleaner, more testable APIs.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Goodbye ResponseEntity: 4 Declarative Patterns for Cleaner Spring Boot APIs

Many Spring Boot projects use ResponseEntity as the default return type, which quickly leads to verbose controllers where business logic and HTTP response handling are intertwined. The article presents four declarative designs that eliminate the need for explicit ResponseEntity usage.

Pattern 1 – Use @ResponseStatus on methods and exceptions

Problem: HTTP status codes are set imperatively inside method bodies, making them invisible from the signature. Developers must read the method implementation or documentation to know the response status.

// ❌ Status code hidden inside method body
@PostMapping
public ResponseEntity<UserDto> createUser(@RequestBody CreateUserRequest req) {
    return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(req));
}

Solution: Annotate methods with @ResponseStatus so the status is declared at the method level, and return the domain object directly.

// ✅ Status declared declaratively
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserDto createUser(@RequestBody CreateUserRequest req) {
    return userService.create(req);
}

@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
    userService.delete(id);
}

The annotation can also be placed on exception classes, allowing the HTTP status to be bound to the exception type.

// Exception carries its own status code
@ResponseStatus(HttpStatus.NOT_FOUND)
public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(Long id) {
        super("User not found: " + id);
    }
}

Controllers can now simply throw UserNotFoundException and receive a 404 response without any try‑catch or manual ResponseEntity construction.

Pattern 2 – Return domain objects directly

Problem: For ordinary 200 responses developers still wrap the result in ResponseEntity.ok(...), adding unnecessary boilerplate and complicating unit tests.

// ❌ Redundant wrapper for a plain 200 response
@GetMapping("/{id}")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
    UserDto user = userService.findById(id);
    return ResponseEntity.ok(user);
}

Solution: Return the object itself; Spring’s HttpMessageConverter handles serialization and automatically uses the default 200 status.

// ✅ Direct return – Spring handles the rest
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) {
    return userService.findById(id);
}

@GetMapping
public List<UserDto> listUsers() {
    return userService.findAll();
}

If a custom header is required, inject HttpServletResponse and set the header manually, keeping the return type simple.

// ✅ Add custom header without ResponseEntity
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public UserDto createUser(@RequestBody CreateUserRequest req,
                         HttpServletResponse response) {
    UserDto created = userService.create(req);
    response.setHeader("Location", "/api/users/" + created.id());
    return created;
}

Pattern 3 – Global error handling with ProblemDetail and @ControllerAdvice

Problem: Error handling is scattered across controllers; each method may return a different error structure, forcing clients to adapt to multiple formats.

// ❌ Repeated error handling in each controller method
@GetMapping("/{id}")
public ResponseEntity<?> getUser(@PathVariable Long id) {
    try {
        return ResponseEntity.ok(userService.findById(id));
    } catch (UserNotFoundException ex) {
        return ResponseEntity.status(404)
            .body(Map.of("error", ex.getMessage()));
    }
}

Solution: Define a single @RestControllerAdvice that converts exceptions into the standard RFC 9457 ProblemDetail format.

@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    @ResponseStatus(HttpStatus.NOT_FOUND)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex,
                                         HttpServletRequest req) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setInstance(URI.create(req.getRequestURI()));
        return problem;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = ex.getBindingResult().getFieldErrors()
            .stream()
            .collect(Collectors.toMap(FieldError::getField,
                                      FieldError::getDefaultMessage,
                                      (a, b) -> a));
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
        problem.setProperty("errors", errors);
        return problem;
    }

    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ProblemDetail handleUnexpected(Exception ex) {
        log.error("Unhandled exception", ex);
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
    }
}

All error responses now share a uniform JSON shape, e.g.:

{
  "status": 404,
  "title": "Not Found",
  "detail": "User not found: 42",
  "instance": "/api/users/42"
}

Pattern 4 – Automatic success‑response wrapping with ResponseBodyAdvice

Problem: Teams often enforce a global success envelope (e.g., {"data":..., "traceId":...}) by manually wrapping each controller return, which leads to mismatched method signatures and easy omissions.

// ❌ Manual wrapping – signature no longer reflects actual payload
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<UserDto>> getUser(@PathVariable Long id) {
    return ResponseEntity.ok(ApiResponse.success(userService.findById(id)));
}

Solution: Implement a ResponseBodyAdvice that intercepts every response (except ProblemDetail) and wraps it in a uniform envelope.

@ControllerAdvice
public class EnvelopeAdvice implements ResponseBodyAdvice<Object> {

    @Override
    public boolean supports(MethodParameter returnType,
                          Class<? extends HttpMessageConverter<?>> converterType) {
        // Skip ProblemDetail – error responses have their own format
        return !returnType.getParameterType().isAssignableFrom(ProblemDetail.class);
    }

    @Override
    public Object beforeBodyWrite(Object body,
                                 MethodParameter returnType,
                                 MediaType selectedContentType,
                                 Class<? extends HttpMessageConverter<?>> converterType,
                                 ServerHttpRequest request,
                                 ServerHttpResponse response) {
        String traceId = MDC.get("requestId");
        return new ApiResponse<>(body, traceId);
    }
}

// Uniform envelope record
public record ApiResponse<T>(T data, String traceId) {}

Controllers can now return plain domain objects:

// ✅ Controller returns the real object
@GetMapping("/{id}")
public UserDto getUser(@PathVariable Long id) {
    return userService.findById(id);
}

The final JSON sent to the client looks like:

{
  "data": {"id":42,"name":"Alice"},
  "traceId":"a3f9c2d1-..."
}

These four patterns together make Spring Boot APIs more declarative, reduce boilerplate, improve testability, and enforce consistent error and success response formats.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootAPI designControllerAdviceResponseStatusResponseBodyAdviceResponseEntityProblemDetail
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.