DEV Community

Ed Legaspi
Ed Legaspi

Posted on Originally published at czetsuyatech.com

Designing a Consistent Error Contract for Spring Boot APIs

Consistent Spring Boot API error contract showing error codes, categories, retryability, tracing, and microservice propagation

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
Enter fullscreen mode Exit fullscreen mode

All three represent conflicts, but they do not mean the same thing.

A client receiving only:

{
  "status": 409,
  "message": "Conflict"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. API consumers, which need stable identifiers and actionable information.
  2. Developers, who need predictable behavior across applications.
  3. 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": {}
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Nothing should break.

The code remains stable even though the message changes.

Now imagine changing the code itself:

CUSTOMER_DISABLED
        ↓
CUSTOMER_INACTIVE
Enter fullscreen mode Exit fullscreen mode

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
) {}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Returning only:

{
  "message": "Validation failed"
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Payment Service returns:

{
  "code": "PAYMENT_ALREADY_PROCESSED",
  "message": "Payment has already been processed",
  "status": 409,
  "category": "BUSINESS",
  "retryable": false
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

Later, the service changes the response to:

{
  "code": "PAYMENT_TIMEOUT",
  "retryable": true
}
Enter fullscreen mode Exit fullscreen mode

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:

https://www.czetsuyatech.com

Top comments (0)