
Exception handling is one of those concerns that seems simple until an application grows.
In a small Spring Boot application, a @RestControllerAdvice and a few exception handlers may be enough.
But as the system evolves, new questions emerge:
- How do clients distinguish business failures from infrastructure failures?
- Should consumers depend on exception messages or stable error codes?
- How should validation errors be represented?
- What happens when a downstream microservice returns an error your service doesn't recognize?
- How do you expose useful diagnostics without leaking internal implementation details?
These questions turn exception handling from a controller-level concern into an API design problem.
While building NERV Exception, an open-source exception-handling library for Java and Spring Boot, I explored how to address these problems through reusable contracts and integration modules.
This article covers the design principles behind that approach, along with the trade-offs that matter in production.
1. Why Basic Exception Handling Is Not Enough
Consider a typical Spring Boot exception handler:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(Exception.class)
public ResponseEntity<String> handle(Exception exception) {
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(exception.getMessage());
}
}
This is convenient, but it creates several problems.
First, an exception message isn't a reliable API contract. Developers may change it without realizing that consumers depend on the exact text.
Second, not every exception represents an internal server error. Business conflicts, validation failures, missing resources, and dependency failures have different meanings.
Third, returning raw exception messages may expose internal details.
Finally, there is no structured information for clients to make decisions.
A frontend may need to display a validation message. A backend consumer may need to distinguish a permanent rejection from a transient dependency failure. An operator may need a trace identifier to investigate the incident.
A plain string cannot reliably support all three.
2. Treat Error Responses as API Contracts
A production-oriented error response should communicate enough information for consumers to understand and handle the failure without exposing unnecessary implementation details.
For example:
{
"code": "ORDER_ALREADY_PROCESSED",
"message": "The order has already been processed",
"status": 409,
"retryable": false,
"category": "BUSINESS",
"traceId": "abc123",
"spanId": "def456",
"path": "/api/orders/123/process",
"timestamp": "2026-10-20T08:30:00Z",
"origin": {
"service": "order-service",
"instance": "order-service-1",
"version": "1.0.0",
"environment": "production"
},
"details": {}
}
This is an illustrative payload using the fields supported by NERV Exception's NervErrorResponse. The example business code and values are application-defined.
Each field has a purpose.
code provides a stable identifier that clients can interpret without parsing message text.
message provides a readable explanation appropriate for the intended audience.
status identifies the HTTP status associated with the response.
category describes the general class of failure.
retryable communicates whether retrying may be appropriate, although the caller must still determine whether the operation is safe to repeat.
traceId and spanId help correlate the response with distributed tracing when tracing context is available.
origin identifies the service associated with the failure.
details allows structured, error-specific information to be included when appropriate.
The important design principle is that consumers should depend on stable error semantics rather than exception class names or message text.
3. Separate Application Error Codes from HTTP Status Codes
HTTP statuses are necessary, but they aren't sufficient to describe application failures.
For example, several distinct problems may legitimately return HTTP 409:
409 Conflict
ORDER_ALREADY_PROCESSED
PAYMENT_ALREADY_CAPTURED
INVENTORY_RESERVATION_CONFLICT
A client that receives only 409 Conflict knows the request could not be completed because of a conflict.
It doesn't necessarily know which conflict occurred.
Application error codes add the missing distinction.
However, it's important not to confuse error codes with exception messages.
An error code should remain stable even if the wording of the message changes.
For example:
Code: ORDER_ALREADY_PROCESSED
Old message:
The order was already processed.
New message:
This order has already been completed.
Clients should continue to recognize the same failure.
That is why a stable application error code is a more reliable integration point than a message string.
4. Keep Error Classification Independent
A common mistake is to assume that HTTP status, error category, and retryability are interchangeable.
They are not.
Consider these illustrative examples:
| Failure | HTTP status | Retryable |
|---|---|---|
| Invalid request | 400 | No |
| Missing resource | 404 | No |
| Business conflict | 409 | Usually no |
| Rate limit exceeded | 429 | Depends on policy |
| Temporary dependency failure | 503 | Potentially |
A 500-series status doesn't automatically mean an operation is safe to retry.
Likewise, a 409 response doesn't necessarily tell the consumer whether the conflict is a business rejection or a concurrency condition.
The error category and retryability flag should express their own meanings.
In NERV Exception, categories are represented as strings, allowing applications to define categories appropriate to their domains.
This provides flexibility, but it also means teams should establish conventions for naming and interpreting those categories.
5. Centralize HTTP Error Mapping
A consistent contract is useful only if applications produce it consistently.
Without centralized handling, individual controllers may return different shapes:
{
"error": "Invalid order"
}
{
"message": "Customer not found",
"status": 404
}
{
"success": false,
"reason": "Payment rejected"
}
These variations force consumers to implement endpoint-specific error parsing.
Spring's @RestControllerAdvice provides a central place to translate exceptions into HTTP responses.
A reusable exception-handling library can take this further by standardizing:
- Application error definitions
- Exception-to-response conversion
- Error response serialization
- Structured diagnostic information
- Spring Web integration
NERV Exception separates these responsibilities across modules rather than requiring every application to copy the same exception handler implementation.
The architectural benefit is consistency without duplicating infrastructure code across services.
6. Validation Errors Need Structured Details
Validation failures are different from most application exceptions.
A single request may violate multiple constraints.
For example:
{
"name": "",
"quantity": -5
}
A consumer benefits from knowing which fields failed and why.
A structured response might contain:
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"status": 400,
"retryable": false,
"category": "CLIENT",
"details": {
"errors": {
"name": "must not be blank",
"quantity": "must be greater than or equal to 1"
}
}
}
This example illustrates the details.errors object-map structure used by NERV Exception's validation handling. The exact error code and messages depend on the application and configuration.
The important point is that validation information should be machine-readable.
Clients should not need to extract field names from a sentence.
For public APIs, validation details should also avoid exposing sensitive values or internal implementation information.
7. Microservice Error Propagation Is Not Just Serialization
Exception handling becomes more complicated when services call each other.
Consider:
API Gateway
|
v
Order Service
|
v
Payment Service
Payment Service returns:
{
"code": "PAYMENT_ALREADY_PROCESSED",
"message": "Payment has already been processed",
"status": 409,
"category": "BUSINESS",
"retryable": false
}
What should Order Service do?
It could replace the response with a generic downstream error.
But that would discard useful business information.
Alternatively, Order Service could maintain a copy of every Payment Service error definition.
That creates tighter coupling between services.
A third approach is to use a shared response structure while allowing services to own their business error codes.
This is the approach explored by NERV Exception's Feign integration.
Known and unknown downstream errors
The Feign decoder distinguishes between error codes that are registered locally and those that are not.
For an unknown code, NERV Exception can represent the downstream definition through RemoteNervErrorCode.
That allows the application to retain meaningful information without requiring every remote business code to exist in a shared local catalog.
For a locally registered code, the local definition determines the resolved status, category, and retryability.
This distinction is important because propagation is not always transparent.
A receiving service can reinterpret an error when it has a matching local definition.
Also, the original downstream trace identifiers, request path, and timestamp are not automatically preserved in the regenerated HTTP response.
Those fields are populated from the receiving application's current context.
Therefore, a shared error contract improves interoperability, but it does not guarantee byte-for-byte preservation of downstream responses.
8. Retryable Does Not Mean Automatically Retried
A retryability flag is useful, but it can be misunderstood.
Consider a payment request that times out after the payment provider has already accepted the operation.
The caller cannot determine from the timeout alone whether the payment succeeded.
Retrying without an idempotency mechanism could produce a duplicate operation.
That means retry decisions must account for more than an error flag.
They must consider:
- Whether the operation is idempotent
- Whether the outcome is known
- Retry limits and backoff
- Dependency health
- The risk of retry amplification
NERV Exception's Feign decoder can represent retryable downstream failures using Feign's RetryableException.
But that does not automatically install a retry policy.
Actual retry execution depends on the configured Feign retry mechanism.
Applications must also decide how retry exhaustion should be handled and how the final failure should be exposed.
Retry classification, retry execution, and business recovery are separate responsibilities.
Keeping them separate makes distributed systems easier to reason about.
9. Observability Needs More Than an Error Message
In production, a useful error response should help engineers locate the failure without revealing internal implementation details to API consumers.
For example:
{
"code": "PAYMENT_PROVIDER_UNAVAILABLE",
"message": "Payment processing is temporarily unavailable",
"status": 503,
"traceId": "abc123",
"spanId": "def456"
}
The trace and span identifiers can help connect the client-visible failure to internal telemetry.
However, including tracing fields in a response does not automatically create distributed traces.
The application must have tracing infrastructure configured, and the relevant context must be available.
NERV Exception can read Micrometer tracing context when present.
It does not independently create tracing spans for every exception.
This distinction matters when integrating error handling with observability tools.
A response contract provides correlation information.
Tracing infrastructure provides the actual execution history.
10. Protect Internal Error Information
A production error handler must balance diagnostics with security.
Developers may want to expose exception causes, stack traces, SQL errors, or dependency details during development.
Public consumers generally should not receive that information.
For example, avoid exposing messages such as:
Connection refused to internal-db.company.local:5432
or internal exception stack traces.
Instead, return a safe message and retain detailed diagnostics in controlled logs and tracing systems.
One practical lesson from reviewing NERV Exception's Spring Boot integration is that effective configuration matters.
The starter's default properties in the reviewed 1.4.0 implementation enable internal message and cause exposure, although stack-trace exposure is disabled by default.
For public APIs, explicitly configure these settings to prevent unintended disclosure.
A reusable library can provide controls, but applications are responsible for selecting appropriate production settings.
11. What NERV Exception Provides
NERV Exception organizes exception-handling concerns into reusable modules.
The reviewed project includes:
| Module | Responsibility |
|---|---|
nerv-exception-api |
Shared error contracts |
nerv-exception-core |
Core exception and error-code behavior |
nerv-exception-spring-web |
Spring Web exception handling |
nerv-exception-spring-feign |
Feign error decoding |
nerv-exception-spring-boot-starter |
Spring Boot integration and configuration |
nerv-exception-jpa |
Persistence-related integration |
nerv-exception-event |
Event-related error support |
nerv-exception-spring-kafka |
Kafka-related exception support |
The objective is not to eliminate application-specific exception handling.
Applications still need to define their own error codes, business recovery behavior, security boundaries, and retry policies.
The library provides common infrastructure so teams can focus on those decisions rather than repeatedly implementing the same response contract.
12. Lessons for Production Systems
After exploring these concerns, several principles stand out.
Treat errors as part of your public API. Error codes and their meanings should be stable and documented.
Share contracts without unnecessarily sharing business ownership. Independent services should be able to introduce their own error codes.
Separate classification from recovery. A retryable error does not guarantee a safe retry.
Preserve useful context deliberately. Error propagation may transform metadata, status, and tracing fields.
Design for unknown codes. Consumers should remain functional when downstream services introduce new failures.
Review effective production configuration. Internal diagnostic information should not leak through public error responses.
Most importantly, recognize that consistent exception handling is not simply about formatting JSON.
It's about giving consumers reliable information while allowing applications and services to evolve independently.
Final Thoughts
A global exception handler is a useful starting point.
But production systems need more than centralized try/catch behavior.
They need stable error identifiers, meaningful classifications, structured validation responses, controlled diagnostics, and deliberate error propagation across service boundaries.
These are the concerns that shaped NERV Exception.
The goal isn't to hide every failure behind a uniform response.
It's to make failures understandable, actionable, and consistent without taking away each application's responsibility for its own behavior.
A good error contract standardizes communication without pretending every failure has the same meaning.
Explore NERV Exception
NERV Exception is part of NERV — Next-Generation Engineering for Runtime Velocity, a collection of open-source Java and Spring Boot libraries focused on production-oriented engineering.
Explore the projects:
https://github.com/czetsuyatech
For the canonical version of this article and other engineering articles, visit:
This article is part of the NERV Exception technical series. Future articles explore error contracts, service boundaries, error taxonomies, Feign propagation, retries, and observability in more depth.
Top comments (0)