We have been leaving TODO labels behind for four lessons. The 404s are built by hand in every controller with ResponseEntity.notFound(). The business rules from 03-03 throw IllegalStateExceptions that turn into server errors with the stack trace inside. The ConstraintViolationException from 03-04 comes out as a 500 when the fault lies with the client. And validation errors respond "Invalid request content." without saying which field failed. All of that is settled in this lesson. We will build CicloUrbana's exception hierarchy, centralise it in a single global handler, adopt the standard RFC 7807 Problem Details format that Spring Boot 3 supports natively, and make sure no error leaks information that must not leave the server.

Contents

  1. Spring Boot's default behaviour
  2. Why it is not enough for a public API
  3. CicloUrbana's exception hierarchy
  4. @ResponseStatus on the exception and its limits
  5. @ExceptionHandler local to the controller
  6. @RestControllerAdvice: the global handler
  7. RFC 7807: Problem Details
  8. Validation errors with the list of fields
  9. Framework exceptions
  10. Not leaking sensitive information
  11. What to log according to severity
  12. A trace identifier in the response
  13. Common Mistakes and Tips
  14. Exercises

  1. Spring Boot's default behaviour

Spring Boot never leaves an exception without a response. When one escapes the controller, the DispatcherServlet —whose journey we sketched in 03-01— passes it through a chain of HandlerExceptionResolvers. If none recognises it, the request is internally forwarded to /error, served by the BasicErrorController. If you add a test endpoint that throws IllegalStateException, the response is this:

{ "timestamp": "2026-09-01T09:14:22.481+00:00", "status": 500,
  "error": "Internal Server Error", "path": "/api/v1/stations/error-test" }

That format is controlled by four properties:

server:
  error:
    include-message: always          # never | always | on-param (default: never)
    include-binding-errors: always   # validation errors in the response
    include-stacktrace: never        # NEVER in production
    include-exception: false         # the exception class name
    path: /error                     # internal path of the BasicErrorController
Property Default Risk if enabled
include-message never May expose internal details from the message
include-binding-errors never Low: it is data the client sent
include-stacktrace never High: reveals classes, paths and versions
include-exception false Medium: reveals the internal implementation

With include-message and include-stacktrace set to always, the response goes on to include the full message and the entire stack trace: package names, library versions, line numbers. That is gold for anyone hunting for vulnerabilities. include-stacktrace must be never in production, no exceptions.

  1. Why it is not enough for a public API

Even if we tune those properties, the default handling has four problems that configuration cannot fix. Everything is a 500: IllegalStateException, IllegalArgumentException and NoSuchElementException all come out the same, even though some are the client's fault and others ours, and that ruins monitoring —module 9's alerting system will wake somebody at dawn over a 404. The client cannot program against the error, because there is no stable code to branch on, only an English string that can change in any release. There is no useful context: "Bad Request" does not say which field was wrong. And the format is not a standard, so every client writes its own parser.

What a public API needs is for each kind of failure to have a correct HTTP code, a stable identifier, a useful message and a uniform format. That is what we are going to build.

  1. CicloUrbana's exception hierarchy

Every domain exception inherits from a common base, in com.ciclourbana.common.exceptions:

/**
 * Base of every CicloUrbana business exception.
 *
 * It is a RuntimeException (unchecked) deliberately: forcing a throws
 * declaration on every signature pollutes the code without adding safety,
 * because nobody can recover from these failures except the global handler.
 */
public abstract class CicloUrbanaException extends RuntimeException {

    /** Stable code the client can use in its own logic. */
    private final String code;

    protected CicloUrbanaException(String code, String message) {
        super(message);
        this.code = code;
    }

    public String getCode() {
        return code;
    }
}

The code field is what makes the API programmable: the client reacts to STATION_FULL without depending on the message text, which can be translated or rewritten without breaking anything. The concrete exceptions:

/** The requested resource does not exist. Maps to 404. */
public class ResourceNotFoundException extends CicloUrbanaException {
    public ResourceNotFoundException(String resourceType, Object id) {
        super("RESOURCE_NOT_FOUND",
              "No %s found with identifier %s".formatted(resourceType, id));
    }
}

