Production-Ready Exception Handling in Spring Boot: Beyond @RestControllerAdvice
Exception handling is one of those engineering concerns that rarely receives much attention at the beginning of a project.
You create a Spring Boot application, define a few REST endpoints, and add a global exception handler.
Everything works.
Then the application grows.
More endpoints are introduced. Business rules become more complicated. Services start communicating with other services. Authentication and authorization become important. Eventually, you have several Spring Boot applications, each implementing its own variation of the same exception-handling logic.
That’s when something seemingly simple becomes an architectural problem.
While developing NERV Exception, an open-source exception-handling library for Java and Spring Boot, I wanted to explore a question:
What does production-ready exception handling actually mean when applications must communicate failures consistently, securely, and across service boundaries?
The answer turned out to involve much more than @RestControllerAdvice.
The Problem With Exception Handling That Only Works Locally
Consider a familiar Spring Boot implementation:
@RestControllerAdvice
public class GlobalExceptionHandler { @ExceptionHandler(Exception.class)
public ResponseEntity<String> handle(Exception exception) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(exception.getMessage());
}
}For a small internal application, this might be a reasonable starting point.
But it makes several assumptions.
It assumes that every exception represents an internal server failure.
It assumes that an exception message is appropriate to expose to clients.
It assumes that clients only need a human-readable explanation.
And it assumes that the service handling the exception is also the service where the failure originated.
These assumptions become increasingly difficult to maintain as a system grows.
A validation failure is not the same as a missing resource. A business conflict is not the same as an unavailable payment provider. And a downstream timeout doesn’t necessarily mean the operation failed.
Yet poorly designed exception handling can make all these situations look almost identical.
The first lesson is straightforward:
Exception handling isn’t just about catching failures. It’s about communicating their meaning.
HTTP Status Codes Tell Only Part of the Story
HTTP provides useful semantics.
A 400 response indicates a problem with the request. A 404 indicates that a resource wasn't found. A 409 represents a conflict. A 503 indicates service unavailability.
But HTTP statuses cannot describe every application-specific failure.
Imagine an order service that returns 409 Conflict for three different situations:
- The order has already been processed.
- The payment has already been captured.
- Inventory was reserved by another transaction.
All three may legitimately produce the same HTTP status.
But they can require different actions from the consumer.
A frontend might display a different message for each. Another backend service might need to stop processing in one case but initiate a reconciliation workflow in another.
If the consumer only sees 409, it must guess what happened.
Some systems attempt to solve this by parsing exception messages.
That’s fragile.
Messages are written for humans. They may change during refactoring, localization, or routine improvements to wording.
A better approach is to introduce stable application error codes.
For example:
{
"code": "ORDER_ALREADY_PROCESSED",
"message": "This order has already been processed",
"status": 409,
"category": "BUSINESS",
"retryable": false
}The code identifies the failure.
The message explains it.
The HTTP status communicates the transport-level outcome.
The category provides broader classification.
And retryability offers guidance about possible recovery.
These fields serve different purposes. They shouldn’t be treated as interchangeable.
The example above is illustrative; application-specific codes and categories are defined by the owning application.
An Error Response Is a Public API Contract
Most engineering teams spend considerable effort designing successful API responses.
They review DTOs, naming conventions, backward compatibility, and documentation.
Error responses often receive less scrutiny.
But consumers depend on them too.
If a frontend recognizes ORDER_ALREADY_PROCESSED, that error code has effectively become part of the API.
Renaming it can break consumer behavior, even if the endpoint still returns the same HTTP status and the same JSON structure.
This leads to a useful design principle:
Treat error codes with the same care as successful API response fields.
They should be stable, meaningful, and owned by the application or domain responsible for them.
A structured error contract can also include information for diagnostics.
NERV Exception’s response model supports fields such as:
{
"code": "ORDER_ALREADY_PROCESSED",
"message": "This order has already been processed",
"status": 409,
"retryable": false,
"category": "BUSINESS",
"traceId": "abc123",
"spanId": "def456",
"path": "/api/orders/123/process",
"timestamp": "2026-10-22T08:30:00Z",
"origin": {
"service": "order-service",
"instance": "order-service-1",
"version": "1.0.0",
"environment": "production"
},
"details": {}
}The example illustrates the supported response shape, not a claim that every field will be populated in every deployment.
A trace identifier can help connect a client-visible error with internal telemetry. Origin information can identify the service associated with the failure. Structured details can communicate validation information or other safe context.
But there’s a boundary to respect.
An error response should help a consumer understand what happened without exposing everything the application knows about the failure.
That distinction becomes especially important in production.
Why Copying Global Exception Handlers Doesn’t Scale
Once an organization has several Spring Boot services, a familiar pattern emerges.
One project contains a reasonably good exception handler.
A developer copies it into another project.
The second project adds support for validation errors.
The third introduces custom business exceptions.
Another team adds tracing information.
Someone else changes how internal exception messages are exposed.
Eventually, every service has a slightly different implementation.
The duplication isn’t just a maintenance inconvenience.
It creates inconsistent behavior.
One service might return a stable application error code. Another might return only an exception message. A third might expose an internal exception cause.
Clients now need different error-handling logic depending on which service they call.
This is one reason we built NERV Exception as a reusable library.
The objective was to centralize common infrastructure concerns while allowing applications to retain ownership of their business failures.
That distinction is important.
A shared library can define how errors are represented, converted, and serialized.
But it shouldn’t need to own every business error that every application might produce.
Get czetsuya’s stories in your inbox
Join Medium for free to get updates from this writer.
Reuse the error-handling mechanism, not the entire business error catalog.
The Microservice Boundary Changes Everything
Exception handling becomes more interesting when one service calls another.
Imagine this architecture:
API Gateway
|
v
Order Service
|
v
Payment ServicePayment Service returns:
{
"code": "PAYMENT_ALREADY_PROCESSED",
"message": "Payment has already been processed",
"status": 409,
"category": "BUSINESS",
"retryable": false
}Order Service receives the response through an HTTP client.
Now it has to decide what to do.
Should it discard the downstream error and replace it with a generic integration failure?
Should it maintain a local copy of every Payment Service error code?
Or should it preserve the downstream error even if the code isn’t recognized locally?
Each approach introduces trade-offs.
Replacing the error can discard useful information.
Sharing every business error definition can couple services that should evolve independently.
Preserving remote errors requires the receiving service to tolerate codes it doesn’t understand.
While building NERV Exception’s Feign integration, we explored that third approach.
The library can decode structured downstream errors and represent previously unknown codes using RemoteNervErrorCode.
This allows a service to preserve meaningful downstream error information without requiring every remote business error to be registered locally.
However, there’s an important qualification.
When a downstream code is registered locally, NERV Exception uses the local definition for status, category, and retryability.
That means the receiving service can reinterpret those aspects of the error.
And not every field is carried unchanged across the boundary. A regenerated HTTP response can contain the receiving service’s current trace context, request path, and timestamp rather than the original downstream values.
The lesson is that a consistent error format does not automatically guarantee transparent error propagation.
Service boundaries introduce questions about ownership, authority, and context.
Those are architectural questions, not merely serialization problems.
Retryability Is Not a Promise That Retrying Is Safe
One of the more useful fields in a structured error response is retryable.
But it’s also easy to misunderstand.
Consider a payment request.
Order Service sends a payment instruction to Payment Service.
Payment Service processes the payment successfully, but the response is lost because of a network interruption.
Order Service sees a timeout.
Should it retry?
The failure may be transient, but the operation may already have succeeded.
Without an idempotency mechanism, retrying could create a duplicate payment.
This is why retryability must be treated as guidance rather than a complete recovery policy.
A caller still needs to consider operation idempotency, unknown outcomes, retry limits, backoff, and the effect of repeated requests on downstream dependencies.
NERV Exception’s Feign integration can classify retryable downstream errors using Feign’s RetryableException.
But that classification doesn’t automatically configure a retry policy or guarantee that retry attempts will occur.
Applications remain responsible for retry execution and for deciding what happens when attempts are exhausted.
The broader lesson is:
Error classification, retry execution, and business recovery are three different responsibilities.
A reusable library can help with the first.
The other two require decisions informed by the application’s business behavior.
Production Errors Must Be Useful Without Being Dangerous
During development, detailed exception messages are convenient.
A developer wants to know which database connection failed, which class threw the exception, and what the underlying cause was.
But exposing that information through a public API can create security risks.
An internal hostname, SQL error, stack trace, or dependency detail may be useful to an operator but inappropriate for an external consumer.
A production-oriented design should distinguish between information intended for clients and information intended for internal diagnostics.
For example, an API might return:
{
"code": "PAYMENT_PROVIDER_UNAVAILABLE",
"message": "Payment processing is temporarily unavailable",
"status": 503,
"retryable": true
}Meanwhile, internal logs and traces can contain the deeper information required to investigate the failure.
There was an important implementation lesson here as well.
In the reviewed NERV Exception 1.4.0 implementation, the Spring Boot starter’s default configuration enables internal message and cause exposure, although stack-trace exposure is disabled by default.
That means applications exposing public APIs should explicitly review and restrict those settings.
It’s a reminder that production readiness cannot be judged only by the presence of configuration options.
What matters is the effective configuration of the deployed application.
The same principle applies to tracing.
NERV Exception can use Micrometer tracing context when it is available, but an error-handling library doesn’t automatically create a complete distributed tracing system.
Error contracts and observability infrastructure complement each other.
Neither replaces the other.
What Building NERV Exception Taught Me
Working on reusable exception-handling infrastructure changed how I think about errors in Spring Boot applications.
At first, the challenge appears to be reducing repetitive code.
That’s worthwhile, but it isn’t the most important benefit.
The deeper value comes from making failure behavior consistent across applications.
A well-designed error contract gives consumers stable identifiers and useful classifications.
A reusable integration layer helps applications avoid duplicating response conversion logic.
Structured details make validation failures easier to process.
Tracing context helps operators connect client-visible failures to internal telemetry.
And deliberate handling of downstream errors allows microservices to communicate failures without necessarily sharing every business definition.
But none of these mechanisms eliminates the need for application-level decisions.
Teams still need to decide which errors are public, how unknown codes are handled, which operations are safe to retry, and what information can cross service boundaries.
That’s why I don’t think production-ready exception handling should be measured by how many exceptions a library catches.
It should be measured by whether the resulting behavior is predictable, safe, and useful to the systems consuming it.
Final Thoughts
A global exception handler is a good starting point.
But production-ready exception handling requires more than catching exceptions in one place.
It requires a stable communication contract, meaningful application error codes, safe diagnostic information, and clear responsibilities across service boundaries.
The most important shift is to stop thinking of exceptions solely as internal programming events.
Once a failure crosses an API boundary, it becomes part of how systems communicate.
And like every other form of communication between services, it deserves deliberate design.
The goal isn’t to make every failure look identical. It’s to make every failure understandable without losing the meaning that makes it different.
About NERV Exception
NERV Exception is an open-source Java and Spring Boot library focused on consistent error contracts, exception handling, error code resolution, and integrations such as Spring Web and OpenFeign.
It’s part of the broader NERV engineering ecosystem.
Explore the projects on GitHub.
For the canonical technical article, read “Stop Copying Exception Handlers Between Spring Boot Projects” on CzetsuyaTech.









