Building REST APIs

HTTP Status Codes

LuettavaEsikatselu

Katselet ilmaista esikatseluoppituntia.

We have now defined:

HTTP endpoints

Handlers

Request DTOs

Response DTOs

There is one more essential part of the API contract:

How does the client know what happened when a request succeeds or fails?

The first signal is the:

HTTP status code

For example:

201 Created

or:

404 Not Found

or:

409 Conflict

The status code communicates the broad outcome before the client even interprets the response body.

The goal of this lesson is:

Map the expected outcomes of our Product, Inventory, and Order APIs to clear and consistent HTTP status semantics.


Status Codes Are Part of the API Contract

Suppose the client requests:

GET /api/v1/orders/d2e7b44e-463f-4bbc-b16f-b1c80bc80442

and receives:

{
  "id": "d2e7b44e-463f-4bbc-b16f-b1c80bc80442",
  "status": "UNPAID"
}

but the response status is:

500 Internal Server Error

The contract is contradictory.

Likewise, this is weak:

200 OK

with:

{
  "error": "Order not found"
}

The HTTP layer says:

success

while the body says:

failure

Status code and response body should describe the same outcome.


Broad Status Families

The status families most relevant to our API are:

2xx
→ successful request

4xx
→ the request, authentication context,
  authorization, resource state,
  or business state prevents the operation

5xx
→ the server or one of its infrastructure
  dependencies failed unexpectedly

This is our high-level vocabulary.

Specific application error codes provide more detail.


200 OK

Use:

200 OK

for successful operations that return a normal response body.

Examples include:

GET /api/v1/products
GET /api/v1/products/{productId}
GET /api/v1/orders
GET /api/v1/orders/{orderId}

and successful updates where we return the resulting resource.

For example:

PATCH /api/v1/products/{productId}

may return:

200 OK

with the updated Product representation.


Empty Collections Still Return 200

Suppose:

GET /api/v1/orders

finds no Orders for the authenticated Customer.

That is not an error.

The collection exists.

It simply contains no matching elements.

So:

200 OK

with something conceptually like:

{
  "items": []
}

is correct.

Do not return:

404 Not Found

merely because a collection is empty.


201 Created

Use:

201 Created

when the operation successfully creates a new resource.

Our main examples are:

POST /api/v1/products

and:

POST /api/v1/orders

After successful creation:

201 Created

communicates:

A new resource now exists.

That is more precise than returning 200 OK for every success.


Location With 201

Creation may also return a:

Location

header.

For example:

HTTP/1.1 201 Created
Location: /api/v1/orders/d2e7b44e-463f-4bbc-b16f-b1c80bc80442

This identifies the newly created resource.

Conceptually:

POST /api/v1/orders
        ↓
201 Created
        ↓
Location: /api/v1/orders/{orderId}

This is a useful conventional HTTP contract.


204 No Content

Use:

204 No Content

when:

operation succeeded

and

the server intentionally returns no body

But do not combine:

204 No Content

with a normal JSON representation.

If the client benefits from seeing the resulting resource, 200 OK is usually clearer.


We Do Not Need 204 Everywhere

For important operations such as:

Product update

Inventory update

Order cancellation

Order payment

returning the resulting representation may be useful.

For example:

POST /api/v1/orders/{orderId}/cancel

can return:

200 OK

with:

{
  "id": "d2e7b44e-463f-4bbc-b16f-b1c80bc80442",
  "status": "CANCELLED",
  "createdAt": "2026-10-01T00:20:00Z",
  "items": [],
  "totalCents": 9000
}

The client immediately sees the confirmed final state.


A Simple Success Rule

Our baseline is:

resource created
→ 201

successful read/update/command with body
→ 200

successful operation intentionally without body
→ 204

Choose the code based on the observable outcome.

Not habit.


400 Bad Request

Use:

400 Bad Request

when the request fails the API input contract.

Examples:

malformed JSON

missing required field

invalid UUID syntax

invalid quantity

negative priceCents

negative availableQuantity

empty required collection