/** Business rule violated (422) and conflict with another resource (409). */
public class BusinessRuleException extends CicloUrbanaException {
    public BusinessRuleException(String code, String message) { super(code, message); }
}
public class ResourceConflictException extends CicloUrbanaException {
    public ResourceConflictException(String code, String message) { super(code, message); }
}

/** Specialisations with their message already built. */
public class StationFullException extends ResourceConflictException {
    public StationFullException(String name, int capacity) {
        super("STATION_FULL", "Station %s has no free docks (capacity %d)"
                .formatted(name, capacity));
    }
}
public class BikeUnavailableException extends BusinessRuleException {
    public BikeUnavailableException(String plate, BikeStatus status) {
        super("BIKE_UNAVAILABLE",
              "Bike %s is not available (status: %s)".formatted(plate, status));
    }
}

The complete mapping to HTTP codes:

Exception HTTP code When it is thrown
ResourceNotFoundException 404 RESOURCE_NOT_FOUND Non-existent station or rental
ResourceConflictException 409 varies Duplicate station name
StationFullException 409 STATION_FULL Returning to a full station
BusinessRuleException 422 varies Rental already finished
BikeUnavailableException 422 BIKE_UNAVAILABLE Bike under maintenance
MethodArgumentNotValidException 400 VALIDATION @Valid fails on the body
ConstraintViolationException 400 VALIDATION @Validated fails on a parameter
HttpMessageNotReadableException 400 UNREADABLE_BODY Malformed JSON
Anything else 500 INTERNAL_ERROR Unforeseen failure

With this, the services stop throwing IllegalStateException:

// In RentalService, replacing the provisional code from 03-03
Rental rental = rentalRepository.findById(rentalId)
        .orElseThrow(() -> new ResourceNotFoundException("rental", rentalId));
if (rental.status() == RentalStatus.FINISHED) {
    throw new BusinessRuleException("RENTAL_ALREADY_FINISHED",
            "Rental %d was already finished".formatted(rentalId));
}
Station destination = stationRepository.findById(destinationStationId)
        .orElseThrow(() -> new ResourceNotFoundException("station", destinationStationId));
if (bikeRepository.countByStation(destination.id()) >= destination.capacity()) {
    throw new StationFullException(destination.name(), destination.capacity());
}

Notice the orElseThrow pattern: it turns an empty Optional into the right exception in a single expression, and it is the reason repositories return Optional and not null.

  1. @ResponseStatus on the exception and its limits

The quickest way to associate an HTTP code with an exception is to annotate it with @ResponseStatus(HttpStatus.NOT_FOUND). Spring detects it with ResponseStatusExceptionResolver and responds 404. It works, it is one line, and it has three serious limits:

Limit Consequence
The status is fixed The same exception cannot give 409 in one case and 422 in another
It does not control the body The response is still generated by BasicErrorController
It only works for your own exceptions You cannot annotate ConstraintViolationException, which belongs to a library

For those reasons, CicloUrbana does not use @ResponseStatus on its exceptions: the complete mapping lives in the global handler, in a single place that can be read at a glance.

There is an interesting variant, ResponseStatusException, which carries the status inside: throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Station not found"). It is handy for prototypes, but it couples the service layer to the web API —HttpStatus is a Spring Web class— and it does not allow the code field. The course mentions it and does not adopt it.

  1. @ExceptionHandler local to the controller

A method annotated with @ExceptionHandler inside a controller catches the exceptions of that controller:

// Inside StationController, next to the endpoints
@ExceptionHandler(StationFullException.class)
public ProblemDetail handleStationFull(StationFullException e) {
    return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
}

Its limited scope is both its virtue and its flaw: it works for an exception specific to one particular controller, but repeating it in every class multiplies the code, so CicloUrbana uses the global handler and reserves the local ones for genuinely particular cases. A precedence fact worth remembering: the local handler always beats the global one when both catch the same exception.

  1. @RestControllerAdvice: the global handler

@RestControllerAdvice is @ControllerAdvice + @ResponseBody: a class whose @ExceptionHandlers apply to all the controllers.

package com.ciclourbana.common;

