Building REST APIs
HTTP Status Codes
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
| Situation | HTTP Status |
|---|---|
| Successful read | 200 OK |
| Successful update/command with response | 200 OK |
| New resource created | 201 Created |
| Success intentionally without response body | 204 No Content |
| Malformed or invalid request | 400 Bad Request |
| Authentication not established | 401 Unauthorized |
| Authenticated caller lacks required authority | 403 Forbidden |
| Required accessible resource not found | 404 Not Found |
| Valid request conflicts with current business state | 409 Conflict |
| Unexpected application/infrastructure failure | 500 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
4xxfor expected request, authentication, authorization, resource, and business-state failures, while reserving5xxfor 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 OKis our normal success for reads and mutations that return a body.- Empty collection reads still return
200. 201 Createdis used for successful Product and Order creation.- Creation may include a
Locationheader. 204 No Contentis used only when the operation intentionally returns no body.400 Bad Requestis 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 Unauthorizedmeans authentication was not established.- Authentication failures are rejected before protected UseCases execute.
403 Forbiddenmeans the authenticated caller lacks the required authority.- Admin-only Product and Inventory operations use
403for authenticated non-Admins. - Customer Order ownership is deliberately existence-hiding.
- Missing and non-owned Customer Orders both return
404 / ORDER_NOT_FOUND. 404applies to mutation endpoints as well as reads.- Missing referenced Products during Create Order return
404 / PRODUCT_NOT_FOUND. 409 Conflictrepresents 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.
422is not part of our normal v1 status vocabulary.- Framework-level transport failures may naturally produce statuses such as
405or415. - Expected Domain/application failures must not become
500. - Unexpected application or infrastructure failures use safe
5xxresponses. 500 / INTERNAL_ERRORis our generic unexpected-failure fallback.- Payment-provider-specific
502/503behavior 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
200with the resulting representation. - Inventory updates return
200when returning the resulting Inventory representation. - Order creation returns
201. - Order reads return
200. - Order cancellation and Payment return
200when successful. - Business conflict and malformed input remain separate categories.
- Empty collections and missing individual resources have different semantics.
- HTTP
PUTdoes 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
200withsuccess=false. - HTTP status provides broad meaning while the application error code provides precision.
- Operational logging and metrics should distinguish expected
4xxoutcomes from genuine5xxfailures. - Unexpected bugs must never be disguised as
400. - Infrastructure failures must never be disguised as business
409conflicts. - 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.