duplicate Product lines

These problems exist in the submitted request itself.


Example: Invalid Order Input

Suppose:

POST /api/v1/orders

contains:

{
  "items": [
    {
      "productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
      "quantity": -5
    }
  ]
}

The current Inventory state is irrelevant.

The submitted quantity violates the request contract.

So:

400 Bad Request

is appropriate.


Validation Error Contract

Field-level request validation may use our Problem Details shape:

{
  "type": "https://api.tarkev.com/problems/validation-error",
  "title": "Validation failed",
  "status": 400,
  "detail": "One or more fields are invalid.",
  "instance": "/api/v1/orders",
  "code": "VALIDATION_ERROR",
  "errors": [
    {
      "field": "items[0].quantity",
      "code": "INVALID_VALUE",
      "message": "Quantity must be positive."
    }
  ]
}

HTTP status tells the client:

request invalid

The application code and errors[] explain exactly why.


Structural Invalid Requests

Not every invalid request is necessarily tied to one field annotation.

For example:

duplicate Product IDs
inside one Create Order request

violate the operation contract.

That still belongs in the:

400

family.

A stable application code such as:

INVALID_REQUEST

may be appropriate when the error is structural rather than a single field validation failure.

The important distinction is:

invalid submitted request
→ 400

Transport Validation vs Business Conflict

This distinction is important.

Suppose the Customer requests:

quantity = 5

but the current Inventory contains:

2

The request itself is valid.

The Product ID is valid.

The quantity is positive.

The operation fails because:

current business state
cannot satisfy the request

That is not the same category as malformed input.


Do Not Use 400 for Everything

Avoid mapping all expected failures to:

400 Bad Request

For example:

Order missing
→ not automatically 400

Product missing
→ not automatically 400

authentication missing
→ not 400

role not permitted
→ not 400

PAID Order cancellation
→ not 400

A small but meaningful HTTP vocabulary makes the API easier for clients to understand.


401 Unauthorized

Despite its historical name, 401 primarily means:

Valid authentication was not established.

Examples:

missing authentication credential

invalid token

expired/unacceptable token

Conceptually:

HTTP Request
    ↓
Spring Security
    ↓
authenticated identity unavailable
    ↓
401

Authentication Happens Before the UseCase

Suppose:

POST /api/v1/orders

requires an authenticated Customer.

If authentication fails, we should not execute:

CreateOrderUseCase

and then discover that CustomerId is missing.

The security boundary rejects the request first:

401 Unauthorized

Our canonical application error code is:

AUTHENTICATION_REQUIRED

403 Forbidden

Use:

403 Forbidden

when:

authentication succeeded

but

the current identity does not have
authority for this operation

Example:

CUSTOMER role

calls:

POST /api/v1/products

which is an Admin-only operation.

Response:

403 Forbidden

with:

ACCESS_DENIED

is appropriate.


401 vs 403

A useful mental model is:

401
→ We cannot establish an acceptable authenticated identity.
403
→ We know who you are,
  but you are not allowed to perform this operation.

Keep these semantics distinct.


Ownership Is Different From Role Authorization

Suppose authenticated Customer A requests:

GET /api/v1/orders/{orderId}

but the Order belongs to Customer B.

We could theoretically return:

403 Forbidden

But that reveals:

the Order exists

For Customer-owned protected resources, that is unnecessary information leakage.

Our canonical Order access behavior is different.


Hidden Ownership Uses 404

For Customer-facing Order access:

Order missing

and:

Order exists
but belongs to another Customer

both become:

404 Not Found

with:

ORDER_NOT_FOUND

The client cannot distinguish:

resource does not exist

from:

resource exists but is not visible

This is deliberate.


404 Not Found

Use:

404 Not Found

when the required resource cannot be found within the caller's accessible resource scope.

Examples:

GET /api/v1/products/{productId}

when the Product is unavailable according to that endpoint's visibility contract.

And:

GET /api/v1/orders/{orderId}

when no Customer-accessible Order exists.


404 Applies to Mutations Too