@RestControllerAdvice
public class GlobalExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
    private static final URI TYPE_BASE = URI.create("https://api.ciclourbana.example/errors/");

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException e,
                                        HttpServletRequest request) {
        log.info("Resource not found at {}: {}", request.getRequestURI(), e.getMessage());
        return build(HttpStatus.NOT_FOUND, "Resource not found", e);
    }

    @ExceptionHandler(ResourceConflictException.class)
    public ProblemDetail handleConflict(ResourceConflictException e,
                                        HttpServletRequest request) {
        log.warn("Conflict at {}: {}", request.getRequestURI(), e.getMessage());
        return build(HttpStatus.CONFLICT, "Conflict with the current state", e);
    }

    @ExceptionHandler(BusinessRuleException.class)
    public ProblemDetail handleBusinessRule(BusinessRuleException e,
                                            HttpServletRequest request) {
        log.warn("Rule violated at {}: {}", request.getRequestURI(), e.getMessage());
        return build(HttpStatus.UNPROCESSABLE_ENTITY, "Operation not allowed", e);
    }

    /** Safety net. If it fires, it is a bug: hence ERROR with the stack trace. */
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception e, HttpServletRequest request) {
        String traceId = UUID.randomUUID().toString();
        log.error("Unexpected error [trace={}] at {}", traceId, request.getRequestURI(), e);

        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR,
                // GENERIC message: e.getMessage() is not leaked
                "An internal error occurred. Contact support quoting the trace.");
        problem.setTitle("Internal error");
        problem.setType(TYPE_BASE.resolve("internal-error"));
        problem.setProperty("code", "INTERNAL_ERROR");
        problem.setProperty("trace", traceId);
        return problem;
    }

    /** Common construction of the Problem Details response. */
    private ProblemDetail build(HttpStatus status, String title, CicloUrbanaException e) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, e.getMessage());
        problem.setTitle(title);
        problem.setType(TYPE_BASE.resolve(e.getCode().toLowerCase().replace('_', '-')));
        problem.setProperty("code", e.getCode());
        problem.setProperty("timestamp", Instant.now().toString());
        return problem;
    }
}

Three important details. Returning ProblemDetail directly is enough: Spring recognises it, sets the status from the status field and adds Content-Type: application/problem+json, with no need for a ResponseEntity. The Exception handler does not leak e.getMessage(): the real message goes to the log and only a generic text with a trace identifier reaches the client (section 10). And resolution order is by type specificity, not by declaration order: if a StationFullException is thrown, Spring picks the ResourceConflictException handler —its nearest superclass with a handler— and not the Exception one, which is why the latter can be declared without fear of it swallowing the rest.

When there are several @RestControllerAdvices —for example, one for the domain and another contributed by Spring Security in module 5—, the tie-break does depend on order, and it is controlled with @Order: @Order(Ordered.HIGHEST_PRECEDENCE) on the security handler and @Order(Ordered.LOWEST_PRECEDENCE) on the general one, which acts as a safety net. @RestControllerAdvice also accepts basePackages, assignableTypes or annotations to limit its scope, useful when a public API and an internal panel with different error formats coexist.

With the handler in place, the controllers get simpler. The getById from 03-02 loses its ResponseEntity and its .map(...).orElseGet(...):

@GetMapping("/{id:\\d+}")
public StationDetailResponse getById(@PathVariable("id") @Positive Long id) {
    Station station = stationService.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("station", id));
    return stationMapper.toDetail(station, bikeService.findByStation(id));
}

The TODO we left there is settled: the error path no longer takes up room on the happy path.

  1. RFC 7807: Problem Details

RFC 7807 —updated by RFC 9457— defines a standard format for HTTP errors, and Spring Boot 3 supports it natively with the ProblemDetail class. The standard's fields:

Field Meaning
type URI identifying the type of problem; it doubles as documentation
title Human-readable and stable summary for that type
status The HTTP code, repeated in the body
detail Explanation specific to this occurrence
instance URI of the specific occurrence
(extensions) Your own fields: code, trace, errors...

The distinction between title and detail is the most confused one: title is fixed for the problem type ("Resource not found") and detail changes with every occurrence ("No station found with identifier 999"). A client can group errors by type and show detail to the user.

The response our handler produces for GET /api/v1/stations/999 is a 404 with Content-Type: application/problem+json and this body:

{ "type": "https://api.ciclourbana.example/errors/resource-not-found",
  "title": "Resource not found", "status": 404,
  "detail": "No station found with identifier 999",
  "instance": "/api/v1/stations/999",
  "code": "RESOURCE_NOT_FOUND", "timestamp": "2026-09-01T09:22:41.117Z" }

