DEV Community

Thellu
Thellu

Posted on

A Boring Error Format Is a Feature: Building a Consistent Error Envelope in Spring Boot

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" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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);
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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); }
}
Enter fullscreen mode Exit fullscreen mode

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) {}
}
Enter fullscreen mode Exit fullscreen mode

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"));
  }
}
Enter fullscreen mode Exit fullscreen mode

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)