404 is not only for GET.

For example:

PATCH /api/v1/products/{productId}

when the Product does not exist:

404 Not Found

Likewise:

POST /api/v1/orders/{orderId}/cancel

when no accessible Customer Order exists:

404 Not Found

The method does not change the meaning of resource absence.


Missing Product During Order Creation

Suppose:

POST /api/v1/orders

contains a Product ID that does not exist.

Our canonical mapping is:

PRODUCT_NOT_FOUND
→ 404

Even though /orders is the primary endpoint, the operation depends on an existing Product resource.

A required referenced resource is missing.

404 communicates that clearly.


Referenced Resource vs Primary Resource

There is some semantic nuance here.

The request is:

POST /api/v1/orders

not:

GET /api/v1/products/{productId}

But Create Order still requires the referenced Product to exist.

Our API deliberately uses:

404
+
PRODUCT_NOT_FOUND

for this case.

Consistency matters more than endlessly debating every possible interpretation.


409 Conflict

Use:

409 Conflict

when:

the request is structurally valid

the required resources exist

but current business state
does not permit the requested operation

This is a strong fit for our Domain conflicts.


PAID Order Cannot Be Cancelled

Suppose:

Order.status = PAID

and the Customer calls:

POST /api/v1/orders/{orderId}/cancel

The request is well-formed.

The Order exists.

The Customer owns it.

But the Domain rejects:

PAID → CANCELLED

So the response is:

409 Conflict

with:

ORDER_NOT_CANCELLABLE

CANCELLED Order Cannot Be Paid

Suppose:

Order.status = CANCELLED

and the Customer calls:

POST /api/v1/orders/{orderId}/pay

Again:

request valid

Order exists

ownership valid

current Order state incompatible

So:

409 Conflict

with:

ORDER_NOT_PAYABLE

fits naturally.


Insufficient Inventory

Suppose Create Order requests:

10 units

while current Inventory is:

3 units

The request is structurally valid.

The Product exists.

But current Inventory cannot satisfy the operation.

Our canonical result is:

409 Conflict

with:

INSUFFICIENT_INVENTORY

Inactive Product

Suppose a Product exists but is inactive when Create Order tries to use it.

That is not:

Product missing

The Product exists.

Its current state does not allow a new Order.

So:

409 Conflict

with:

PRODUCT_NOT_ORDERABLE

is appropriate.


Duplicate Product Lines Are Different

Suppose Create Order contains:

{
  "items": [
    {
      "productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
      "quantity": 1
    },
    {
      "productId": "59ba787c-6d83-4a21-9bae-19c3d0469bd5",
      "quantity": 2
    }
  ]
}

Our request contract says the same Product may appear at most once.

This failure does not depend on current database state.

The submitted operation itself is invalid.

So:

400 Bad Request

is a better fit than 409.

A useful distinction is:

invalid request composition
→ 400

valid request conflicts
with current business state
→ 409

Why We Are Not Using 422

You may see APIs using:

422 Unprocessable Content

for semantically invalid requests.

That can be a valid design.

For this course, however, our intentionally small mapping is:

malformed/invalid request
→ 400

resource unavailable
→ 404

current business-state conflict
→ 409

We do not need every possible HTTP status code.

Consistency matters more.


Do Not Debate 400 vs 422 Forever

Teams can spend a disproportionate amount of time deciding whether one validation problem should return:

400

or:

422

The more important goals are:

consistent convention

stable application error codes

clear documentation

predictable client behavior

Our v1 contract deliberately chooses a simpler vocabulary.


405 Method Not Allowed

Suppose we support:

GET /api/v1/products/{productId}

but the client sends:

DELETE /api/v1/products/{productId}

There is no DELETE endpoint.

Spring may respond with:

405 Method Not Allowed

This is primarily a framework/transport concern.

We do not write Product Domain logic to handle it.


415 Unsupported Media Type

Suppose an endpoint expects:

Content-Type: application/json

but the client sends an unsupported content type.

Spring may respond:

415 Unsupported Media Type

