DEV Community

Sanjeet Singh Jagdev
Sanjeet Singh Jagdev

Posted on

Stop Scattering Your Error Handling Across Spring Boot Services

Inspiration

Most Spring Boot codebases don't start messy.

They become messy—especially around error handling.

It starts with a harmless error being thrown from a Service class

throw new RuntimeException("Order Not Found");
Enter fullscreen mode Exit fullscreen mode

Then evolves into

throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Order not found");
Enter fullscreen mode Exit fullscreen mode

And before you know it:

  • Messages are duplicated across services
  • HTTP Status decisions are inconsistent
  • Error responses vary from endpoint-to-endpoint
  • Code becomes riddled in try-catch (and nested try-catch)
  • Debugging it all starts to give you nightmares

At some point you realise:

Error handling isn't a side concern—its part of the API contract

The Core Problem

The issue isn't exceptions themselves,

It's where those decisions are made.

In most systems:

  • Services/Repositories decide what to throw
  • Controllers decide how to respond
  • Handlers decide what to return

This leads to distributed responsibility, resulting in inconsistency and violating the Single Responsibility Principle.

Approach: Centralised Error Handling Semantics

What if we flip the model?

Instead of deciding everything at the point of failure

Define Error Codes centrally and attach behaviour and metadata to them

This is the core idea that we'll build on.

From Exceptions to Error Codes

Instead of throwing arbitrary exceptions, we introduce ErrorCodes and construct a domain-level contract.

@Getter
@RequiredArgsConstructor
public enum ErrorCodes {
    DATA_NOT_FOUND(
        HttpStatus.NOT_FOUND,
        "Requested data is not found",
        ErrorHandlers::handleNotFound
    ),
    REQUEST_PROCESS_ERROR(
        HttpStatus.INTERNAL_SERVER_ERROR,
        "Some error occurred while processing this request",
        ErrorHandlers::handleInternalServerError
    );
    // And more error codes

    private final HttpStatus httpStatus;
    private final String message;
    private final Function<AppException, Error> errorHandler;
}
Enter fullscreen mode Exit fullscreen mode

This is no longer just an enum.

It’s a contract.

Each error now defines:

  • Its HTTP semantics
  • Its message
  • Its response strategy

What does the Service layer looks like?

return orderRepository
        .findById(orderId)
        .orElseThrow(() -> new AppException(ErrorCodes.DATA_NOT_FOUND, Map.of("orderId", orderId)));
Enter fullscreen mode Exit fullscreen mode

If you look closely, you'll find that there are:

  • No hardcoded messages
  • No HTTP status codes
  • No logic on how to handle the error

What it translates to is:

Anywhere in the application, if an orderId is not found, it should consistently result in a NOT_FOUND error.

Why This Matters

When the application scales, the entire error handling logic will be isolated in a single place. All classes just point to the error code.

This pattern enforces a few important constraints:

1. Errors are First-Class Citizens

They are no longer side effects, they are explicitly modeled and handled.

2. Behaviour is attached to the Error itself

Instead of

Exception -> Handler -> Response
Enter fullscreen mode Exit fullscreen mode

we now have

ErrorCode -> Behaviour -> Response
Enter fullscreen mode Exit fullscreen mode

The error knows how it should be handled

3. The system becomes predictable

The GlobalErrorHandler becomes trivial

@ControllerAdvice
public class GlobalExceptionAdvice {
    @ExceptionHandler(AppException.class)
    public ResponseEntity<Error> handleAppException(AppException e) {
        var code = e.getErrorCode();
        return new ResponseEntity<>(
                code.getErrorHandler().apply(e),
                code.getHttpStatus()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

This results in no branching or delegation logic, its purely declarative.

4. Error Response Generation is Isolated

The API response for the error is handled in a single place and multiple domains can have multiple ErrorHandlers

public class ErrorHandlers {
    private ErrorHandlers() {
        throw new UnsupportedOperationException("Cannot instantiate utility class");
    }

    public static Error handleNotFound(AppException e) {
        var code = e.getErrorCode();
        return new Error()
                .code(code.name())
                .message(code.getMessage())
                .details(e.getDetails());
    }

    public static Error handleInternalServerError(AppException e) {
        var code = e.getErrorCode();

        return new Error()
                .code(code.name())
                .message(code.getMessage());
    }
}
Enter fullscreen mode Exit fullscreen mode

Each handler has full access to error context to shape the response.

What did we achieve?

Separation of Concerns. Each component now has defined responsibilities

Layer Responsibility
Service Declares failure intent
ErrorCodes Defines error semantics
ErrorHandlers Shape responses
ControllerAdvice Orchestrates Everything

This perfectly captures the essence of Single Responsibility Principle

Trade-offs (There always are)

This approach comes at a cost

Enum Explosion

In large systems, the error codes will grow and so will the enums. But that's not necessarily a bad thing. It requires discipline to maintain and can eventually be split by domain.

High Abstraction

We're adding a level of abstraction that developers will initially find hard to understand.

They will need to know:

  • Where errors are defined
  • How they propagate
  • How handlers are wired

But consistency always comes at a cost.

Overkill for Small Systems

If you’re building a small CRUD app, this is probably unnecessary.
You'll rather have a few errors that you could easily handle without this level of orchestration.

Where This Pattern Actually Shines

This approach works particularly well in:

  • Microservice architectures
  • APIs consumed by external clients
  • Systems requiring structured observability
  • Teams that care about API consistency

Closing Thoughts

This pattern isn’t about clever abstractions.

It’s about taking control of something that usually devolves into chaos.

Most teams don’t design error handling.
They accumulate it over time.

This approach forces you to be intentional.

Define your errors once. Let everything else follow from that.

This is the same philosophy behind:

  • Declarative data transformations
  • Functional error handling
  • Domain-driven design

Move decisions closer to the model, and systems become easier to reason about.

You can check out the entire code in this Github Gist

Top comments (0)