When APIs grow, error handling usually becomes… creative:
- sometimes a plain message string
- sometimes a Spring default HTML error page
- sometimes stack traces leak into JSON
- validation errors look totally different from business errors
- your frontend has to handle 7 shapes of “error”
A consistent error envelope is not “nice to have”. It’s a contract.
This post shows a pragmatic approach to implement a predictable error format in Spring Boot, covering:
- Bean Validation errors (400)
- JSON parse/type errors (400)
- Custom business errors (409/422/etc.)
- Not found (404)
- A safe fallback (500)
- A trace ID you can grep in logs
- MockMvc tests so it stays consistent forever
The error envelope (contract)
Here’s a shape that’s boring—and that’s exactly why it works:
{
"timestamp": "2026-02-22T18:20:31.123Z",
"status": 400,
"error": "invalid.request",
"message": "Request validation failed",
"path": "/api/users",
"traceId": "9f2c1f0b1d1a4d0f",
"details": [
{ "field": "email", "reason": "must be a well-formed email address" },
{ "field": "age", "reason": "must be greater than or equal to 18" }
]
}
Notes:
-
error: stable machine-readable code (great for FE) -
message: human-readable summary -
details: optional list for per-field/per-issue reasons -
traceId: helps correlate client errors ↔ server logs quickly
Step 1) Define the response model
Use a small DTO (records work great):
import java.time.Instant;
import java.util.List;
public record ApiErrorResponse(
Instant timestamp,
int status,
String error,
String message,
String path,
String traceId,
List<ApiErrorDetail> details
) {
public static ApiErrorResponse of(
int status,
String error,
String message,
String path,
String traceId,
List<ApiErrorDetail> details
) {
return new ApiErrorResponse(Instant.now(), status, error, message, path, traceId, details);
}
}
public record ApiErrorDetail(String field, String reason) {
public static ApiErrorDetail of(String field, String reason) {
return new ApiErrorDetail(field, reason);
}
}
Step 2) Add a traceId (simple and production-friendly)
If you already have a request ID from a gateway, reuse it. Otherwise, generate one.
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.MDC;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
@Component
public class TraceIdFilter extends OncePerRequestFilter {
public static final String TRACE_ID = "traceId";
private static final String HEADER = "X-Trace-Id";
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String traceId = request.getHeader(HEADER);
if (traceId == null || traceId.isBlank()) {
traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
}
MDC.put(TRACE_ID, traceId);
response.setHeader(HEADER, traceId);
try {
filterChain.doFilter(request, response);
} finally {
MDC.remove(TRACE_ID);
}
}
}
Now every log line can include %X{traceId} in your logging pattern.
Step 3) Create a single place for all exception mapping
Use @RestControllerAdvice. If you already extend ResponseEntityExceptionHandler, you can override Spring’s default handlers too.
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.MDC;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.util.List;
import static java.util.Collections.emptyList;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static String traceId() {
String v = MDC.get(TraceIdFilter.TRACE_ID);
return (v == null || v.isBlank()) ? null : v;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiErrorResponse> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpServletRequest req
) {
List<ApiErrorDetail> details = ex.getBindingResult().getAllErrors().stream()
.map(err -> {
if (err instanceof FieldError fe) {
return ApiErrorDetail.of(fe.getField(), fe.getDefaultMessage());
}
return ApiErrorDetail.of(null, err.getDefaultMessage());
})
.toList();
ApiErrorResponse body = ApiErrorResponse.of(
400,
"invalid.request",
"Request validation failed",
req.getRequestURI(),
traceId(),
details
);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiErrorResponse> handleConstraintViolation(
ConstraintViolationException ex,
HttpServletRequest req
) {
List<ApiErrorDetail> details = ex.getConstraintViolations().stream()
.map(v -> ApiErrorDetail.of(
v.getPropertyPath() == null ? null : v.getPropertyPath().toString(),
v.getMessage()
))
.toList();
ApiErrorResponse body = ApiErrorResponse.of(
400,
"invalid.request",
"Constraint validation failed",
req.getRequestURI(),
traceId(),
details
);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(HttpMessageNotReadableException.class)
public ResponseEntity<ApiErrorResponse> handleNotReadable(
HttpMessageNotReadableException ex,
HttpServletRequest req
) {
ApiErrorResponse body = ApiErrorResponse.of(
400,
"invalid.json",
"Malformed JSON or invalid field type",
req.getRequestURI(),
traceId(),
List.of(ApiErrorDetail.of(null, rootCauseMessage(ex)))
);
return ResponseEntity.badRequest().body(body);
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiErrorResponse> handleTypeMismatch(
MethodArgumentTypeMismatchException ex,
HttpServletRequest req
) {
String field = ex.getName();
ApiErrorResponse body = ApiErrorResponse.of(
400,
"invalid.request",
"Invalid value for parameter",
req.getRequestURI(),
traceId(),
List.of(ApiErrorDetail.of(field, ex.getMessage()))
);
return ResponseEntity.badRequest().body(body);
}
// Example custom app exceptions
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ApiErrorResponse> handleNotFound(
NotFoundException ex,
HttpServletRequest req
) {
ApiErrorResponse body = ApiErrorResponse.of(
404,
"not.found",
ex.getMessage(),
req.getRequestURI(),
traceId(),
emptyList()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
@ExceptionHandler(ConflictException.class)
public ResponseEntity<ApiErrorResponse> handleConflict(
ConflictException ex,
HttpServletRequest req
) {
ApiErrorResponse body = ApiErrorResponse.of(
409,
"conflict",
ex.getMessage(),
req.getRequestURI(),
traceId(),
emptyList()
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
}
// Fallback: never leak stack traces to clients
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiErrorResponse> handleUnexpected(
Exception ex,
HttpServletRequest req
) {
ApiErrorResponse body = ApiErrorResponse.of(
500,
"internal.error",
"Unexpected server error",
req.getRequestURI(),
traceId(),
emptyList()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
private static String rootCauseMessage(Throwable t) {
Throwable cur = t;
while (cur.getCause() != null) cur = cur.getCause();
String msg = cur.getMessage();
return (msg == null || msg.isBlank()) ? cur.getClass().getSimpleName() : msg;
}
}
Minimal custom exceptions:
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) { super(message); }
}
public class ConflictException extends RuntimeException {
public ConflictException(String message) { super(message); }
}
Step 4) Example controller to see it in action
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public UserDto create(@RequestBody CreateUserRequest req) {
if ("taken@example.com".equalsIgnoreCase(req.email())) {
throw new ConflictException("Email already exists");
}
return new UserDto("u_123", req.email());
}
@GetMapping("/{id}")
public UserDto get(@PathVariable String id) {
throw new NotFoundException("User not found: " + id);
}
public record CreateUserRequest(
@NotBlank String name,
@Email String email,
@Min(18) int age
) {}
public record UserDto(String id, String email) {}
}
Step 5) Tests: lock the contract with MockMvc
These tests prevent future refactors from accidentally changing your error shape.
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(UserController.class)
@Import({GlobalExceptionHandler.class, TraceIdFilter.class})
class ErrorEnvelopeTest {
@Autowired MockMvc mvc;
@Test
void validationErrors_returnConsistentEnvelope() throws Exception {
String json = "{"
+ "\"name\": \"\","
+ "\"email\": \"not-an-email\","
+ "\"age\": 10"
+ "}";
mvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.status").value(400))
.andExpect(jsonPath("$.error").value("invalid.request"))
.andExpect(jsonPath("$.path").value("/api/users"))
.andExpect(jsonPath("$.details").isArray())
.andExpect(jsonPath("$.details.length()").value(3));
}
@Test
void malformedJson_returnInvalidJsonEnvelope() throws Exception {
String json = "{ \"name\": \"a\", \"email\": \"x@\", \"age\": }";
mvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.error").value("invalid.json"))
.andExpect(jsonPath("$.details").isArray());
}
@Test
void notFound_return404Envelope() throws Exception {
mvc.perform(get("/api/users/does-not-exist"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status").value(404))
.andExpect(jsonPath("$.error").value("not.found"));
}
}
Practical tips (the stuff that saves you later)
-
Keep error codes stable (
invalid.request,not.found,conflict) even if messages change. -
Don’t return stack traces to clients. Log them with
traceId. - Don’t return JPA entities directly from controllers (unrelated issue, but it breaks predictability fast).
- If your API is public, add these response schemas to your OpenAPI so consumers rely on them.
Wrap-up
A consistent error envelope is a boring contract—and that’s a feature.
Once you standardize it:
- frontend becomes simpler
- logs become searchable
- incidents become faster to triage
- your API feels “professional” immediately
Top comments (0)