Content-Type: application/problem+json is set by Spring on its own when it detects a ProblemDetail. It is the MIME type we announced in the table in 03-01.

Spring Boot also offers a global switch that requires writing no handlers at all:

spring:
  mvc:
    problemdetails:
      enabled: true    # Spring MVC exceptions come out as Problem Details

It makes the framework's own exceptions —MethodArgumentNotValidException, HttpRequestMethodNotSupportedException, NoResourceFoundException— produce Problem Details instead of the BasicErrorController format. It does not cover your own, for which the handler is still needed, but it brings coherence to the errors you do not handle explicitly.

There are also ErrorResponseException, a Spring exception that already carries a ProblemDetail inside and lets you throw a fully formed error from anywhere, and ResponseEntityExceptionHandler, a base class with pre-written handlers for every Spring MVC exception that you can extend and override method by method: it gives more coverage out of the box in exchange for less control.

  1. Validation errors with the list of fields

We pick up the debt from 03-04: a MethodArgumentNotValidException contains a BindingResult with every field that failed, and we have to extract them and put them in the response.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleBodyValidation(MethodArgumentNotValidException e) {

    // A field may have several errors: they are grouped by field name
    Map<String, List<String>> errors = e.getBindingResult().getFieldErrors().stream()
            .collect(Collectors.groupingBy(FieldError::getField, LinkedHashMap::new,
                    Collectors.mapping(DefaultMessageSourceResolvable::getDefaultMessage,
                                       Collectors.toList())));

    // Class-level constraint errors (@ValidCoordinates with no associated field)
    List<String> global = e.getBindingResult().getGlobalErrors().stream()
            .map(DefaultMessageSourceResolvable::getDefaultMessage).toList();

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "The request contains %d field(s) with errors".formatted(errors.size()));
    problem.setTitle("Validation error");
    problem.setType(TYPE_BASE.resolve("validation"));
    problem.setProperty("code", "VALIDATION");
    problem.setProperty("errors", errors);
    if (!global.isEmpty()) {
        problem.setProperty("globalErrors", global);
    }
    return problem;
}

/** @Validated on parameters: a different exception, the same response shape. */
@ExceptionHandler(ConstraintViolationException.class)
public ProblemDetail handleParameterValidation(ConstraintViolationException e) {

    Map<String, List<String>> errors = e.getConstraintViolations().stream()
            .collect(Collectors.groupingBy(v -> lastNode(v.getPropertyPath()),
                    LinkedHashMap::new,
                    Collectors.mapping(ConstraintViolation::getMessage, Collectors.toList())));

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST, "Invalid request parameters");
    problem.setTitle("Validation error");
    problem.setType(TYPE_BASE.resolve("validation"));
    problem.setProperty("code", "VALIDATION");
    problem.setProperty("errors", errors);
    return problem;
}

/** The path arrives as "list.size"; the client only cares about "size". */
private String lastNode(Path path) {
    String full = path.toString();
    return full.substring(full.lastIndexOf('.') + 1);
}

The result, with the same request that in 03-04 returned "Invalid request content.":

{ "type": "https://api.ciclourbana.example/errors/validation",
  "title": "Validation error", "status": 400,
  "detail": "The request contains 2 field(s) with errors",
  "instance": "/api/v1/stations", "code": "VALIDATION",
  "errors": {
    "name": ["The station name is required",
             "The name must be between 3 and 80 characters"],
    "capacity": ["Capacity must be greater than zero"] } }

Now Ribalta's panel can highlight the two fields and show their messages next to each one. Note that name accumulates two errors: grouping into a List<String> loses none, whereas a Map<String, String> would have silently discarded one. And this also settles the 500 trap from 03-04: ConstraintViolationException now responds 400.

  1. Framework exceptions

Besides our own, we have to handle the ones Spring throws when the request is malformed:

Exception Cause Status
HttpMessageNotReadableException Malformed JSON or missing body 400
MethodArgumentTypeMismatchException /stations/abc with id of type Long 400
MissingServletRequestParameterException A mandatory @RequestParam is missing 400
HttpRequestMethodNotSupportedException DELETE on the collection 405
HttpMediaTypeNotSupportedException XML is sent where JSON is expected 415
HttpMediaTypeNotAcceptableException An Accept we cannot satisfy 406
NoResourceFoundException Non-existent path (Spring Boot 3.2+) 404
@ExceptionHandler(HttpMessageNotReadableException.class)
public ProblemDetail handleUnreadableBody(HttpMessageNotReadableException e) {
    // CAREFUL: e.getMessage() includes a fragment of the received JSON and the
    // target Java class. That is internal information: it is not returned to the client.
    log.warn("Unreadable body: {}", e.getMessage());

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "The request body is not valid JSON");
    problem.setTitle("Unreadable body");
    problem.setType(TYPE_BASE.resolve("unreadable-body"));
    problem.setProperty("code", "UNREADABLE_BODY");
    return problem;
}

@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ProblemDetail handleTypeMismatch(MethodArgumentTypeMismatchException e) {
    String expectedType = e.getRequiredType() != null
            ? e.getRequiredType().getSimpleName() : "unknown";

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "Parameter '%s' must be of type %s".formatted(e.getName(), expectedType));
    problem.setTitle("Incorrect parameter type");
    problem.setProperty("code", "TYPE_MISMATCH");
    problem.setProperty("parameter", e.getName());
    return problem;
}

NoResourceFoundException (with spring.mvc.throw-exception-if-no-handler-found: true) and HttpRequestMethodNotSupportedException follow the same pattern; exercise 3 completes the latter with the Allow header. Now that request from the 03-02 .http file which returned an incomprehensible 400 gives something useful for ?minimumCapacity=lots:

{ "type": "about:blank", "title": "Incorrect parameter type", "status": 400,
  "detail": "Parameter 'minimumCapacity' must be of type Integer",
  "code": "TYPE_MISMATCH", "parameter": "minimumCapacity" }

  1. Not leaking sensitive information

An error handler is an attack surface: every piece of data it returns is a piece of data someone can use against you. What must never go out:

Data Why it is dangerous
Stack trace Reveals classes, library versions and code structure
Database messages Leak tables and columns; they help SQL injection
File system paths Reveal the operating system and the deployment
Java class names com.ciclourbana.gateway.RedsysClient tells them what you use
Internal addresses and ports They ease lateral movement across the network
The value rejected by a validation It may be the password just typed

The first three almost always arrive by the same route: returning e.getMessage() of an exception you do not control. With ProblemDetail.forStatusAndDetail(INTERNAL_SERVER_ERROR, e.getMessage()) and a database failure, the API responds with something like "could not execute statement; SQL [insert into stations ...]; constraint [uk_stations_name]", which reveals the whole schema. The Exception handler from section 6 does the right thing: a generic message on the way out, the full detail in the log, and a trace identifier joining the two.

The rule: the message of your own exceptions may go out, because we wrote it with the client in mind; the message of someone else's, never.

A subtle case: resource enumeration. If GET /api/v1/users/1 responds 404 and GET /api/v1/users/2 responds 403, an attacker deduces that user 2 exists; for sensitive resources the right answer is 404 in both cases. It does not apply to the Ribalta stations, which are public information, but it does apply to users, and we will return to it in module 5.

  1. What to log according to severity

Logging everything with log.error renders the log useless: when everything is an error, nothing is.

Situation Level Stack trace Reason
Resource 404 INFO No Normal operation
Failed validation INFO No The client got it wrong; that is expected
Business 409 / 422 WARN No Expected, but it may signal a buggy client
401 / 403 WARN No It may be an improper access attempt
External dependency down ERROR Yes Requires intervention
Unforeseen exception ERROR Yes It is a bug: it has to be fixed

Two practical rules. Stack traces only at ERROR: a 404 with twenty lines of trace multiplies the log size while adding nothing. And log the exception as the last argument, not concatenated:

log.error("Unexpected error [trace={}] at {}", traceId, uri, e);   // GOOD: full trace
log.error("Unexpected error: " + e.getMessage());                  // BAD: loses the cause

SLF4J treats a trailing Throwable argument specially and prints the full stack trace, chained causes included; concatenating loses exactly the information needed to diagnose. Logging in depth arrives in 09-05.

  1. A trace identifier in the response