Again, this belongs primarily to the HTTP framework boundary.

Every Handler does not need custom code for it.


5xx Server Errors

Use 5xx for genuine server or infrastructure failures.

Examples:

database unavailable

unexpected NullPointerException

programming bug

unexpected persistence failure

unexpected infrastructure failure

The generic fallback is:

500 Internal Server Error

with a safe Problem Details response.


Business Rejection Is Not 500

Suppose:

order.cancel();

throws:

OrderNotCancellable

because the Order is already PAID.

That is an expected business rejection.

It should become:

409
ORDER_NOT_CANCELLABLE

not:

500

simply because Java represented the outcome with an exception.


Expected Failure vs Unexpected Failure

The HTTP mapping layer must distinguish:

expected application/domain failure

from:

unexpected system failure

Examples of expected failures:

PRODUCT_NOT_FOUND

ORDER_NOT_FOUND

PRODUCT_NOT_ORDERABLE

INSUFFICIENT_INVENTORY

ORDER_NOT_CANCELLABLE

ORDER_NOT_PAYABLE

Unexpected failures fall back to:

500
INTERNAL_ERROR

unless a more specific infrastructure contract has deliberately been defined.


Payment Provider Failures Need Their Own Later Design

Payment introduces additional nuance.

A provider may return:

known payment rejection

which could be an expected integration/business outcome.

Or the provider may be:

unavailable

timing out

returning unusable responses

which may eventually justify infrastructure codes such as:

502 Bad Gateway

or:

503 Service Unavailable

depending on the provider contract.

We do not finalize those mappings yet.

They belong in the Payment integration module when the actual provider behavior is known.


Do Not Invent 502 / 503 Prematurely

It is useful to know these status codes exist.

But do not map every external-service exception to one randomly.

The correct status depends on:

provider semantics

retry behavior

whether outcome is known

whether the service is unavailable

whether the upstream response is unusable

Those details are intentionally deferred.


500 Internal Server Error

500 is our safe fallback for unexpected application failure.

The public response must not expose:

stack trace

SQL statement

Java class name

internal path

database details

secret configuration

Those details belong in internal logs and observability.

The client receives safe application-level meaning.


Database Errors Do Not Define HTTP Semantics

Suppose PostgreSQL throws a:

constraint violation

We do not return raw database information to the client.

Instead ask:

What does this failure mean at the API boundary?

If the database is enforcing an expected business condition and we can translate it meaningfully, a controlled 4xx may be appropriate.

If the constraint failure indicates a bug or broken system invariant, it may correctly become:

500
INTERNAL_ERROR

Translate meaning.

Do not expose exception implementation.


Product Endpoint Statuses

GET /api/v1/products

Success:

200 OK

Even when no Products are currently returned.


GET /api/v1/products/{productId}

Accessible Product exists:

200 OK

Product not found or not visible according to the endpoint contract:

404 Not Found

with:

PRODUCT_NOT_FOUND

POST /api/v1/products

Successful Admin creation:

201 Created

Invalid Product input:

400 Bad Request

Unauthenticated request:

401 Unauthorized

Authenticated caller without Admin authority:

403 Forbidden

PATCH /api/v1/products/{productId}

Successful update:

200 OK

Invalid update payload:

400 Bad Request

Missing Product:

404 Not Found

Non-Admin caller:

403 Forbidden

POST /api/v1/products/{productId}/deactivate

Successful deactivation:

200 OK

Missing Product:

404 Not Found

Caller lacks Admin permission:

403 Forbidden

Repeated deactivation remains successful according to our current Domain model because Product deactivation is intentionally harmless/idempotent.

We do not invent a 409 for it.


Inventory Endpoint Statuses

GET /api/v1/inventory

Authorized Admin request:

200 OK

Authenticated non-Admin:

403 Forbidden

Unauthenticated:

401 Unauthorized

GET /api/v1/inventory/{productId}

Inventory state exists:

200 OK

No accessible Inventory state:

404 Not Found

Remember:

missing Inventory
≠
Inventory quantity = 0

