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");
Then evolves into
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Order not found");
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;
}
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)));
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_FOUNDerror.
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
we now have
ErrorCode -> Behaviour -> Response
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()
);
}
}
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());
}
}
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)