When a Ribalta operator calls saying "I got an error when finishing the rental", the question is which of the hundreds of errors in the log is theirs. The trace identifier answers that: a unique value that appears at the same time in the client's response and in the log line. The homespun version is the one we already have: a UUID.randomUUID() in the Exception handler. It only covers errors and does not relate the log lines of a single request to each other. The good version uses SLF4J's MDC (Mapped Diagnostic Context), which attaches data to the current thread and makes every log line of a request carry the same identifier:

package com.ciclourbana.common;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)     // before any other filter
public class TraceFilter extends OncePerRequestFilter {

    public static final String MDC_KEY = "traceId";
    private static final String HEADER = "X-Trace-Id";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {

        // If an upstream service already sent an identifier, it is reused:
        // that way the trace survives across services (completed in 09-06)
        String trace = Optional.ofNullable(request.getHeader(HEADER))
                .filter(s -> !s.isBlank())
                .orElseGet(() -> UUID.randomUUID().toString().substring(0, 8));

        MDC.put(MDC_KEY, trace);
        response.setHeader(HEADER, trace);        // the client always receives it
        try {
            chain.doFilter(request, response);
        } finally {
            // ESSENTIAL: Tomcat threads are reused. Without this remove,
            // the next request would inherit the previous one's trace.
            MDC.remove(MDC_KEY);
        }
    }
}

That MDC.remove in the finally is not optional: without it, a request that fails leaves its identifier stuck to the thread and the next request served by that thread writes a trace in the log that does not belong to it. It is a hard error to diagnose precisely because it corrupts the diagnostic tool itself.

It is included in the log pattern with %X{traceId:-no-trace} —%X reads the MDC and :-no-trace is the default value—, and in the handler problem.setProperty("trace", MDC.get(TraceFilter.MDC_KEY)) is all it takes:

logging:
  pattern:
    console: "%d{HH:mm:ss.SSS} %-5level [%X{traceId:-no-trace}] %logger{36} - %msg%n"

The result, in the server log and in the response the operator receives:

09:22:41.117 WARN [a3f5c9e1] c.c.c.GlobalExceptionHandler - Conflict at
/api/v1/rentals/7/finish: Station University has no free docks
{ "type": "https://api.ciclourbana.example/errors/station-full",
  "title": "Conflict with the current state", "status": 409,
  "detail": "Station University has no free docks (capacity 36)",
  "code": "STATION_FULL", "trace": "a3f5c9e1" }

With a3f5c9e1 you locate in the log the exact request and all its lines. This identifier is the seed of the distributed tracing of 09-06, where the trace will stay alive across several services.

Common Mistakes and Tips

Leaving include-stacktrace: always in production. It is the most common information leak in Spring Boot applications. It must be never.

Catching Exception in the controller with a try/catch. It bypasses the global handler and scatters error logic. Let the exception rise.

Returning e.getMessage() of somebody else's exception. It is the route through which database schemas and system paths escape.

Logging everything with log.error. A 404 is not a server error. When everything is ERROR, alerts stop meaning anything.

Forgetting MDC.remove(). The trace leaks into the next request on the same thread and corrupts the log exactly when you need it most.

Using @ResponseStatus and the global handler at the same time. Having the mapping in two places ends in inconsistencies; the course chooses the handler. And using Map<String, String> for validation errors silently loses the additional errors of a single field: use Map<String, List<String>>.

Tip: write the exception-to-HTTP-code table first, since it is the handler's specification, and test every error branch by adding one request per type to the .http file; in module 6 those requests become integration tests that stop a refactor from breaking the error contract.

Exercises

Exercise 1: Duplicate exception with context

StationService.create still throws IllegalStateException when the name is repeated. Create DuplicateStationException, make it respond 409 and add to the response the identifier of the station that already exists, so that Ribalta's panel can link to it directly.

Exercise 2: Internationalised validation errors including the rejected value

Extend the MethodArgumentNotValidException handler so that each error includes the field name, the message translated according to Accept-Language and the rejected value, with a list of sensitive fields whose value is never returned.

Exercise 3: A 405 handler with the Allow header

The HttpRequestMethodNotSupportedException handler from section 9 returns the correct 405 but does not include the Allow header, which RFC 9110 requires. Fix it and explain why that case needs ResponseEntity while the others do not.

Solutions

Solution 1.

public class DuplicateStationException extends ResourceConflictException {

    private final Long existingId;

    public DuplicateStationException(String name, Long existingId) {
        super("DUPLICATE_STATION", "A station named '%s' already exists".formatted(name));
        this.existingId = existingId;
    }

