Most Spring Boot applications have a global exception handler.
Far fewer have a well-defined error contract.
There's an important difference.
A global exception handler decides how an exception becomes an HTTP response. An error contract defines what that response means to the application consuming it.
Once services begin communicating over HTTP, that distinction becomes critical.
A frontend needs stable identifiers to display meaningful messages. A backend service needs to distinguish permanent business failures from potentially recoverable dependency failures. Operators need enough diagnostic context to investigate incidents.
These requirements cannot be satisfied reliably by returning an exception message and an HTTP status alone.
While building NERV Exception, an open-source Java and Spring Boot exception-handling library, I explored how to make API failures consistent without tightly coupling independently deployed microservices.
This article walks through the design principles, response structure, Spring Boot implementation patterns, and service-boundary decisions behind that approach.
1. Why HTTP Status Codes Are Not Enough
Consider an order-processing API.
Three different requests fail with HTTP 409 Conflict:
ORDER_ALREADY_PROCESSED
PAYMENT_ALREADY_CAPTURED
INVENTORY_RESERVATION_CONFLICT
All three represent conflicts, but they do not mean the same thing.
A client receiving only:
{
"status": 409,
"message": "Conflict"
}
cannot reliably determine what action to take.
It might need to show a warning, initiate reconciliation, or ask the user to try a different operation.
Parsing the message isn't a good solution because human-readable messages can change.
Instead, introduce stable application error codes:
{
"code": "ORDER_ALREADY_PROCESSED",
"message": "The order has already been processed",
"status": 409
}
The HTTP status communicates the broad outcome.
The application error code communicates the specific failure.
An HTTP status is a classification mechanism, not a complete business error vocabulary.
2. Define a Consistent Error Response
A useful error response should support three audiences:
- API consumers, which need stable identifiers and actionable information.
- Developers, who need predictable behavior across applications.
- Operators, who need enough context to investigate failures.
NERV Exception uses a flat response structure represented by NervErrorResponse.
An illustrative response looks like this:
{
"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-24T08:30:00Z",
"origin": {
"service": "order-service",
"instance": "order-service-1",
"version": "1.0.0",
"environment": "production"
},
"details": {}
}
The application-specific code and category above are illustrative. Not every deployment will populate every diagnostic field.
Each field serves a different purpose.
| Field | Responsibility |
|---|---|
code |
Stable application error identifier |
message |
Human-readable explanation |
status |
HTTP response status |
retryable |
Recovery guidance |
category |
General failure classification |
traceId |
Distributed tracing correlation |
spanId |
Span correlation |
path |
Request path |
timestamp |
Response timestamp |
origin |
Service-origin information |
details |
Structured, error-specific context |
The important design choice is to separate these responsibilities rather than overload a single message or status field.
3. Model Error Codes as Stable Contracts
Suppose your API returns:
CUSTOMER_DISABLED
A frontend recognizes the code and displays an account-specific message.
Later, someone improves the wording of the response:
Old: Customer is disabled.
New: This customer account is currently inactive.
Nothing should break.
The code remains stable even though the message changes.
Now imagine changing the code itself:
CUSTOMER_DISABLED
↓
CUSTOMER_INACTIVE
That may break consumers relying on the original identifier.
This is why error codes should be treated like other published API contracts.
A simple Java model
For an application-specific implementation, you might define:
public record ApiError(
String code,
String message,
int status,
String category,
boolean retryable
) {}
This is a simplified educational example, not a replacement for NERV Exception's full response model.
You can then represent application errors through definitions such as:
public enum OrderError {
ORDER_ALREADY_PROCESSED(
"ORDER_ALREADY_PROCESSED",
409,
"BUSINESS",
false
),
ORDER_NOT_FOUND(
"ORDER_NOT_FOUND",
404,
"CLIENT",
false
);
private final String code;
private final int status;
private final String category;
private final boolean retryable;
OrderError(
String code,
int status,
String category,
boolean retryable
) {
this.code = code;
this.status = status;
this.category = category;
this.retryable = retryable;
}
public String code() {
return code;
}
public int status() {
return status;
}
public String category() {
return category;
}
public boolean retryable() {
return retryable;
}
}
The purpose isn't to recommend that every team build its own enum framework.
It's to demonstrate that an error definition contains more than a message.
It represents an agreement about how a particular failure should be interpreted.
4. Keep Status, Category, and Retryability Independent
It's tempting to derive everything from HTTP status.
For example:
boolean retryable = status >= 500;
But that rule is too simplistic.
A 500 response doesn't necessarily mean the operation is safe to repeat.
A 409 conflict may describe a permanent business condition or a concurrency conflict that can be resolved through a controlled retry.
Likewise, categories should describe the nature of a failure rather than merely repeat the HTTP status family.
Consider:
| Failure | Status | Retryable |
|---|---|---|
| Invalid request | 400 | No |
| Resource not found | 404 | No |
| Already processed | 409 | No |
| Rate limit exceeded | 429 | Policy-dependent |
| Dependency unavailable | 503 | Potentially |
These are illustrative classifications, not universal rules.
In NERV Exception, categories are represented as strings rather than a closed enum.
That allows applications to define categories appropriate to their domains.
However, flexibility also creates responsibility: teams should establish consistent category naming conventions.
Error semantics should be explicit, not inferred from HTTP status alone.
5. Centralize Response Conversion in Spring Boot
A consistent error contract is useful only if the application produces it consistently.
Spring Boot provides @RestControllerAdvice for centralized exception handling.
A minimal educational example might look like this:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderAlreadyProcessedException.class)
public ResponseEntity<ApiError> handle(
OrderAlreadyProcessedException exception
) {
ApiError response = new ApiError(
"ORDER_ALREADY_PROCESSED",
"The order has already been processed",
409,
"BUSINESS",
false
);
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(response);
}
}
This is a simplified Spring MVC pattern. The custom exception and DTO are illustrative.
The problem with implementing this independently in every service is that teams gradually introduce differences.
One service includes error codes.
Another includes only messages.
Another returns validation details in an incompatible format.
Another exposes internal exception causes.
A reusable library can centralize common response conversion behavior while allowing each application to define its own errors.
That is one of the reasons NERV Exception separates shared error contracts, core behavior, and Spring Web integration into different modules.
6. Represent Validation Failures as Structured Data
Validation errors require special consideration.
A single request can contain multiple invalid fields.
For example:
{
"name": "",
"quantity": -5
}
Returning only:
{
"message": "Validation failed"
}
forces consumers to guess which fields need attention.
A more useful response is:
{
"code": "VALIDATION_ERROR",
"message": "Request validation failed",
"status": 400,
"category": "CLIENT",
"retryable": false,
"details": {
"errors": {
"name": "must not be blank",
"quantity": "must be greater than or equal to 1"
}
}
}
This 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 main benefit is that clients can map validation messages to individual form fields without parsing arbitrary strings.
Structured validation details are especially useful for frontend applications, mobile clients, and API integrations.
7. Don't Require Every Microservice to Know Every Error Code
Now consider a distributed architecture:
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
}
Order Service has never encountered this code before.
There are several possible approaches.
Option A: Replace the error with a generic integration failure.
This is simple but discards potentially useful information.
Option B: Import every Payment Service error definition.
This makes the error recognizable but creates tighter coupling between services.
Option C: Preserve unknown error codes using the shared contract.
This allows services to evolve their business error catalogs independently.
NERV Exception's Feign integration supports the third approach through RemoteNervErrorCode.
Unknown versus known codes
The distinction is important.
When a downstream error code is unknown locally, the Feign decoder can construct a remote error definition that preserves the code, message, response-body status, retryability, and category.
When the code is registered locally, the local definition determines status, category, and retryability.
The downstream message, origin, and supported details remain available through the downstream exception.
This means a local registry can influence how a remote error is interpreted.
Registration isn't merely a lookup convenience; it can establish local semantic authority.
What isn't preserved automatically?
Not every field travels unchanged.
The original downstream traceId, spanId, path, and timestamp are not retained by the downstream exception.
When a receiving service generates a new HTTP response, those fields are populated from its current context.
Also, malformed or non-NERV responses are delegated to the configured fallback Feign decoder.
Therefore, a consistent error contract improves propagation but does not guarantee lossless forwarding of every field.
8. Retryable Errors Still Need a Retry Policy
Suppose Payment Service returns:
{
"code": "PAYMENT_PROVIDER_UNAVAILABLE",
"status": 503,
"category": "DEPENDENCY",
"retryable": true
}
Should Order Service automatically retry?
Not necessarily.
Consider a payment that succeeded but whose response was lost.
Repeating the operation without idempotency protection could create a duplicate charge.
Retry decisions must consider:
- Idempotency and operation safety
- Unknown outcomes
- Retry limits
- Backoff and jitter
- Dependency health
- Retry amplification across multiple services
NERV Exception's Feign decoder can wrap a retryable downstream failure in Feign's RetryableException.
But it does not automatically configure a retry policy.
The application and its Feign configuration determine whether retries occur.
Another consideration is what happens after retry exhaustion.
If the wrapper reaches a generic exception handler without application-specific handling, the final response may be a generic internal server error rather than the original downstream error contract.
Retry classification, retry execution, and final error presentation must be designed together.
9. Make Errors Observable Without Exposing Internals
A production error response should help engineers investigate incidents.
But it shouldn't expose sensitive implementation details to public clients.
For example, avoid returning raw database errors, internal hostnames, stack traces, or confidential request values.
Instead, return safe application-level information and use logs and tracing for deeper diagnostics.
A response might include:
{
"code": "PAYMENT_PROVIDER_UNAVAILABLE",
"message": "Payment processing is temporarily unavailable",
"status": 503,
"traceId": "abc123",
"spanId": "def456"
}
NERV Exception can read Micrometer tracing context when available.
However, adding tracing fields to a response does not automatically create distributed traces.
The application must configure the necessary tracing infrastructure.
Review production exposure settings
There is also a practical configuration lesson.
In the reviewed NERV Exception 1.4.0 implementation, the Spring Boot starter defaults enable internal message and cause exposure, although stack-trace exposure is disabled.
For public APIs, review and explicitly restrict these settings.
The effective deployed configuration matters more than assumptions about what a library should hide.
10. Error Contracts Need Compatibility Rules
An error contract is a public API contract.
That means changes to its semantics can affect consumers.
Consider:
{
"code": "PAYMENT_TIMEOUT",
"retryable": false
}
Later, the service changes the response to:
{
"code": "PAYMENT_TIMEOUT",
"retryable": true
}
The JSON schema is unchanged.
But the behavior expected from consumers may change significantly.
A client might begin retrying an operation it previously treated as final.
For this reason, compatibility reviews should include more than field names and data types.
They should consider:
- Error code stability
- Status changes
- Category changes
- Retryability changes
- Unknown-code behavior
- Diagnostic-field exposure
A stable schema doesn't necessarily mean stable semantics.
11. Where NERV Exception Fits
NERV Exception provides reusable infrastructure for consistent exception handling in Java and Spring Boot.
Its modules include:
| Module | Responsibility |
|---|---|
nerv-exception-api |
Shared error contracts |
nerv-exception-core |
Core exception and error-code behavior |
nerv-exception-spring-web |
Spring Web integration |
nerv-exception-spring-feign |
OpenFeign error decoding |
nerv-exception-spring-boot-starter |
Spring Boot integration and configuration |
nerv-exception-jpa |
Persistence-related integration |
nerv-exception-event |
Event-related support |
nerv-exception-spring-kafka |
Kafka-related exception support |
The library doesn't eliminate the need for application-specific decisions.
Each service still needs to own its business error codes, decide which details are safe to expose, and define appropriate retry and recovery policies.
The objective is to standardize the infrastructure without centralizing every business decision.
Final Thoughts
A consistent error contract is more than a JSON format.
It is an agreement about how services communicate failures.
A good contract gives clients stable identifiers, separates error classification from recovery decisions, provides safe diagnostic context, and tolerates independently evolving services.
The difficult part isn't serializing an exception.
It's preserving meaningful failure semantics as applications and microservices grow.
Share the language of failure without forcing every service to share the same business error catalog.
Explore NERV Exception
NERV Exception is part of the open-source NERV ecosystem of Java and Spring Boot libraries.
Explore the projects:
https://github.com/czetsuyatech
For the canonical version of this article and the rest of the NERV Exception series, visit:

Top comments (0)