PUT /api/v1/inventory/{productId}

Valid exact quantity update:

200 OK

with the resulting Inventory representation.

Negative quantity:

400 Bad Request

Required target resource missing:

404 Not Found

Non-Admin caller:

403 Forbidden

The fact that the method is PUT does not force the application to accept arbitrary resource creation or upsert behavior.

Application rules still define the target requirements.


Order Endpoint Statuses

Order has the richest status behavior in the API.


POST /api/v1/orders

Successful Order creation:

201 Created

Invalid request input such as:

items missing

items empty

quantity <= 0

duplicate Product IDs

malformed Product UUID

returns:

400 Bad Request

Missing Product During Create Order

If a requested Product does not exist:

404 Not Found

with:

PRODUCT_NOT_FOUND

Product Not Orderable

If the Product exists but is inactive:

409 Conflict

with:

PRODUCT_NOT_ORDERABLE

Insufficient Inventory

If available Inventory cannot satisfy the requested quantity:

409 Conflict

with:

INSUFFICIENT_INVENTORY

GET /api/v1/orders

Authenticated Customer success:

200 OK

No Orders:

200 OK

with an empty paginated collection.

Authentication not established:

401 Unauthorized

GET /api/v1/orders/{orderId}

Accessible Customer-owned Order:

200 OK

No accessible Order:

404 Not Found

with:

ORDER_NOT_FOUND

This intentionally covers both:

Order does not exist

and:

Order belongs to another Customer

POST /api/v1/orders/{orderId}/cancel

Eligible Customer-owned UNPAID Order:

200 OK

with the resulting:

CANCELLED

Order representation.

No accessible Order:

404 Not Found

with:

ORDER_NOT_FOUND

PAID Order:

409 Conflict

with:

ORDER_NOT_CANCELLABLE

Already CANCELLED Order:

409 Conflict

with:

ORDER_NOT_CANCELLABLE

Authentication not established:

401 Unauthorized

POST /api/v1/orders/{orderId}/pay

Eligible Customer-owned UNPAID Order with confirmed Payment success:

200 OK

with:

status = PAID

No accessible Order:

404 Not Found

with:

ORDER_NOT_FOUND

Already PAID:

409 Conflict

with:

ORDER_NOT_PAYABLE

CANCELLED:

409 Conflict

with:

ORDER_NOT_PAYABLE

Authentication missing/invalid:

401 Unauthorized

Provider-specific failure mapping remains deferred until the Payment integration contract is known.


Why Already PAID Is Not 400

The request:

POST /api/v1/orders/{orderId}/pay

is structurally valid.

The URI is valid.

The method is valid.

The caller may own the Order.

The problem is:

current Order state

So:

409 Conflict

communicates the failure more accurately than 400.


Why Inactive Product Is Not 404 During Create Order

The Product exists.

The problem is:

it cannot currently participate
in a new Order

So:

409 Conflict

with:

PRODUCT_NOT_ORDERABLE

is more expressive during Create Order.


Endpoint Context Can Change API Meaning

Consider an inactive Product.

For Customer browsing:

GET /api/v1/products/{productId}

the Product may be intentionally invisible according to the endpoint contract.

That can produce:

404 Not Found

But during:

POST /api/v1/orders

if a Product becomes inactive between browsing and Order creation, the application knows the Product exists but is no longer orderable.

That becomes:

409 Conflict

with:

PRODUCT_NOT_ORDERABLE

The same underlying Domain state can have different HTTP meaning depending on the operation.


Status Mapping Should Be Centralized

We do not want every Handler to contain:

try {
    ...
} catch (ProductNotFound ex) {
    return ResponseEntity
            .notFound()
            .build();
}

and then repeat similar code in every endpoint.

Instead:

application/domain failure
        ↓
central HTTP error mapping
        ↓
status + Problem Details

Spring commonly supports this using:

@ControllerAdvice

and:

@ExceptionHandler

We will implement the detailed error response layer later.


Domain Errors Must Not Know HTTP

Avoid:

public final class OrderNotCancellable
        extends RuntimeException {

    private final int statusCode = 409;
}

The Domain should express:

Order is not cancellable.

The HTTP edge decides:

409
ORDER_NOT_CANCELLABLE

This keeps Domain code transport-independent.


UseCases Must Not Return HTTP Status Codes

Avoid:

public int execute(...) {
    return 404;
}

or:

return HttpStatus.CONFLICT;

A UseCase should produce:

application result

or an application/business failure.

The transport layer translates that outcome into HTTP.


Never Return 200 With success=false

Weak:

200 OK
{
  "success": false,
  "error": "Insufficient inventory"
}

The protocol and body disagree.

Use:

409 Conflict

with structured application error information.


Status Code vs Application Error Code

A status code is intentionally broad.

For example:

409 Conflict

may represent:

PRODUCT_NOT_ORDERABLE

INSUFFICIENT_INVENTORY

ORDER_NOT_CANCELLABLE

ORDER_NOT_PAYABLE

So the complete error contract is:

HTTP status
+
application error code
+
structured Problem Details

Each layer contributes different information.


Do Not Invent One HTTP Status Per Business Error

HTTP has a standardized, finite vocabulary.

We do not need:

a unique HTTP status
for every Domain failure

Instead use:

409

for the broad conflict category and:

ORDER_NOT_PAYABLE

for the precise application meaning.

This keeps the API conventional and expressive.


Empty Collection vs Missing Resource

Compare:

GET /api/v1/orders

with no Orders:

200 OK

and an empty collection.

Versus:

GET /api/v1/orders/{orderId}

when no accessible Order exists:

404 Not Found

These represent different resource semantics.


Updating a Missing Resource

Suppose:

PATCH /api/v1/products/{productId}

has a valid body but the Product does not exist.

Return:

404 Not Found

Do not silently create the Product.

Our PATCH endpoint is not an upsert contract.


PUT Does Not Automatically Mean Upsert

HTTP PUT can support replacement or creation depending on an API contract.

But:

PUT /api/v1/inventory/{productId}

does not automatically mean:

Create arbitrary Inventory for any identifier the caller supplies.

The application still defines which target states are valid.

HTTP method semantics do not override Domain/application rules.


Database Constraint Violations

Suppose PostgreSQL protects:

available_quantity >= 0

with a CHECK constraint.

That constraint is a final integrity boundary.

Its internal details:

constraint name

SQLState

SQL statement

are not the public API vocabulary.

Where possible, expected input is rejected earlier with application meaning.

Unexpected database failures remain server failures.


Status Codes and Logging

Expected:

400

404

409

are not automatically production incidents.

For example:

Customer requests an old Order ID
→ 404

may be completely normal.

Unexpected:

500

is usually much more operationally significant.

Observability should distinguish:

expected client/business failures

from:

server failures

instead of treating every non-2xx response equally.


Status Codes and Metrics

A useful metric may group responses by:

2xx

4xx

5xx

But application-level error codes add important context.

For example, a spike in:

409
INSUFFICIENT_INVENTORY

during checkout may be operationally meaningful even though it is not a server crash.

HTTP status gives one dimension.

Application error code gives another.


Do Not Hide Bugs as 400

This pattern is dangerous:

try {
    ...
} catch (Exception ex) {
    return badRequest();
}

Now:

NullPointerException

database outage

programming bug

all become:

400 Bad Request

That lies to both:

client

operations team

Only known request/application failures should map to controlled 4xx responses.

Unexpected failures should produce safe 5xx responses and internal diagnostics.


Do Not Hide Infrastructure Failure as 409

If PostgreSQL is unavailable during Order creation, the response should not be:

409 Conflict

The Customer's request did not conflict with Order or Inventory business state.

The system failed to execute the operation.

That belongs to the:

5xx

family.


Our V1 Mapping Table