    public Long getExistingId() { return existingId; }
}

// In StationService
public Station create(Station newStation) {
    stationRepository.findByName(newStation.name()).ifPresent(existing -> {
        throw new DuplicateStationException(newStation.name(), existing.id());
    });
    return stationRepository.save(newStation);
}

Notice the change in the repository: existsByName returned a boolean and now we need findByName, which returns Optional<Station>. It is a recurring pattern: if throwing the error requires data from the conflicting resource, the repository has to return you the object, not a boolean. The specific handler:

@ExceptionHandler(DuplicateStationException.class)
public ProblemDetail handleDuplicateStation(DuplicateStationException e) {
    ProblemDetail problem = build(HttpStatus.CONFLICT, "Duplicate station", e);
    problem.setProperty("existingId", e.getExistingId());
    problem.setProperty("existingLink", "/api/v1/stations/" + e.getExistingId());
    return problem;
}
{ "type": "https://api.ciclourbana.example/errors/duplicate-station",
  "title": "Duplicate station", "status": 409,
  "detail": "A station named 'Main Square' already exists",
  "code": "DUPLICATE_STATION", "existingId": 1,
  "existingLink": "/api/v1/stations/1" }

The existingLink field is a touch of HATEOAS where it genuinely pays: the panel can offer "view the existing station" without building the URL. And even if there were no handler for DuplicateStationException, the exception would still respond 409 thanks to the one for its superclass ResourceConflictException; the specific handler only adds the two extra fields. That is the advantage of a well-designed hierarchy.

Solution 2.

/** Fields whose value is NEVER returned, even when validation failed. */
private static final Set<String> SENSITIVE_FIELDS =
        Set.of("password", "secret", "token", "card", "cvv", "nationalid", "iban");

public record FieldErrorDetail(String field, String message, Object rejectedValue) {}

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException e, Locale language) {

    List<FieldErrorDetail> errors = e.getBindingResult().getFieldErrors().stream()
            // getMessage(error, language) resolves the key against the
            // MessageSource from 03-04 with the language from Accept-Language
            .map(error -> new FieldErrorDetail(error.getField(),
                    messageSource.getMessage(error, language), safeValue(error)))
            .toList();

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
            "The request contains %d validation error(s)".formatted(errors.size()));
    problem.setTitle("Validation error");
    problem.setType(TYPE_BASE.resolve("validation"));
    problem.setProperty("code", "VALIDATION");
    problem.setProperty("errors", errors);
    return problem;
}

/** Returns the rejected value unless the field is a sensitive one. */
private Object safeValue(FieldError error) {
    String field = error.getField().toLowerCase();
    if (SENSITIVE_FIELDS.stream().anyMatch(field::contains)) {
        return "***";
    }
    Object value = error.getRejectedValue();
    // Truncate: a text field may carry megabytes and would fill the response
    return value instanceof String text && text.length() > 100
            ? text.substring(0, 100) + "..." : value;
}

With Accept-Language: es:

{ "title": "Validation error", "status": 400,
  "detail": "The request contains 2 validation error(s)",
  "code": "VALIDATION",
  "errors": [
    { "field": "name", "message": "El nombre de la estación es obligatorio", "rejectedValue": "" },
    { "field": "capacity", "message": "La capacidad debe ser mayor que cero",
      "rejectedValue": -5 } ] }

Three decisions worth pointing out. First: Locale as a handler parameter, which Spring injects from the LocaleResolver of 03-04, so that messageSource.getMessage(error, language) resolves {station.name.required} in the right language. Second: the sensitive-field list is a deny list, with the problem we already know —a new field called socialSecurityNumber would not be on it—, so in a system with genuinely sensitive data the right approach is the reverse: return no value at all except those explicitly marked as safe. Third: truncating at 100 characters stops a malicious client from provoking enormous responses.

Solution 3.

