Tuesday, August 4, 2026

The Pragmatic Guide to HTTP Status Codes and Error Design in Enterprise APIs

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:

  1. Is the request syntactically malformed?

    • Yes → 400 Bad Request
    • No → Continue
  2. Is the client properly authenticated?

    • No → 401 Unauthorized
    • Yes → Continue
  3. Does the client have permission to perform this operation?

    • No → 403 Forbidden
    • Yes → Continue
  4. Does the target resource exist?

    • No → 404 Not Found
    • Yes → Continue
  5. Does the request conflict with the resource's current state?

    • Yes → 409 Conflict
    • No → Continue
  6. Is the request's precondition (e.g., ETag) met?

    • No → 412 Precondition Failed
    • Yes → Continue
  7. Does the request violate domain business rules despite valid syntax?

    • Yes → 422 Unprocessable Entity
    • No → Proceed with the operation

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 CodeRFC NameSemantic MeaningEnterprise ScenariosAnti-Pattern to Avoid
400Bad RequestSyntactic or structural error in the request payloadMalformed JSON syntax, unparseable primitive types, missing mandatory headersReturning 400 for valid JSON payloads that fail domain business rules
401UnauthorizedAuthentication credential is missing or invalidMissing JWT bearer token, expired OAuth2 session, invalid API keyConfusing with 403 when identity is known but access rights are missing
403ForbiddenIdentity is known, but lacks permission for resourceUser with ROLE_VIEWER attempting a DELETE or PUT operationReturning 404 to hide resource existence unless explicitly required by security policy
404Not FoundTarget URI or entity primary key does not existQuerying /api/v1/orders/a0eebc99 when the record ID is absent in the databaseReturning 404 when a sub-resource query fails due to business permissions
409ConflictRequest conflicts with current state of resourceAttempting to cancel an order already marked CANCELLED or SHIPPEDReturning 400 when a request's syntax is valid but server state prevents execution
412Precondition FailedOptimistic locking header (If-Match) failedUpdating an entity whose ETag value has changed since last fetchAllowing silent overwrites (Lost Update Problem) on concurrent PUT operations
422Unprocessable EntityWell-formed syntax, but violates domain validationPlacing an order when account balance is insufficient or inventory is out of stockReturning generic 500 errors for expected, handling-worthy business rule failures
429Too Many RequestsRate limit or concurrency quota exceededExceeding Token Bucket capacity configured at Gateway or service mesh boundaryDropping 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.

Tuesday, July 14, 2026

The Lost Promise of REST: Why Your API Isn't RESTful (and How to Fix It)

It is common in our industry to label any JSON-over-HTTP service as a "REST API." However, if we hold these services up to the standards set by Roy T. Fielding—the creator of the architectural style—the majority of them fail the test. Most are simply RPC-over-HTTP, or what I call "REST-ish."

They act as data-transfer services, but they lack the defining constraint that makes REST, well, REST: HATEOAS (Hypermedia as the Engine of Application State).

What Does HATEOAS Really Mean?

Fielding’s vision for REST was not about exposing database rows as URLs; it was about creating a system that acts like the web itself.

When you browse the web, you don't hardcode URLs into your browser's address bar to navigate a site. You read a page, see a link, and click it. You submit a form to change your state. The information on the screen provides the affordances—the cues—that tell you what you are allowed to do next.

As Fielding famously noted:

"When I say hypertext, I mean the simultaneous presentation of information and controls such that the information becomes the affordance through which the user (or automaton) obtains choices and selects actions."

For an API to be truly RESTful, machines must be able to follow these same cues.

Three Levels of Hypermedia-Driven Interaction

To move from "REST-ish" to truly RESTful, we must stop forcing clients to memorize our URL structures and start sending them instructions.

1. The Human Standard: HTML

We already know how to do this for humans. When a browser receives an HTML document, it doesn't need an API manual to know how to "Add to Cart." The <form> tag tells the browser the URL, the method, and the required fields.

HTML:
<form action="/cart/items" method="POST">
  <input type="hidden" name="product_id" value="prod_987">
  <button type="submit">Add to Cart</button>
</form>

The browser discovers the interaction, and the user executes it.

2. The Machine Standard: HAL+JSON

For machine-to-machine communication, we need a similar language. HAL (Hypertext Application Language) is a popular choice for expressing relationships. Instead of a static data block, the API returns a set of available links dynamically based on the resource's current state.

JSON:
{
  "order_id": "ord_1024",
  "status": "awaiting_payment",
  "_links": {
    "self": { "href": "/orders/ord_1024" },
    "payment": { "href": "/orders/ord_1024/pay", "title": "Submit payment" }
  }
}

