Consistent Error Handling Across Your API Is Not a Nice to Have

by Eric Hanson, Backend Developer at Clean Systems Consulting

The hidden cost of inconsistency

When an API has inconsistent error shapes across endpoints, every developer integrating it has to solve the same problem repeatedly: what does an error look like here? They write ad-hoc error handling code, peppered with if (error.message), else if (error.error), else if (error.errors[0].detail). The codebase becomes a map of your API's inconsistency.

This is not a theoretical concern. Most APIs of any age have it. The payments team shipped their endpoints before the auth team defined a standard. The v2 routes got a new framework. One service returns RFC 7807 Problem Details, another returns a custom envelope, a third returns plain strings.

The result is that a client-side error boundary that works for /payments silently fails for /subscriptions.

What you are actually asking clients to handle

Look at three real-world patterns that coexist in many APIs:

// Pattern A — flat object
{ "error": "Not found", "code": 404 }

// Pattern B — nested with array
{ "errors": [{ "title": "Not found", "status": "404" }] }

// Pattern C — message only
{ "message": "Resource not found" }

A developer writing a generic error handler for your API has to check for all three. Every new pattern you add multiplies this. Eventually they stop trying to handle errors generically and write per-endpoint try/catch blocks — which means errors that should be centrally logged get silently swallowed.

How it happens and how to stop it

Inconsistency almost never comes from malice. It comes from:

  • Multiple teams owning different parts of the API
  • Framework defaults that differ from your standard
  • Copied exception handlers that predate your style guide
  • Errors thrown at different layers (framework vs. application vs. library) with different shapes

The fix is not a document that says "use this format." Documents get ignored. The fix is enforcement at the framework level.

In Spring Boot, for example, you define a @ControllerAdvice that catches every exception type and maps it to your standard envelope. Your application code never constructs error responses directly — it throws domain exceptions, and the advice handles the shape:

@ControllerAdvice
public class GlobalExceptionHandler {

  @ExceptionHandler(ValidationException.class)
  public ResponseEntity<ApiError> handleValidation(ValidationException ex) {
    return ResponseEntity.status(400).body(
      ApiError.of("VALIDATION_FAILED", ex.getMessage(), ex.getFieldErrors())
    );
  }

  @ExceptionHandler(ResourceNotFoundException.class)
  public ResponseEntity<ApiError> handleNotFound(ResourceNotFoundException ex) {
    return ResponseEntity.status(404).body(
      ApiError.of("NOT_FOUND", ex.getMessage())
    );
  }

  @ExceptionHandler(Exception.class)
  public ResponseEntity<ApiError> handleGeneric(Exception ex, HttpServletRequest req) {
    log.error("Unhandled exception on {}", req.getRequestURI(), ex);
    return ResponseEntity.status(500).body(
      ApiError.of("INTERNAL_ERROR", "An unexpected error occurred")
    );
  }
}

Now it is structurally impossible for an endpoint to accidentally return a different error shape. The framework enforces the contract.

In Express, the equivalent is a centralized error middleware registered last:

app.use((err, req, res, next) => {
  const status = err.status || 500;
  res.status(status).json({
    error: {
      code: err.code || 'INTERNAL_ERROR',
      message: err.message || 'An unexpected error occurred',
      request_id: req.id,
    }
  });
});

All routes call next(err) — they never call res.json() on error paths themselves.

Contract testing for error shapes

Consistency enforcement should not rely only on the framework. Add contract tests that assert the error shape, not just the status code:

it('returns standard error envelope on 400', async () => {
  const res = await request(app).post('/users').send({ email: 'bad' });
  expect(res.status).toBe(400);
  expect(res.body).toMatchObject({
    error: {
      code: expect.any(String),
      message: expect.any(String),
      request_id: expect.any(String),
    }
  });
});

Run this test for every endpoint's known error conditions. If a new framework upgrade accidentally changes your error format, this catches it before it ships.

The RFC 7807 option

If you are starting fresh or doing a major API revision, consider RFC 7807 (Problem Details for HTTP APIs). It defines a standard JSON error format with fields like type, title, status, detail, and instance. Spring Boot 3.x implements it natively. The advantage is that tooling and client libraries already understand it.

The downside: it is opinionated about field names and the type field expects a URI. If you have existing clients, migration is painful. Adopt it at the start, not mid-stream.

What consistent errors unlock

Once your errors are consistent, developers can write a single error handler and trust it everywhere. SDKs become simpler. Monitoring becomes easier — you can alert on code: "INTERNAL_ERROR" across your entire API surface with one rule. Support tickets decrease because the error messages are useful enough that developers can self-serve.

Consistency is not a polish step. It is what makes your API behave like a single coherent system instead of a collection of independent services that happen to share a domain name.

Scale Your Backend - Need an Experienced Backend Developer?

We provide backend engineers who join your team as contractors to help build, improve, and scale your backend systems.

We focus on clean backend design, clear documentation, and systems that remain reliable as products grow. Our goal is to strengthen your team and deliver backend systems that are easy to operate and maintain.

We work from our own development environments and support teams across US, EU, and APAC timezones. Our workflow emphasizes documentation and asynchronous collaboration to keep development efficient and focused.

  • Production Backend Experience. Experience building and maintaining backend systems, APIs, and databases used in production.
  • Scalable Architecture. Design backend systems that stay reliable as your product and traffic grow.
  • Contractor Friendly. Flexible engagement for short projects, long-term support, or extra help during releases.
  • Focus on Backend Reliability. Improve API performance, database stability, and overall backend reliability.
  • Documentation-Driven Development. Development guided by clear documentation so teams stay aligned and work efficiently.
  • Domain-Driven Design. Design backend systems around real business processes and product needs.

Tell us about your project

Our offices

  • Copenhagen
    1 Carlsberg Gate
    1260, København, Denmark
  • Magelang
    12 Jalan Bligo
    56485, Magelang, Indonesia

More articles

Seattle Backend Salaries Hit $175K Because Big Tech Sets the Floor — Here Is How Startups Compete

You wrote a job post with a salary range you thought was competitive. Then you watched every qualified applicant ghost you after the first screen.

Read more

Spring Boot Caching in Practice — @Cacheable, Cache Warming, and When Caching Makes Things Worse

Spring Boot's caching abstraction makes it easy to add caching to any method. What it doesn't tell you is when caching the wrong things causes stale data bugs, cache stampedes, and memory pressure that's harder to debug than the original performance problem.

Read more

Testing Ruby Service Objects with RSpec — My Go-To Approach

Service objects are easy to test well and easy to test badly. The difference is in how you handle dependencies, what you assert on, and where you draw the boundary between unit and integration.

Read more

How Backend Contractors Actually Work

A quick look behind the scenes of what you’re really paying for (and why it’s usually not just “someone writing APIs”)

Read more