Navigating the complex world of HTTP status codes doesn't have to be a source of endless debate
As engineering teams build and scale enterprise APIs, few topics generate as much passionate discussion as HTTP status code selection. While status codes like 200 OK, 404 Not Found, and 500 Internal Server Error seem straightforward, the real challenges emerge when we venture into the nuanced territory of client errors—those dreaded 4xx responses.
Should you return a 400 Bad Request or a 422 Unprocessable Entity when a customer's order exceeds their credit limit? Is 409 Conflict appropriate for a subscription cancellation that's already been processed? What about idempotent operations that succeed but return no content—200 or 204?
These questions plague API designers and backend engineers alike. This guide offers a pragmatic, production-ready approach to HTTP status code selection, complete with Spring Boot 3 implementations that follow RFC 9457 standards.
The Decision Tree: Your HTTP Status Code Compass
When designing endpoint responses for error scenarios, validation failures, or state mutations across microservices, follow this decision workflow to consistently select the appropriate status code:
Is the request syntactically malformed?
- Yes →
400 Bad Request - No → Continue
- Yes →
Is the client properly authenticated?
- No →
401 Unauthorized - Yes → Continue
- No →
Does the client have permission to perform this operation?
- No →
403 Forbidden - Yes → Continue
- No →
Does the target resource exist?
- No →
404 Not Found - Yes → Continue
- No →
Does the request conflict with the resource's current state?
- Yes →
409 Conflict - No → Continue
- Yes →
Is the request's precondition (e.g., ETag) met?
- No →
412 Precondition Failed - Yes → Continue
- No →
Does the request violate domain business rules despite valid syntax?
- Yes →
422 Unprocessable Entity - No → Proceed with the operation
- Yes →
This decision tree provides clarity for even the most ambiguous error scenarios, ensuring your API responds predictably and consistently.
The Decision Matrix: 4xx Status Codes Demystified
| Status Code | RFC Name | Semantic Meaning | Enterprise Scenarios | Anti-Pattern to Avoid |
|---|---|---|---|---|
| 400 | Bad Request | Syntactic or structural error in the request payload | Malformed JSON syntax, unparseable primitive types, missing mandatory headers | Returning 400 for valid JSON payloads that fail domain business rules |
| 401 | Unauthorized | Authentication credential is missing or invalid | Missing JWT bearer token, expired OAuth2 session, invalid API key | Confusing with 403 when identity is known but access rights are missing |
| 403 | Forbidden | Identity is known, but lacks permission for resource | User with ROLE_VIEWER attempting a DELETE or PUT operation | Returning 404 to hide resource existence unless explicitly required by security policy |
| 404 | Not Found | Target URI or entity primary key does not exist | Querying /api/v1/orders/a0eebc99 when the record ID is absent in the database | Returning 404 when a sub-resource query fails due to business permissions |
| 409 | Conflict | Request conflicts with current state of resource | Attempting to cancel an order already marked CANCELLED or SHIPPED | Returning 400 when a request's syntax is valid but server state prevents execution |
| 412 | Precondition Failed | Optimistic locking header (If-Match) failed | Updating an entity whose ETag value has changed since last fetch | Allowing silent overwrites (Lost Update Problem) on concurrent PUT operations |
| 422 | Unprocessable Entity | Well-formed syntax, but violates domain validation | Placing an order when account balance is insufficient or inventory is out of stock | Returning generic 500 errors for expected, handling-worthy business rule failures |
| 429 | Too Many Requests | Rate limit or concurrency quota exceeded | Exceeding Token Bucket capacity configured at Gateway or service mesh boundary | Dropping requests silently without returning Retry-After headers |
Spring Boot 3 Implementation: Embracing RFC 9457
Spring Boot 3 provides first-class support for RFC 9457 (Problem Details for HTTP APIs) through the org.springframework.http.ProblemDetail abstraction. This standardizes error responses across your API, making them machine-readable and developer-friendly.
Step 1: Define Custom Domain Exceptions
Start by creating domain-specific exceptions that map to HTTP status codes:
package com.coffeestream.shared.exception;
/**
* Thrown when an operation cannot be completed due to the target resource's current state.
* Maps to HTTP 409 Conflict.
*/
public class ResourceStateConflictException extends RuntimeException {
private final String resourceId;
private final String currentState;
public ResourceStateConflictException(String resourceId, String currentState, String message) {
super(message);
this.resourceId = resourceId;
this.currentState = currentState;
}
public String getResourceId() { return resourceId; }
public String getCurrentState() { return currentState; }
}
package com.coffeestream.shared.exception;
/**
* Thrown when a request violates domain rules despite valid syntax.
* Maps to HTTP 422 Unprocessable Entity.
*/
public class BusinessRuleViolationException extends RuntimeException {
private final String errorCode;
public BusinessRuleViolationException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
Step 2: Implement Global Exception Handling
Use @RestControllerAdvice to centralize exception handling and convert your domain exceptions to RFC 7807 ProblemDetail responses:
package com.coffeestream.shared.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.net.URI;
import java.time.Instant;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final String BASE_ERROR_URI = "https://api.coffeestream.com/errors/";
@ExceptionHandler(ResourceStateConflictException.class)
public ProblemDetail handleResourceStateConflict(ResourceStateConflictException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT,
ex.getMessage()
);
problem.setTitle("Resource State Conflict");
problem.setType(URI.create(BASE_ERROR_URI + "state-conflict"));
problem.setProperty("code", "INVALID_STATE_TRANSITION");
problem.setProperty("resourceId", ex.getResourceId());
problem.setProperty("currentState", ex.getCurrentState());
problem.setProperty("timestamp", Instant.now());
return problem;
}
@ExceptionHandler(BusinessRuleViolationException.class)
public ProblemDetail handleBusinessRuleViolation(BusinessRuleViolationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.UNPROCESSABLE_ENTITY,
ex.getMessage()
);
problem.setTitle("Unprocessable Entity");
problem.setType(URI.create(BASE_ERROR_URI + "business-rule-violation"));
problem.setProperty("code", ex.getErrorCode());
problem.setProperty("timestamp", Instant.now());
return problem;
}
}
Step 3: Observe the Canonical RFC 9457 Wire Responses
With this implementation, your API produces consistent, informative error responses:
409 Conflict (State Machine Collision)
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.coffeestream.com/errors/state-conflict",
"title": "Resource State Conflict",
"status": 409,
"detail": "Order 'a0eebc99-9c0b-4ef8' cannot be cancelled because it is in state 'SHIPPED'.",
"instance": "/api/v1/orders/a0eebc99-9c0b-4ef8/cancellations",
"code": "INVALID_STATE_TRANSITION",
"resourceId": "a0eebc99-9c0b-4ef8",
"currentState": "SHIPPED",
"timestamp": "2026-08-04T09:30:00Z"
}
422 Unprocessable Entity (Domain Business Failure)
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/problem+json
{
"type": "https://api.coffeestream.com/errors/business-rule-violation",
"title": "Unprocessable Entity",
"status": 422,
"detail": "Selected coffee roast 'Ethiopian Yirgacheffe' is currently out of stock.",
"instance": "/api/v1/subscriptions",
"code": "ITEM_OUT_OF_STOCK",
"timestamp": "2026-08-04T09:30:00Z"
}
Best Practices for Enterprise Error Governance
1. Enforce Media Type Standards
Always return Content-Type: application/problem+json for 4xx and 5xx responses to conform with RFC 9457. This signals to clients that your error responses follow the standard format.
2. Standardize Machine-Readable Error Codes
Include a string identifier (like code: "ITEM_OUT_OF_STOCK") alongside human-readable detail messages. This allows frontend applications and API clients to handle errors programmatically without relying on fragile string-parsing of human messages.
3. Never Disclose Internal Stack Traces
Map internal database or framework exceptions to generic ProblemDetail objects without exposing internal table or class names. Your production errors should help clients debug their requests—not provide a roadmap to your database schema.
4. Preserve Distributed Trace Context
Add OpenTelemetry traceId and spanId as top-level extension properties in your ProblemDetail objects. This simple addition simplifies cross-service debugging and helps your operations team quickly pinpoint issues across your microservices architecture.
Wrapping Up
Thoughtful HTTP status code selection is a hallmark of well-designed APIs. By following this pragmatic guide and leveraging Spring Boot 3's RFC 9457 support, you can create enterprise-grade APIs that are consistent, debuggable, and developer-friendly.
The decision tree and matrix provided here serve as your compass when navigating ambiguous error scenarios. With these tools and a production-ready implementation, you'll no longer find yourself debating whether a request deserves a 400 or a 422—you'll know exactly which status code communicates the right message to your API consumers.