If the order is already paid, the server simply omits the payment link. The client doesn't need if/else logic to guess if payment is allowed—it simply checks if the link exists.

3. The Intelligent Automaton: JSON-LD + Hydra

For highly decoupled systems, we can use JSON-LD paired with Hydra. This takes it a step further by providing a semantic vocabulary that tells the client exactly what is expected.

JSON:

{

  "@context": [

    "http://www.w3.org/ns/hydra/context.jsonld",

    { "@vocab": "http://schema.org/" }

  ],

  "@id": "/users/johndoe",

  "@type": "Person",

  "name": "John Doe",

  "email": "john@example.com",

  "operation": [

    {

      "@type": "ReplaceResourceOperation",

      "method": "PUT",

      "expects": "http://schema.org/Person",

      "title": "Update profile details"

    }

  ],

"operation": [
{ "@type": "ReplaceResourceOperation", "method": "PUT", "expects": "http://schema.org/Person", "title": "Update profile details" } ] }

Here, the client understands the context, the operation type, the HTTP method, and the expected data structure. No out-of-band documentation (like a PDF or an OpenAPI spec) is required to perform the update.

Why This Matters: The Shift to State Machines

When you build APIs this way, you change the nature of your client. Instead of a rigid script that breaks the moment you change a URL, your client becomes a dynamic state machine:

  • The Affordance is King: If the user has permission to edit, the server sends an edit link. If they don't, the link is absent.

  • No Hardcoded URLs: The client only needs the root URL. Every subsequent action is discovered through the payload.

  • Protocol-Driven: By using standard media types like application/hal+json, your API becomes self-documenting.

True REST isn't just about using HTTP verbs—it’s about letting the server drive the application state through hypermedia. It requires a shift in mindset, but the result is a system that is significantly more flexible, discoverable, and resilient to change.


How about REST and OpenAPI, can both technologies be combined?

To understand the relationship between REST (with HATEOAS) and OpenAPI, it helps to view them not as competing technologies, but as serving different functions in the lifecycle of an API.

The Core Difference: Implementation vs. Documentation

  • REST (with HATEOAS) is an architectural style. It defines how your API behaves and how a client interacts with it. HATEOAS (Hypermedia as the Engine of Application State) is the "glory" of REST, where the server provides links within the response to guide the client to the next possible states.

  • OpenAPI is a specification (a blueprint). It is a standard for describing an API. It is not the API itself; rather, it is a document that tells developers and machines what endpoints exist, what parameters they accept, and what they return.

FeatureREST (with HATEOAS)OpenAPI
PurposeDefines architectural constraints/behavior.Documents and describes the API.
FocusHow the client discovers state transitions via links.What the endpoints, schemas, and methods are.
Primary GoalDecoupling the client from the server's URL structure.Enabling automation, tooling, and developer onboarding.
StateThe server dictates the current state via hypermedia links.The document is static and describes potential states.

The Tension: Why They "Don't Work Together Nicely"

You may encounter friction when trying to combine them because their underlying philosophies can contradict each other:

  • REST/HATEOAS says: "The client shouldn't need to know the URL structure beforehand; it should just follow the links provided by the server".

  • OpenAPI says: "Here is a complete map of all URLs and structures so you can build your client right now".

When you use HATEOAS, you are intentionally trying to avoid "hardcoding" URL paths in the client. However, OpenAPI is designed specifically to document those exact paths. When you use an OpenAPI generator to create client code, it often generates static URL-building logic, which effectively defeats the purpose of the dynamic discovery that HATEOAS aims to provide.

Can They Coexist?

Yes, they are often used together, but it requires a careful approach:

  1. Documentation vs. Navigation: You can use OpenAPI to describe the "capabilities" of your API (the resources, the schemas, and the entry points) while using HATEOAS within the actual API responses to handle the specific navigational flow and dynamic state transitions.

  2. Extended Definitions: Some developers include custom link definitions within their OpenAPI files to represent the hypermedia relationships, though standard OpenAPI does not natively "force" the HATEOAS behavior.

  3. Framework Support: Libraries like springdoc-openapi-hateoas exist specifically to bridge this gap, attempting to help OpenAPI documentation recognize and represent the HATEOAS links that your code generates.

Summary: Think of the REST API as the building itself (its layout, its doors, and its hallways) and OpenAPI as the architectural blueprint or map you give to visitors. You can have a building that is designed for people to navigate by signs (HATEOAS), and you can still provide a map (OpenAPI) so they know generally where they are going—just be careful not to make your map so rigid that it breaks the navigation design of the building.