Definition
- Correct status code – 400 for bad input, 401 for not authenticated, 403 for not authorised, 404 for missing, 409 for conflict, 422 for business-rule failure, 500 for server crash.
- Anti-pattern: 200 + success false – clients, caches, proxies, and monitoring all key off the HTTP status, so a failure disguised as 200 is invisible.
- Stable error code – a machine-readable string like
INSUFFICIENT_FUNDSthat clients switch on; it never changes wording. - Human message – a separate field that can be reworded or localised without breaking clients.
- Trace id – a correlation id in the response and in server logs so support can find the exact request.
- No stack traces – internal details go to logs under the trace id, never to the response.
Imagine every rejected form at National Bank of India came back with a green "approved" stamp but a tiny handwritten note saying "actually rejected." The filing robots, the manager's dashboard, and the auto-resubmit clerk all only look at the stamp, so they treat every rejection as a success and the real problem stays hidden. The right way is a clearly coloured rejection stamp (the status code), a reference number for the reason (the stable error code), a plain explanation (the human message), and a ticket number so support can trace it (the trace id).
Understanding the Concepts
Why is returning 200 with success false an anti-pattern?
Returning 200 with {"success": false} breaks three systems at once. HTTP libraries treat 2xx as success and skip error paths, so the application silently mishandles failures. Monitoring dashboards count HTTP status, so a wall of fake 200s hides a real outage and SLO alerts never fire. Retry logic decides to retry based on status, so a 200 is never retried even if the real failure was a transient 503.
// Anti-pattern: 200 for everything, error hidden in body.
@PostMapping("/transfers")
public Map<String, Object> transfer(@RequestBody TransferRequest req) {
try {
transferService.create(req);
return Map.of("success", true);
} catch (Exception e) {
return Map.of("success", false, "error", e.getMessage()); // 200 + error!
}
}
// Problem: monitoring stays green, clients don't trigger error paths,
// retry logic never retries, caches may cache the "error" response.
What makes a correct status code?
The status code must reflect the failure category. The server should never collapse different failures into one code.
| Status | Meaning | Example |
|---|---|---|
| 400 | Bad Request: malformed or missing fields | Invalid JSON, missing required field |
| 401 | Unauthorized: not authenticated | Missing or expired token |
| 403 | Forbidden: authenticated but not permitted | Customer accessing admin report |
| 404 | Not Found: resource does not exist | Account SAV9999 |
| 409 | Conflict: state conflict | Duplicate account creation |
| 422 | Unprocessable Entity: business-rule failure | Insufficient funds on valid request |
| 429 | Too Many Requests: rate limited | Client exceeded call quota |
| 500 | Internal Server Error: unexpected crash | Null pointer, database down |
// Correct: each failure maps to the right status code.
// InsufficientFundsException → 422 Unprocessable Entity
// AccountNotFoundException → 404 Not Found
// DuplicateAccountException → 409 Conflict
// MethodArgumentNotValid → 400 Bad Request
// AuthenticationException → 401 Unauthorized
// AccessDeniedException → 403 Forbidden
What makes a good error body?
A good error body is consistent across every endpoint so clients write one parser. It separates a stable machine-readable code from a human-readable message, carries a trace id, a timestamp, and never includes stack traces.
package com.bank.exception;
import java.time.Instant;
public record ApiError(
String code, // STABLE: "INSUFFICIENT_FUNDS" – clients switch on this
String message, // Human-readable, can change or be localised
String traceId, // Correlation id to find logs
Instant timestamp // When the error occurred
) {}
Why separate the error code from the message?
The stable code like INSUFFICIENT_FUNDS never changes, so client logic can switch on it reliably. The human message like "your balance is too low" can be reworded or localised freely. Changing the message must never break a client. The team learned this the hard way when the mobile app switched on the message text and a marketing rewording broke the app overnight.
// Client code switches on the stable code, not the message.
if ("INSUFFICIENT_FUNDS".equals(error.code())) {
showAddMoneyButton();
}
// The message can change from "Insufficient funds" to "Your balance is too low"
// without breaking any client.
Why include a trace id and exclude stack traces?
The trace id appears in both the error response and the server logs, so support can take the id from the customer and find the exact request. Stack traces must never appear in the response because they leak class names, framework versions, and internal structure that help attackers and confuse clients. Stack traces go to the logs under the trace id.
// Trace id in the response: customer can share it with support.
// {
// "code": "INSUFFICIENT_FUNDS",
// "message": "Balance 250000 is less than requested 900000",
// "traceId": "a1b2c3d4-e5f6-7890",
// "timestamp": "2026-03-15T10:30:00Z"
// }
// Stack trace in the logs (NOT in the response):
// [traceId=a1b2c3d4] InsufficientFundsException: Balance 250000 < 900000
// at com.bank.service.TransferService.validate(TransferService.java:45)
How do you centralise error handling with @RestControllerAdvice?
Instead of try-catch in every controller, one advice class maps each exception type to a status and a body. This guarantees every endpoint returns the same shape and the same rules.
package com.bank.exception;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import org.slf4j.MDC;
import java.time.Instant;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(InsufficientFundsException.class)
public ResponseEntity<ApiError> handleInsufficient(InsufficientFundsException e) {
return buildError("INSUFFICIENT_FUNDS", e.getMessage(),
HttpStatus.UNPROCESSABLE_ENTITY);
}
@ExceptionHandler(AccountNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(AccountNotFoundException e) {
return buildError("ACCOUNT_NOT_FOUND", e.getMessage(),
HttpStatus.NOT_FOUND);
}
@ExceptionHandler(DuplicateAccountException.class)
public ResponseEntity<ApiError> handleConflict(DuplicateAccountException e) {
return buildError("DUPLICATE_ACCOUNT", e.getMessage(),
HttpStatus.CONFLICT);
}
private ResponseEntity<ApiError> buildError(String code, String message,
HttpStatus status) {
ApiError error = new ApiError(code, message,
MDC.get("traceId"), Instant.now());
return ResponseEntity.status(status).body(error);
}
}
How does this compare to RFC 9457 Problem Details?
RFC 9457 Problem Details is the IETF standard for error bodies, using Content-Type: application/problem+json. It has the same idea: a stable type URL, a title, a status, a detail, and an instance. Spring supports it natively with the ProblemDetail class. You can use Problem Details instead of a custom ApiError record for a standards-based approach.
// RFC 9457 Problem Details via Spring's ProblemDetail.
@ExceptionHandler(InsufficientFundsException.class)
public ProblemDetail handle(InsufficientFundsException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY, e.getMessage());
pd.setType(java.net.URI.create("https://api.nbi.in/problems/insufficient-funds"));
pd.setTitle("Insufficient Funds");
pd.setProperty("traceId", MDC.get("traceId"));
return pd; // Content-Type: application/problem+json automatically
}
What are common mistakes in error handling?
Returning 200 for errors. Leaking stack traces in production. Putting try-catch in every controller instead of centralising. Using the human message as a machine code. Not including a trace id.
// Wrong: try-catch in each controller, stack trace leaked, no trace id.
@PostMapping("/transfers")
public ResponseEntity<?> create(@RequestBody TransferRequest req) {
try {
return ResponseEntity.ok(transferService.create(req));
} catch (Exception e) {
return ResponseEntity.status(500)
.body(Map.of("error", e.toString())); // Stack trace leaked!
}
}
// Correct: let exceptions bubble up to centralised @RestControllerAdvice.
@PostMapping("/transfers")
public ResponseEntity<TransferResponse> create(
@Valid @RequestBody TransferRequest req) {
TransferResponse resp = transferService.create(req);
return ResponseEntity.created(java.net.URI.create("/api/v1/transfers/" + resp.id()))
.body(resp);
// Exceptions are caught by GlobalExceptionHandler automatically.
}
What should a senior engineer know about error design?
The stable error code is part of the API contract, just like URLs. Once clients switch on INSUFFICIENT_FUNDS, you can never rename it without breaking them, so choose codes carefully and document them. The human message is free to change. This separation lets you improve user-facing wording for years without a single client-breaking change.
Visual Overview
┌──────────────────────────────────────────────────────────┐
│ Centralised Error Handling Flow │
├──────────────────────────────────────────────────────────┤
│ │
│ Controller │
│ ┌─────────────────────────────────────────────┐ │
│ │ @PostMapping("/transfers") │ │
│ │ transferService.create(req) │ │
│ │ ↓ throws InsufficientFundsException │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ @RestControllerAdvice (GlobalExceptionHandler) │
│ ┌─────────────────────────────────────────────┐ │
│ │ Maps exception → status + consistent body │ │
│ │ │ │
│ │ InsufficientFunds → 422 + INSUFFICIENT_FUNDS │ │
│ │ AccountNotFound → 404 + ACCOUNT_NOT_FOUND │ │
│ │ DuplicateAccount → 409 + DUPLICATE_ACCOUNT │ │
│ │ ValidationError → 400 + BAD_REQUEST │ │
│ └─────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ Response: │
│ HTTP/1.1 422 Unprocessable Entity │
│ Content-Type: application/json │
│ { │
│ "code": "INSUFFICIENT_FUNDS", │
│ "message": "Balance 250000 < requested 900000", │
│ "traceId": "a1b2c3d4", │
│ "timestamp": "2026-03-15T10:30:00Z" │
│ } │
└──────────────────────────────────────────────────────────┘
Key Takeaway
A REST error response is designed around two things: the correct HTTP status code and a consistent, safe error body. The status code signals the category of failure: 400 for bad input, 401 for not authenticated, 403 for not authorised, 404 for missing, 409 for conflict, 422 for business-rule failure, and 500 for server crash. This matters because the status is the one field that clients, caches, proxies, and monitoring all read.
Returning 200 with {"success": false} is an anti-pattern that was learned the hard way at National Bank of India. During a real incident where thousands of transfers were failing, the operations dashboard stayed completely green because it counts HTTP status, not body contents, so no alert fired and the outage ran undetected for an hour. Clients treated every response as success and never triggered error paths. Retry logic never retried because 200 means success.
A good error body separates a stable machine-readable code from a human-readable message. The code like INSUFFICIENT_FUNDS is part of the API contract and never changes, so client logic switches on it reliably, while the message can be reworded or localised freely. The body also carries a trace id that appears in the server logs so support can find the exact request, and it never includes stack traces, because that leaks internals and helps attackers.
All of this is mapped in one place using @RestControllerAdvice, not scattered try-catch blocks in every controller. Centralising it guarantees every endpoint returns the same shape, the same status rules, and the same safe behaviour. The controllers stay clean and just let exceptions bubble up. That combination of correct status, stable code, trace id, no stack traces, and central mapping is what makes a banking API's errors both debuggable and reliable.
Remember one sentence: use the right HTTP status code, return a consistent body with a stable error code and a trace id, never leak stack traces, and centralise it all in @RestControllerAdvice.
---