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.