SituationHTTP Status
Successful read200 OK
Successful update/command with response200 OK
New resource created201 Created
Success intentionally without response body204 No Content
Malformed or invalid request400 Bad Request
Authentication not established401 Unauthorized
Authenticated caller lacks required authority403 Forbidden
Required accessible resource not found404 Not Found
Valid request conflicts with current business state409 Conflict
Unexpected application/infrastructure failure500 Internal Server Error

This small vocabulary is our v1 foundation.


Product Examples

POST /api/v1/products
valid Admin request
→ 201
POST /api/v1/products
negative priceCents
→ 400
POST /api/v1/products
authenticated CUSTOMER
→ 403
PATCH /api/v1/products/{missingProductId}
→ 404
POST /api/v1/products/{inactiveProductId}/deactivate
repeated request
→ 200

because repeated deactivation is harmless in our current Product model.


Inventory Examples

PUT /api/v1/inventory/{productId}
availableQuantity = 20
→ 200
PUT /api/v1/inventory/{productId}
availableQuantity = -5
→ 400
GET /api/v1/inventory/{missingProductId}
→ 404
GET /api/v1/inventory
CUSTOMER without Admin authority
→ 403

Order Examples

POST /api/v1/orders
valid
→ 201
POST /api/v1/orders
empty items
→ 400
POST /api/v1/orders
duplicate Product lines
→ 400
POST /api/v1/orders
Product missing
→ 404
POST /api/v1/orders
Product inactive
→ 409
POST /api/v1/orders
Inventory insufficient
→ 409

Order Lifecycle Examples

POST /api/v1/orders/{orderId}/cancel
UNPAID Customer-owned Order
→ 200
POST /api/v1/orders/{orderId}/cancel
PAID Order
→ 409
POST /api/v1/orders/{orderId}/cancel
already CANCELLED
→ 409
POST /api/v1/orders/{orderId}/pay
CANCELLED Order
→ 409
GET /api/v1/orders/{orderId}
not accessible to current Customer
→ 404

Status Codes Must Be Predictable

If missing Product normally means:

404

do not map a similar missing Product condition to:

400

elsewhere without a deliberate reason.

If request validation means:

400

keep that convention stable.

Predictability reduces special-case client logic.


How a Client Can Reason About the API

A client can broadly interpret:

2xx
→ operation succeeded

400
→ request input must be corrected

401
→ authentication must be established

403
→ current authenticated identity lacks authority

404
→ required accessible resource unavailable

409
→ current business state does not permit operation

5xx
→ server or dependency failed

Then the application error code provides precise meaning.

That is a strong and understandable contract.


Common Mistakes

Everything Returns 200

Clients can no longer rely on HTTP outcome semantics.

Everything Returns 400

Validation, missing resources, authentication, authorization, and business conflicts become indistinguishable.

Every Exception Returns 500

Expected Domain/business rejections become false server incidents.

401 and 403 Are Interchangeable

Authentication and authorization answer different questions.

Empty Collection Returns 404

An empty collection is normally a successful collection read.

Expected Business Conflict Returns 500

A PAID Order refusing cancellation is expected behavior, not a server crash.

Database Errors Leak Directly

Clients need application meaning, not SQL implementation details.

Domain Exception Contains HTTP Status

Domain code should remain transport-independent.

Timeout Automatically Becomes 400 or 409

Infrastructure/network failures require their own semantics.

Too Many Status Codes

More codes do not automatically create a better API.

Consistency is more useful than unnecessary nuance.


Status Code Review Checklist

For a failure, ask:

Did the request fail input/transport validation?
→ 400

Was authentication not established?
→ 401

Is the authenticated caller
not permitted by role/authority?
→ 403

Is the required accessible resource missing?
→ 404

Is the request valid but current
business state incompatible?
→ 409

Did application/infrastructure
fail unexpectedly?
→ 5xx

For success:

Was a new resource created?
→ 201

Is this a normal success with a body?
→ 200

Is success intentionally body-less?
→ 204

Keep HTTP Mapping at the Edge

Our direction remains:

Domain / UseCase failure
        ↓
HTTP error mapping
        ↓
status code
+
Problem Details response

Not:

Order
→ knows 409

