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

by Arif Ikhsanudin, Backend Developer

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

Hibernate Schema Generation and Validation — What ddl-auto Actually Does in Production

The spring.jpa.hibernate.ddl-auto setting controls whether Hibernate modifies your database schema at startup. Most teams use create or update in development and then wonder why production behaves differently. Here is what each setting does and what belongs in production.

Read more

Git Is Not Just a Backup Tool. Here Is What It Actually Is.

Most developers use Git as a glorified save button. Understanding what Git actually models — a directed acyclic graph of snapshots — changes how you use every command.

Read more

Spring Boot Security Checklist — What to Verify Before Going to Production

Security gaps in Spring Boot applications follow predictable patterns. This checklist covers authentication, authorization, data protection, infrastructure configuration, and the vulnerabilities that survive code review. Work through it before every production deployment.

Read more

The Real Cost of Hiring a Backend Developer in Amsterdam (And the Smarter Alternative)

You budgeted for a backend developer. You didn't budget for the three months of interviews, the signing bonus someone else offered first, and the onboarding period where nothing ships. That's the part of the cost nobody puts in the job req.

Read more