DEV Community

Vaishnavi Agrawal
Vaishnavi Agrawal

Posted on • Originally published at vaishnaviagrawal1.substack.com

How I Cleaned Up Error Handling in My Spring Boot API

Dark navy cover card titled

Welcome to my newsletter. Every week I take one backend topic, learn it properly, and write down what clicked, with working code from something I actually built. This week: exception handling.

When I built the first version of my Purchase Decision API, my error handling was scattered everywhere. Almost every controller method had its own try-catch block. Looking up a user that did not exist returned one error shape, registering with an existing email returned another, and anything unexpected leaked Spring's default error response to the client.

None of it was broken, exactly. It was worse: it was inconsistent. Every new endpoint meant copying error-handling code from an older one and hoping I kept the format the same. I usually did not.

Here is the pattern I settled on instead: custom exceptions plus one global handler with @ControllerAdvice. It is a small change, and it cleaned up my controllers more than anything else I have done to them.

The idea in one picture

Think of a store where every employee handles complaints their own way. One offers a refund, one apologizes and does nothing, and one argues. Customers get a different experience depending on who they happen to reach. Now put a single trained person at a complaints desk and give every employee the same instruction: send complaints there. Suddenly, every complaint gets the same treatment.

@ControllerAdvice is the complaints desk. It is a global exception handler that catches exceptions thrown anywhere in your controllers and handles them in one central place, instead of you repeating try-catch blocks in every controller method. Your controllers stay focused on their actual job, and every endpoint returns errors in the same format.

Flow diagram titled

Step 1: name your errors

The first thing I did was turn my failure cases into small, specific exception classes. In my API, the three that come up constantly are a missing user, a duplicate email at registration, and a wrong password:

public class UserNotFoundException extends RuntimeException {
    public UserNotFoundException(String message) {
        super(message);
    }
}

public class EmailAlreadyExistsException extends RuntimeException {
    public EmailAlreadyExistsException(String message) {
        super(message);
    }
}

public class InvalidPasswordException extends RuntimeException {
    public InvalidPasswordException(String message) {
        super(message);
    }
}
Enter fullscreen mode Exit fullscreen mode

They extend RuntimeException so I can throw them from my service layer without declaring them everywhere. The service just states what went wrong:

User user = userRepository.findByEmail(email)
        .orElseThrow(() -> new UserNotFoundException("No user with email " + email));
Enter fullscreen mode Exit fullscreen mode

Notice what the service does not do: it does not know or care what HTTP status this becomes. That decision lives somewhere else.

Step 2: decide what an error looks like

Before the handler, I defined one shape that every error response uses. Mine is small:

public record ErrorResponse(
        int status,
        String error,
        String message,
        Instant timestamp
) {}
Enter fullscreen mode Exit fullscreen mode

A client calling my API now knows that any error, from any endpoint, looks like this:

{
  "status": 404,
  "error": "Not Found",
  "message": "No user with email priya@example.com",
  "timestamp": "2026-07-08T14:32:10Z"
}
Enter fullscreen mode Exit fullscreen mode

That consistency is the whole point. Frontend code can handle errors in one place too, because it can rely on the shape.

Step 3: the complaints desk itself

One class, annotated with @ControllerAdvice, catches each exception and maps it to the right status code:

@Slf4j
@ControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(UserNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleUserNotFound(UserNotFoundException ex) {
        return build(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(EmailAlreadyExistsException.class)
    public ResponseEntity<ErrorResponse> handleEmailExists(EmailAlreadyExistsException ex) {
        return build(HttpStatus.CONFLICT, ex.getMessage());
    }

    @ExceptionHandler(InvalidPasswordException.class)
    public ResponseEntity<ErrorResponse> handleInvalidPassword(InvalidPasswordException ex) {
        return build(HttpStatus.UNAUTHORIZED, ex.getMessage());
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
        // log the real error for ourselves, return something safe to the client
        log.error("Unexpected error", ex);
        return build(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong on our side.");
    }

    private ResponseEntity<ErrorResponse> build(HttpStatus status, String message) {
        ErrorResponse body = new ErrorResponse(
                status.value(), status.getReasonPhrase(), message, Instant.now());
        return ResponseEntity.status(status).body(body);
    }
}
Enter fullscreen mode Exit fullscreen mode

Picking the status codes was its own small lesson. A missing user is 404 Not Found. A duplicate email is 409 Conflict, because the request is fine but it collides with existing state. A wrong password is 401 Unauthorized. Before this refactor I returned 400 for almost everything, which technically worked and helped nobody.

The last handler matters most. Anything I did not predict gets caught, logged with its stack trace on the server, and turned into a generic 500 for the client. The client never sees internals, and I still get the full details in my logs.

What actually changed

My controllers went from this kind of thing:

try {
    UserDto user = userService.getByEmail(email);
    return ResponseEntity.ok(user);
} catch (UserNotFoundException ex) {
    return ResponseEntity.status(404).body(/* hand-built error */);
}
Enter fullscreen mode Exit fullscreen mode

to this:

return ResponseEntity.ok(userService.getByEmail(email));
Enter fullscreen mode Exit fullscreen mode

Without the global handler, I would repeat the same error-handling code in every controller method and risk formatting errors differently each time. With it, I write each error's handling once, every endpoint returns errors the same way, and my controllers stay focused on their actual logic.

Honest notes

Two things I want to be upfront about. First, this is the pattern that works for my project's current size; I am sure there are refinements I have not needed yet. Second, Spring Boot 3 has a built-in ProblemDetail type that follows an RFC standard for error bodies. I have not used it in this project, and trying it is on my list. If you use it and like it, I would genuinely like to hear why.

Recap

Scattered try-catch blocks mean duplicated code and inconsistent errors. The fix I settled on: specific exception classes thrown from the service layer, one ErrorResponse shape, and one @ControllerAdvice class that maps each exception to the right status code, with a logging catch-all so nothing leaks. Controllers get shorter, clients get predictable errors, and adding a new error case is one small handler method instead of another copy-pasted try-catch.

How do you shape your error responses, and did you settle on something different? Tell me in the comments. I compare notes gladly.

P.S. If this was useful, subscribe. I write one piece like this every week.

Top comments (0)