@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ProblemDetail> handleMethodNotSupported(
        HttpRequestMethodNotSupportedException e) {

    ProblemDetail problem = ProblemDetail.forStatusAndDetail(
            HttpStatus.METHOD_NOT_ALLOWED,
            "Method %s is not allowed on this path".formatted(e.getMethod()));
    problem.setTitle("Method not allowed");
    problem.setType(TYPE_BASE.resolve("method-not-allowed"));
    problem.setProperty("code", "METHOD_NOT_ALLOWED");
    problem.setProperty("allowedMethods", e.getSupportedMethods());

    HttpHeaders headers = new HttpHeaders();
    Set<HttpMethod> allowed = e.getSupportedHttpMethods();
    if (allowed != null && !allowed.isEmpty()) {
        headers.setAllow(allowed);             // Allow: GET, POST, ... header
    }
    return ResponseEntity.status(HttpStatus.METHOD_NOT_ALLOWED)
            .headers(headers).body(problem);
}

For DELETE /api/v1/stations, the response is a 405 with Allow: GET, POST and an application/problem+json body that repeats the methods in allowedMethods.

Why this case needs ResponseEntity. ProblemDetail describes only the body of the response: it has status so that Spring sets the code, but it has no way of expressing headers. RFC 9110 says a 405 must include Allow, so it has to be wrapped. It is exactly the criterion we set in the table in 03-02: ResponseEntity when headers are needed, the object directly when they are not.

It is worth noting the deliberate duplication: the allowed methods appear in the Allow header and in the allowedMethods field. The header is what the standard requires and what intermediaries read; the body field is what most JavaScript clients will read in practice, because reading headers from the browser requires the CORS exposedHeaders configuration we saw in 03-02. Duplicating here costs one line and saves a problem.

Conclusion

Four lessons' worth of debt is settled. You know what Spring Boot does by default —the forward to /error and the BasicErrorController— and why the server.error.include-* properties are not enough for a public API, starting with the fact that everything comes out as a 500. You have built CicloUrbana's exception hierarchy with CicloUrbanaException as its base, its code field that makes the API programmable without depending on the text, and the concrete exceptions of the Ribalta domain, each with its HTTP code in a table that is the handler's specification. You know @ResponseStatus and its three limits, the local @ExceptionHandler and its precedence over the global one, and above all the @RestControllerAdvice that centralises handling in a single class, with the type-specificity resolution that lets you declare an Exception handler without fear of it swallowing the rest.

CicloUrbana's errors now speak the RFC 7807 Problem Details standard, with type, title, status, detail, instance and our own extensions, served with application/problem+json and complemented by spring.mvc.problemdetails.enabled. The validation errors from 03-04 finally return the exact list of offending fields, grouped into a Map<String, List<String>> so none is lost, and ConstraintViolationException no longer comes out as a 500. You handle the framework's exceptions —unreadable JSON, wrong type, method not allowed, non-existent path— and you know none of them must leak e.getMessage(). You have the table of what must never go out in an error, the log-level policy by severity, and a trace identifier propagated with the MDC that appears both in the response and in every log line of that request, with the MDC.remove() in the finally that avoids corrupting the diagnosis.

CicloUrbana now has a complete API: thirteen endpoints, validation at the edge, DTOs that separate the domain from the contract and uniform, well-formed errors. And yet, if tomorrow the Ribalta mobile app team or the council's open data portal team wanted to integrate, they would have to ask us endpoint by endpoint which fields each one accepts, what it returns and which errors it can produce. All that knowledge lives in the code and in our heads, and nowhere else.

Lesson 03-07, Documenting the API with OpenAPI, puts it in writing automatically and in a machine-readable way. We will see what OpenAPI 3.1 is and why a formal contract changes the way teams work together; we will compare code-first and design-first; we will integrate springdoc-openapi with its /v3/api-docs and /swagger-ui.html endpoints; we will document CicloUrbana with @Tag, @Operation, @Parameter, @ApiResponse and @Schema; we will see how the Bean Validation constraints from 03-04 and this lesson's Problem Details appear in the schema on their own; we will group endpoints with GroupedOpenApi; we will export the openapi.json during the build to generate clients; and we will close the module with a review of the API before jumping to real persistence.

Spring Boot Course

Module 1: Introduction to Spring Boot

Module 2: Spring Boot Core Concepts

Module 3: Building RESTful Web Services

Module 4: Data Access with Spring Boot

Module 5: Security in Spring Boot

Module 6: Testing in Spring Boot

Module 7: Advanced Spring Boot Features

Module 8: Deploying Spring Boot Applications

Module 9: Performance and Monitoring

Module 10: Best Practices and Tips

© Copyright 2026. All rights reserved