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.
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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
