Skip to main content

Normalize Errors Returned

At work, I have plenty of scenarios like:

  • service.com/users/-1 returns:

    {
    "errorDescription": "User not found",
    "cause": "BAD REQUEST"
    }
  • but service.com/product/-1 returns:

    {
    "message": "not found",
    "error": 404
    }

Errors are not consistent just flew out of the window there, and it gets worse with errors inside 200OK. We don’t want to be that kind of dev: we are going to do proper error handling with @RestControllerAdvice and ProblemDetail.

Error Handing

@RestControllerAdvice acts like a central "error coordinator" for your application.

  • It’s a single place where you can define how all errors, exceptions, or unexpected scenarios get translated into responses.
  • Instead of scattering error-handling logic across every controller, this tool ensures every error—whether from a user lookup, product search, or internal bug—follows the same rules and format.

ProblemDetail is a standardized "error template" that structures responses in a clear, consistent way. Think of it as a pre-designed form that every error fills out:

  • What type of error occurred (e.g., "user_not_found")
  • A human-readable title (e.g., "Resource Not Found")
  • The HTTP status code (e.g., 404)
  • Additional details (e.g., "User ID -1 does not exist")

Together, these tools ensure your microservice never confuses clients with mismatched error formats. Even edge cases or unanticipated errors get wrapped into the same predictable structure.

@RestControllerAdvice classes in the Hexagonal Architecture make sense to be in the /adapter/in folder. However, you may find them in /config to indicate it's a cross-cutting concern.

Let’s create a @RestControllerAdvice class.

src/main/java/dev/pollito/users_manager/config/advice/ControllerAdvice.java
package dev.pollito.users_manager.adapter.in.rest.advice;

import io.opentelemetry.api.trace.Span;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@RestControllerAdvice
@Slf4j
public class ControllerAdvice {
@NotNull private static ProblemDetail buildProblemDetail(@NotNull Exception e, HttpStatus status) {
String exceptionSimpleName = e.getClass().getSimpleName();
log.error("{} being handled", exceptionSimpleName, e);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, e.getLocalizedMessage());
problemDetail.setTitle(exceptionSimpleName);
problemDetail.setProperty("timestamp", DateTimeFormatter.ISO_INSTANT.format(Instant.now()));
problemDetail.setProperty("trace", Span.current().getSpanContext().getTraceId());
return problemDetail;
}

@ExceptionHandler(Exception.class)
public ProblemDetail handle(@NotNull Exception e) {
return buildProblemDetail(e, HttpStatus.INTERNAL_SERVER_ERROR);
}
}

If we visit an uri that doesn't exist (like http://localhost:8080), we will now get a standardized error:

standard error

Adding More Handlers

Right now you could be thinking

But No static resource should be 404 instead of 500

To which I say, yes, you’re totally right, and I wish there was a way to implement that behavior by default. But with this normalization of errors, everything is a 500 unless you explicitly say otherwise. I think the trade-off is worth it.

For making No static resource a 404, add in the @RestControllerAdvice class a new @ExceptionHandler(NoResourceFoundException.class) method.

@ExceptionHandler(NoResourceFoundException.class)
public ProblemDetail handle(@NotNull NoResourceFoundException e) {
return buildProblemDetail(e, HttpStatus.NOT_FOUND);
}

Now when requesting to http://localhost:8080 we get the new expected behavior:

expected 404

Commit the progress so far.

git add .
git commit -m "error handling"

Common Handlers You May Need

Here are some common exceptions that you may want to handle:

ExceptionDescriptionExampleNotes
ConstraintViolationExceptionRequest parameters/fields fail validation (@NotNull, @Size, @Pattern)Request body missing a required fieldRequires Jakarta EE (to be added later)
MethodArgumentTypeMismatchExceptionRequest parameter cannot be converted to expected typeController expects Integer but receives String
NoResourceFoundExceptionRequest accesses non-existent Spring MVC resourceAccessing an undefined endpoint
NoSuchElementExceptionOptional.get() called on empty OptionalLooking for non-existent user by ID
PropertyReferenceExceptionInvalid property used in Spring Data repository querySorting by non-existent fieldRequires Spring Data (to be added later)