Not:

Product
→ throws 404

Not:

UseCase
→ returns HttpStatus

This keeps our internal architecture transport-independent.


Engineering Principle

The core principle is:

HTTP status codes describe the category of observable outcome; Domain and application errors describe the business meaning behind that outcome.

Another:

Use 4xx for expected request, authentication, authorization, resource, and business-state failures, while reserving 5xx for genuine application or infrastructure failures.

And:

A small, predictable HTTP vocabulary is more valuable than an inconsistent collection of highly specialized status codes.


Summary

In this lesson, we established that:

  • HTTP status codes are part of the public API contract.
  • Status and response body should communicate the same outcome.
  • 200 OK is our normal success for reads and mutations that return a body.
  • Empty collection reads still return 200.
  • 201 Created is used for successful Product and Order creation.
  • Creation may include a Location header.
  • 204 No Content is used only when the operation intentionally returns no body.
  • 400 Bad Request is used for malformed or invalid request input.
  • Field-level validation can use VALIDATION_ERROR.
  • Structural invalid requests may use INVALID_REQUEST.
  • Duplicate Product lines are invalid request composition and therefore return 400.
  • 401 Unauthorized means authentication was not established.
  • Authentication failures are rejected before protected UseCases execute.
  • 403 Forbidden means the authenticated caller lacks the required authority.
  • Admin-only Product and Inventory operations use 403 for authenticated non-Admins.
  • Customer Order ownership is deliberately existence-hiding.
  • Missing and non-owned Customer Orders both return 404 / ORDER_NOT_FOUND.
  • 404 applies to mutation endpoints as well as reads.
  • Missing referenced Products during Create Order return 404 / PRODUCT_NOT_FOUND.
  • 409 Conflict represents valid operations blocked by current business state.
  • Inactive Product ordering returns 409 / PRODUCT_NOT_ORDERABLE.
  • Insufficient Inventory returns 409 / INSUFFICIENT_INVENTORY.
  • Invalid Order cancellation returns 409 / ORDER_NOT_CANCELLABLE.
  • Invalid Order payment returns 409 / ORDER_NOT_PAYABLE.
  • Repeated Product deactivation remains successful because deactivation is harmless in the current model.
  • 422 is not part of our normal v1 status vocabulary.
  • Framework-level transport failures may naturally produce statuses such as 405 or 415.
  • Expected Domain/application failures must not become 500.
  • Unexpected application or infrastructure failures use safe 5xx responses.
  • 500 / INTERNAL_ERROR is our generic unexpected-failure fallback.
  • Payment-provider-specific 502/503 behavior remains deferred until the actual integration contract is known.
  • Database exceptions do not define the public API contract.
  • SQL state, constraint names, stack traces, and implementation details are never returned as normal API error information.
  • Product creation returns 201.
  • Product updates and deactivation return 200 with the resulting representation.
  • Inventory updates return 200 when returning the resulting Inventory representation.
  • Order creation returns 201.
  • Order reads return 200.
  • Order cancellation and Payment return 200 when successful.
  • Business conflict and malformed input remain separate categories.
  • Empty collections and missing individual resources have different semantics.
  • HTTP PUT does not automatically override the application's target-resource rules.
  • HTTP status mapping should be centralized.
  • Domain errors do not contain HTTP status codes.
  • UseCases do not return HttpStatus.
  • We do not return 200 with success=false.
  • HTTP status provides broad meaning while the application error code provides precision.
  • Operational logging and metrics should distinguish expected 4xx outcomes from genuine 5xx failures.
  • Unexpected bugs must never be disguised as 400.
  • Infrastructure failures must never be disguised as business 409 conflicts.
  • The entire API uses a small, predictable status vocabulary.

Next lesson:

Request Validation

There, we will add Jakarta Bean Validation to our Product, Inventory, and Order request DTOs, distinguish transport validation from Domain invariants, and decide precisely where checks such as required fields, UUID syntax, positive quantity, duplicate Products, Product existence, Product active state, and Inventory availability belong.