Building REST APIs
Consistent API Error Responses
আপনি একটি free preview lesson দেখছেন।
একটি backend API শুধু successful response দিয়ে professional হয় না।
Production system-এ client equally needs to understand:
What failed?
Why did it fail?
Can I fix the request?
Should I retry?
Which field is invalid?
Is this a business conflict or server failure?
আমরা আগের lesson-এ HTTP status code define করেছি।
For example:
400 → invalid request
401 → authentication not established
403 → caller not permitted
404 → resource unavailable
409 → current business state conflict
5xx → server/infrastructure failure
কিন্তু status code alone যথেষ্ট নয়।
Consider:
409 Conflict
এটি হতে পারে:
INSUFFICIENT_INVENTORY
ORDER_ALREADY_PAID
ORDER_ALREADY_CANCELLED
PRODUCT_NOT_ORDERABLE
Client-এর precise reason জানা দরকার।
এই lesson-এ আমরা LiveKlass APIs-এর জন্য একটি consistent Problem Details-style error format তৈরি করব।
Canonical shape:
{
"type": "https://api.liveklass.io/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 greater than 0."
}
]
}
এই lesson-এর goal:
Every API failure should have predictable HTTP semantics, a stable machine-readable error code, a safe human-readable explanation, and field-level details where appropriate.
Why Consistency Matters
Without a common error contract, one Handler might return:
{
"error": "Order not found"
}
another:
{
"message": "Product is unavailable"
}
another:
{
"success": false,
"reason": "NO_STOCK"
}
and another might expose:
{
"exception": "IllegalStateException",
"message": "..."
}
Now every API client needs endpoint-specific error parsing।
That's unnecessary complexity।
Instead, all API failures should follow one structural contract।
Our Canonical Error Shape
The base representation:
{
"type": "https://api.liveklass.io/problems/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "The requested order could not be found.",
"instance": "/api/v1/orders/O-1001",
"code": "ORDER_NOT_FOUND"
}
For validation failures, we additionally include:
"errors": [
{
"field": "items[0].quantity",
"code": "INVALID_VALUE",
"message": "Quantity must be greater than 0."
}
]
The errors field is optional।
It should appear when multiple specific field-level errors are useful।
Understanding Each Field
Let's examine each field carefully।
type
Example:
"type": "https://api.liveklass.io/problems/order-not-found"
type identifies the category of problem।
It should be stable।
Examples:
https://api.liveklass.io/problems/validation-error
https://api.liveklass.io/problems/order-not-found
https://api.liveklass.io/problems/insufficient-inventory
https://api.liveklass.io/problems/order-not-cancellable
This is not a request-specific value।
It identifies the kind of problem।
type Is Not the Java Exception Class
Avoid:
{
"type": "io.liveklass.order.OrderNotFoundException"
}
Why?
Because Java package/class names are implementation details।
Refactoring:
OrderNotFoundException
→ OrderMissingException
should not necessarily break API clients।
Public API vocabulary should remain independent from internal class names।
title
Example:
"title": "Order not found"
This is a short human-readable summary।
It should describe the problem category, not every request-specific detail।
Examples:
Validation failed
Order not found
Insufficient inventory
Order cannot be cancelled
Access denied
Internal server error
Keep it short and stable enough for humans।
Clients should still rely primarily on:
status
code
for programmatic behaviour।
status
Example:
"status": 409
This should match the actual HTTP response status।
If HTTP response is:
409 Conflict
the body should not contain:
"status": 400
That inconsistency makes debugging harder।
detail
Example:
"detail": "The order has already been paid and cannot be cancelled."
detail gives a human-readable explanation for this occurrence।
It can be more specific than title।
For example:
title
→ Order cannot be cancelled
detail
→ Paid orders cannot be cancelled.
Detail Should Be Safe
Do not return:
{
"detail": "Constraint order_status_check violated at OrderRepository.java:92"
}
or:
{
"detail": "PostgreSQL connection jdbc:postgresql://..."
}
or:
{
"detail": "NullPointerException at io.liveklass..."
}
Internal technical detail belongs in logs, not client responses।
instance
Example:
"instance": "/api/v1/orders/O-1001/cancel"
This identifies the request/resource occurrence associated with the problem।
For an API request, using the request path is simple and useful।
Examples:
/api/v1/orders
/api/v1/orders/O-1001
/api/v1/products/P-100
/api/v1/inventory/P-100
Why instance Is Useful
Suppose logs or frontend telemetry contain:
ORDER_NOT_CANCELLABLE
The instance immediately gives additional context:
/api/v1/orders/O-1001/cancel
without exposing internal implementation details।
Later request correlation IDs can provide even stronger operational traceability։
code
Example:
"code": "ORDER_NOT_FOUND"
This is our stable, machine-readable application error code।
This is one of the most important fields for API consumers।
Frontend code can use:
ORDER_NOT_FOUND
INSUFFICIENT_INVENTORY
ORDER_NOT_CANCELLABLE
instead of parsing:
"The requested order could not be found."
Never Make Clients Parse message
Bad frontend:
if (
error.detail ===
"The order has already been paid."
) {
...
}
If wording changes, client breaks।
Better:
if (
error.code ===
"ORDER_NOT_CANCELLABLE"
) {
...
}
Human-readable text can evolve while the machine-readable code remains stable।
Naming Error Codes
Use consistent uppercase identifiers:
VALIDATION_ERROR
ORDER_NOT_FOUND
PRODUCT_NOT_FOUND
PRODUCT_NOT_ORDERABLE
INSUFFICIENT_INVENTORY
ORDER_NOT_CANCELLABLE
ORDER_NOT_PAYABLE
AUTHENTICATION_REQUIRED
ACCESS_DENIED
INTERNAL_ERROR
Avoid mixing:
ORDER_NOT_FOUND
product-not-found
inventoryError
ERR_123
Consistency improves discoverability।
errors
Field-level validation failures can include:
"errors": [
{
"field": "name",
"code": "REQUIRED",
"message": "Name is required."
},
{
"field": "price",
"code": "INVALID_VALUE",
"message": "Price must be greater than or equal to 0."
}
]
This is useful when one request can contain multiple invalid fields।
Field-Level Error Structure
Each item contains:
field
code
message
For example:
{
"field": "items[0].quantity",
"code": "INVALID_VALUE",
"message": "Quantity must be greater than 0."
}
field
The field should correspond to the public request model।
Examples:
name
price
availableQuantity
items
items[0].productId
items[0].quantity
Do not expose:
createOrderRequest.items[0].quantity
or Java property paths containing internal class names if we can normalize them to API-facing field paths।
Field Error code
Useful basic codes:
REQUIRED
INVALID_VALUE
INVALID_FORMAT
Potentially:
TOO_SHORT
TOO_LONG
if those rules actually exist later।
Do not invent dozens of codes when:
REQUIRED
INVALID_VALUE
are enough।
Validation Error Example
Request:
POST /api/v1/orders
{
"items": [
{
"productId": "",
"quantity": 0
}
]
}
Response:
400 Bad Request
Content-Type: application/problem+json
{
"type": "https://api.liveklass.io/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].productId",
"code": "REQUIRED",
"message": "Product ID is required."
},
{
"field": "items[0].quantity",
"code": "INVALID_VALUE",
"message": "Quantity must be greater than 0."
}
]
}
This gives both humans and programs useful information।
Not Every Error Needs errors
Suppose:
GET /api/v1/orders/O-999
returns:
{
"type": "https://api.liveklass.io/problems/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "The requested order could not be found.",
"instance": "/api/v1/orders/O-999",
"code": "ORDER_NOT_FOUND"
}
No field-level errors array is necessary।
The problem applies to the operation/resource as a whole।
Product Not Found
Example:
404 Not Found
{
"type": "https://api.liveklass.io/problems/product-not-found",
"title": "Product not found",
"status": 404,
"detail": "The requested product could not be found.",
"instance": "/api/v1/products/P-999",
"code": "PRODUCT_NOT_FOUND"
}
Missing Product During Order Creation
Suppose:
POST /api/v1/orders
references P-999।
A response could be:
{
"type": "https://api.liveklass.io/problems/product-not-found",
"title": "Product not found",
"status": 404,
"detail": "A product required to create the order could not be found.",
"instance": "/api/v1/orders",
"code": "PRODUCT_NOT_FOUND"
}
We could include Product identity in detail if useful and safe।
For example:
Product P-999 could not be found.
But avoid exposing information that the current caller should not know।
Insufficient Inventory
Request is structurally valid, but current Inventory cannot satisfy it।
Response:
409 Conflict
{
"type": "https://api.liveklass.io/problems/insufficient-inventory",
"title": "Insufficient inventory",
"status": 409,
"detail": "The requested quantity is not currently available.",
"instance": "/api/v1/orders",
"code": "INSUFFICIENT_INVENTORY"
}
Should We Expose Available Quantity?
We could imagine:
{
"availableQuantity": 2
}
But that would create an additional public contract।
Our current requirements do not say customers should know exact Inventory quantity।
So don't add it automatically।
The error only needs to communicate that the requested quantity cannot be fulfilled।
Product Not Orderable
Inactive Product during Order creation:
409 Conflict
{
"type": "https://api.liveklass.io/problems/product-not-orderable",
"title": "Product is not orderable",
"status": 409,
"detail": "The product is not currently available for new orders.",
"instance": "/api/v1/orders",
"code": "PRODUCT_NOT_ORDERABLE"
}
Again, current application state conflicts with the requested operation।
Order Cannot Be Cancelled
Suppose:
Order = PAID
and customer sends:
POST /api/v1/orders/O-1001/cancel
Response:
409 Conflict
{
"type": "https://api.liveklass.io/problems/order-not-cancellable",
"title": "Order cannot be cancelled",
"status": 409,
"detail": "Only unpaid orders can be cancelled.",
"instance": "/api/v1/orders/O-1001/cancel",
"code": "ORDER_NOT_CANCELLABLE"
}
Notice:
Order domain
→ knows cancellation is invalid
HTTP mapping
→ chooses 409 + API problem representation
The Order itself does not construct this JSON।
Order Cannot Be Paid
For:
Order = CANCELLED
and:
POST /api/v1/orders/O-1001/pay
we can return:
{
"type": "https://api.liveklass.io/problems/order-not-payable",
"title": "Order cannot be paid",
"status": 409,
"detail": "Only unpaid orders can be paid.",
"instance": "/api/v1/orders/O-1001/pay",
"code": "ORDER_NOT_PAYABLE"
}
This keeps client behaviour independent from the internal exception class।
Authentication Failure
If authentication is not established:
401 Unauthorized
Problem:
{
"type": "https://api.liveklass.io/problems/authentication-required",
"title": "Authentication required",
"status": 401,
"detail": "Valid authentication is required to access this resource.",
"instance": "/api/v1/orders",
"code": "AUTHENTICATION_REQUIRED"
}
Spring Security may handle this before our Controller runs।
We should still make its error response consistent with the rest of the API।
Access Denied
Authenticated customer tries:
POST /api/v1/products
which requires admin permission։
Response:
403 Forbidden
{
"type": "https://api.liveklass.io/problems/access-denied",
"title": "Access denied",
"status": 403,
"detail": "You do not have permission to perform this operation.",
"instance": "/api/v1/products",
"code": "ACCESS_DENIED"
}
Do not expose internal role-check implementation।
Hidden Resource Ownership
Suppose customer A requests Order belonging to customer B।
We previously chose a security-friendly direction where customer-owned resource existence can be hidden behind:
404 Not Found
Then response should look exactly like a normal missing Order:
{
"type": "https://api.liveklass.io/problems/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "The requested order could not be found.",
"instance": "/api/v1/orders/O-2000",
"code": "ORDER_NOT_FOUND"
}
Do not return:
This order belongs to another customer.
That defeats the existence-hiding design।
Unexpected Server Failure
For an unexpected error:
500 Internal Server Error
Response:
{
"type": "https://api.liveklass.io/problems/internal-error",
"title": "Internal server error",
"status": 500,
"detail": "An unexpected error occurred while processing the request.",
"instance": "/api/v1/orders",
"code": "INTERNAL_ERROR"
}
This is intentionally generic।
What Goes Into Logs Instead?
Internally we may log:
exception type
stack trace
request/correlation ID
relevant safe identifiers
database/infrastructure context
Client receives:
safe problem representation
This separation is essential।
Never Return Stack Traces
Avoid:
{
"detail": "java.lang.NullPointerException: Cannot invoke ...",
"stackTrace": [...]
}
Why?
It can expose:
implementation structure
class names
dependency versions
file paths
database information
and provides poor client UX।
Centralizing Error Handling
Without centralized handling, every Handler becomes:
try {
...
} catch (ProductNotFoundException ex) {
...
} catch (InsufficientInventoryException ex) {
...
} catch (...) {
...
}
This causes:
duplication
inconsistent status codes
inconsistent response bodies
hard-to-maintain mappings
Spring provides a better mechanism:
@ControllerAdvice
and:
@ExceptionHandler
Global API Error Handler
Conceptually:
@RestControllerAdvice
public class ApiExceptionHandler {
}
This Bean can translate exceptions raised by Handlers/UseCases/domain into HTTP problem responses।
Flow:
Handler
↓
UseCase
↓
failure
↓
ApiExceptionHandler
↓
HTTP status + Problem response
Individual Handlers remain focused on normal transport flow।
A Problem Response Model
We can define:
public record ApiProblem(
URI type,
String title,
int status,
String detail,
String instance,
String code,
List<FieldProblem> errors
) {
}
Field error:
public record FieldProblem(
String field,
String code,
String message
) {
}
But errors should be optional for non-validation problems।
Depending on Jackson configuration, we can:
omit null fields
or represent an empty collection consistently।
For our public contract, omitting errors when not applicable is cleaner।
Don't Force Empty Field Errors
Avoid:
{
...
"errors": []
}
on every error if field errors have no meaning।
Prefer:
{
"type": "...",
"title": "Order not found",
"status": 404,
"detail": "...",
"instance": "...",
"code": "ORDER_NOT_FOUND"
}
and include errors only for relevant cases।
Validation Exception Handling
When:
@Valid
@RequestBody
CreateOrderRequest request
fails, Spring raises a framework validation exception before Handler business logic proceeds।
Our centralized error Handler should map that to:
400 Bad Request
with:
code = VALIDATION_ERROR
and field-level errors।
Mapping Validation Constraints to Error Codes
Example Jakarta violations:
@NotBlank
@NotNull
can map to:
REQUIRED
while:
@Positive
@PositiveOrZero
can map to:
INVALID_VALUE
This gives client more stable semantics than exposing annotation class names।
Don't Expose Constraint Annotation Names
Avoid:
{
"code": "PositiveOrZero"
}
because that ties API clients to Jakarta validation implementation।
Better:
{
"code": "INVALID_VALUE"
}
API vocabulary remains ours।
Validation Example: Create Product
Request:
{
"name": "",
"price": -1
}
Response:
{
"type": "https://api.liveklass.io/problems/validation-error",
"title": "Validation failed",
"status": 400,
"detail": "One or more fields are invalid.",
"instance": "/api/v1/products",
"code": "VALIDATION_ERROR",
"errors": [
{
"field": "name",
"code": "REQUIRED",
"message": "Name is required."
},
{
"field": "price",
"code": "INVALID_VALUE",
"message": "Price must be greater than or equal to 0."
}
]
}
JSON Parsing Errors
What if request is malformed:
{
"price":
Bean Validation never runs because JSON cannot be parsed।
Still:
400 Bad Request
But should this be:
VALIDATION_ERROR
?
Not necessarily।
A cleaner distinction can be:
INVALID_REQUEST
because the request could not even be parsed into our DTO।
Example:
{
"type": "https://api.liveklass.io/problems/invalid-request",
"title": "Invalid request",
"status": 400,
"detail": "The request body could not be parsed.",
"instance": "/api/v1/products",
"code": "INVALID_REQUEST"
}
This is different from:
DTO parsed successfully
but fields violated validation rules
Invalid Path Identifier
Once an identifier format is established, malformed IDs may similarly map to:
400 Bad Request
with:
INVALID_REQUEST
or a more specific stable code if useful։
But we should not invent ProductId/OrderId format rules yet।
Application Errors vs Infrastructure Errors
Our exception mappings should distinguish:
known expected business/application failure
from:
unexpected technical failure
For example:
OrderNotFound
→ 404 / ORDER_NOT_FOUND
InsufficientInventory
→ 409 / INSUFFICIENT_INVENTORY
OrderNotCancellable
→ 409 / ORDER_NOT_CANCELLABLE
unexpected SQLException
→ 500 / INTERNAL_ERROR
Never turn all exceptions into 400 or 409।
Exception Naming
Application/domain exceptions could be focused and meaningful:
ProductNotFoundException
InsufficientInventoryException
OrderNotFoundException
OrderNotCancellableException
But don't create a huge inheritance hierarchy merely to support HTTP mapping।
We need enough distinction to express business failure cleanly।
Could We Use One Generic BusinessException?
Something like:
throw new BusinessException(
"ORDER_NOT_CANCELLABLE"
);
can work, but it risks pushing stringly-typed error semantics across the codebase।
Explicit exception/result types are often easier to refactor and understand when the set is still small।
Do not prematurely create a generic error framework।
Domain Exceptions Should Not Know APIProblem
Avoid:
throw new OrderNotCancellableException(
new ApiProblem(...)
);
Domain should not construct:
URI
HTTP status
instance path
API code
It should express business failure only।
HTTP layer performs translation।
Example Mapping
Conceptually:
@ExceptionHandler(
OrderNotFoundException.class
)
public ResponseEntity<ApiProblem>
handleOrderNotFound(
OrderNotFoundException exception,
HttpServletRequest request
) {
ApiProblem problem =
new ApiProblem(
URI.create(
"https://api.liveklass.io/problems/order-not-found"
),
"Order not found",
404,
"The requested order could not be found.",
request.getRequestURI(),
"ORDER_NOT_FOUND",
null
);
return ResponseEntity
.status(HttpStatus.NOT_FOUND)
.body(problem);
}
The exact implementation can later be refactored to reduce repetition।
For now, note the responsibility:
exception
→ HTTP problem translation
Avoid Duplicated Construction Eventually
If every handler manually builds:
new ApiProblem(...)
with repeated fields, a small internal factory/helper in the HTTP error-handling package may become useful।
For example conceptually:
problem(
type,
title,
status,
detail,
instance,
code
)
But don't create a generic framework before repetition actually becomes painful।
Problem Types Should Be Stable
Once published:
https://api.liveklass.io/problems/order-not-found
should not randomly change to:
https://api.liveklass.io/error/order-missing
because clients/documentation may reference it।
Treat problem types like public API identifiers।
Problem Type Naming
Use lowercase kebab-case:
validation-error
invalid-request
product-not-found
product-not-orderable
insufficient-inventory
order-not-found
order-not-cancellable
order-not-payable
authentication-required
access-denied
internal-error
This gives predictable URLs।
API Code Naming
Use uppercase snake case:
VALIDATION_ERROR
INVALID_REQUEST
PRODUCT_NOT_FOUND
PRODUCT_NOT_ORDERABLE
INSUFFICIENT_INVENTORY
ORDER_NOT_FOUND
ORDER_NOT_CANCELLABLE
ORDER_NOT_PAYABLE
AUTHENTICATION_REQUIRED
ACCESS_DENIED
INTERNAL_ERROR
Now:
type
→ URI identifier
code
→ compact machine identifier
Both are stable public contract values।
Avoid Encoding HTTP Status in Error Code
Bad:
HTTP_409_ORDER_ERROR
Better:
ORDER_NOT_CANCELLABLE
Why?
The error code describes the application problem।
HTTP mapping describes transport semantics।
Keeping them separate makes the model cleaner।
Don't Over-Specify Dynamic Details
Suppose insufficient Inventory occurs।
Avoid:
INSUFFICIENT_INVENTORY_PRODUCT_P100_AVAILABLE_2_REQUESTED_5
as a code։
The stable code should remain:
INSUFFICIENT_INVENTORY
Request-specific context belongs in detail or an explicitly designed extension field if ever required।
Error Response Versioning
Because this problem structure is part of the API contract, changing:
code
→ errorCode
or:
errors[].field
→ errors[].property
would be an API contract change।
Keep the format consistent across modules and endpoints।
The API Version in instance
Our application API is expected to use versioned routes such as:
/api/v1/...
Therefore:
"instance": "/api/v1/orders/O-1001/cancel"
should reflect the actual request URI।
Do not hard-code:
/api/v1
inside business exceptions।
The HTTP layer already knows the actual path।
Content Type
A Problem Details-style API can respond with:
Content-Type: application/problem+json
for errors।
This clearly distinguishes problem documents while remaining JSON।
Our Controllers do not need to set this manually in every endpoint; centralized error handling should do it consistently।
Should Success Responses Use the Same Envelope?
No requirement says they should।
Success can remain normal application JSON:
{
"id": "O-1001",
"status": "UNPAID",
...
}
while errors use the problem representation।
Do not wrap successes in fake problem-like structures just for symmetry।
Should Errors Always Have detail?
For our API, yes, provide a safe useful detail।
But avoid putting secrets/internal debugging context there।
Useful:
Only unpaid orders can be cancelled.
Unsafe:
Hibernate detected version conflict on row 184...
Should Clients Display detail Directly?
It may be suitable for many interfaces, but clients should not assume every detail is optimized for final end-user wording।
The stable contract is:
status
code
field errors
UI may choose its own localized messages later।
This also matters because LiveKlass can eventually serve multiple languages while API error codes remain language-neutral।
Error Messages and Localization
Keep:
VALIDATION_ERROR
ORDER_NOT_FOUND
language-independent।
Human messages currently can be English।
If localization becomes a product requirement later, clients can map stable codes to localized user-facing text।
Don't put translated language inside machine-readable error codes।
Logging the Error Code
When expected API errors occur, logging:
code=ORDER_NOT_CANCELLABLE
can be useful for metrics/debugging।
For unexpected failures:
code=INTERNAL_ERROR
can be returned to the client while internal logs retain the original exception।
This creates a useful bridge between API behaviour and observability։
Correlation IDs Later
In Module 11 we will introduce correlation/request IDs।
At that point, a client-facing problem response could potentially include a safe request reference if our API contract benefits from it։
But we should not invent that field now।
Current canonical structure remains:
type
title
status
detail
instance
code
errors?
Handling 404 Consistently
Suppose:
Order does not exist
and:
Order exists but belongs to another customer
are intentionally indistinguishable।
Both should produce exactly the same public shape:
{
"type": "https://api.liveklass.io/problems/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "The requested order could not be found.",
"instance": "/api/v1/orders/O-1001",
"code": "ORDER_NOT_FOUND"
}
Consistency here is part of the security model।
Avoid Different Error Shape for Spring Security
A common inconsistency:
Application error:
{
"type": "...",
"title": "...",
...
}
but authentication failure:
{
"timestamp": "...",
"status": 401,
"error": "Unauthorized",
"path": "..."
}
Now clients must support two error systems।
When implementing Spring Security later, configure:
authentication failures
authorization failures
to produce the same canonical problem format։
Avoid Default Spring Error Responses
Likewise, Spring may otherwise generate framework-default error shapes।
Our public API should not depend on default framework JSON such as:
{
"timestamp": "...",
"status": 500,
"error": "Internal Server Error",
"path": "..."
}
because:
framework upgrade
could change behaviour and it doesn't match our API contract।
Centralized error handling gives us control।
Error Mapping Table
Current baseline:
| Application situation | HTTP | code |
|---|---|---|
| Request validation failed | 400 | VALIDATION_ERROR |
| Malformed request body | 400 | INVALID_REQUEST |
| Authentication missing/invalid | 401 | AUTHENTICATION_REQUIRED |
| Permission denied | 403 | ACCESS_DENIED |
| Product not found | 404 | PRODUCT_NOT_FOUND |
| Order not found/visible | 404 | ORDER_NOT_FOUND |
| Product inactive for ordering | 409 | PRODUCT_NOT_ORDERABLE |
| Inventory insufficient | 409 | INSUFFICIENT_INVENTORY |
| Order cannot be cancelled | 409 | ORDER_NOT_CANCELLABLE |
| Order cannot be paid | 409 | ORDER_NOT_PAYABLE |
| Unexpected server failure | 500 | INTERNAL_ERROR |
This is our initial error vocabulary।
Don't Add Codes for Every Java Branch
Error codes represent meaningful client-observable problems।
We do not need:
ORDER_REPOSITORY_RETURNED_EMPTY
PRODUCT_ACTIVE_FLAG_FALSE
INVENTORY_DECREASE_THROWN
Those describe implementation mechanics।
Public error vocabulary should use product/application meaning।
Validation Field Codes
Initial field-level vocabulary:
REQUIRED
INVALID_VALUE
INVALID_FORMAT
Examples:
name missing
→ REQUIRED
quantity <= 0
→ INVALID_VALUE
malformed identifier format later
→ INVALID_FORMAT
Only add more codes when clients gain real value।
Controller Code Becomes Cleaner
Without centralized errors:
@PostMapping("/{orderId}/cancel")
public ResponseEntity<?> cancel(...) {
try {
...
} catch (OrderNotFoundException ex) {
...
} catch (OrderNotCancellableException ex) {
...
}
}
With centralized handling:
@PostMapping("/{orderId}/cancel")
public OrderResponse cancel(
@PathVariable String orderId
) {
CustomerId customerId =
currentUser.customerId();
Order order =
cancelOrderUseCase.execute(
new OrderId(orderId),
customerId
);
return OrderResponse.from(order);
}
The Handler returns successful flow only।
Failures propagate to the central API error boundary।
Expected Errors Are Still Normal Control Outcomes
An exception such as:
OrderNotCancellableException
does not mean:
production system crashed
It represents a rejected business operation।
Our error layer maps it predictably to:
409
ORDER_NOT_CANCELLABLE
Monitoring should distinguish such expected 4xx outcomes from actual server failures।
Unexpected Exceptions
The central error Handler should also include a fallback for unexpected exceptions։
Conceptually:
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiProblem>
handleUnexpected(
Exception exception,
HttpServletRequest request
) {
// log exception internally
...
}
But the response must remain generic।
Do not return:
exception.getMessage()
blindly to the client।
Why Not Return exception.getMessage()?
Because exception messages may contain:
SQL
table names
internal IDs
provider response details
filesystem paths
secrets
Known application errors can have deliberately designed client-facing detail।
Unknown exceptions should receive a generic message।
Testing Error Responses
We should test the public contract, not merely exception classes।
Example validation test should verify:
status = 400
Content-Type = application/problem+json
code = VALIDATION_ERROR
errors contains items[0].quantity
Product Not Found Test
Verify:
status = 404
code = PRODUCT_NOT_FOUND
type ends with /product-not-found
instance = requested API path
Order Cancellation Conflict Test
Given:
PAID Order
calling:
POST /api/v1/orders/{id}/cancel
should produce:
409
ORDER_NOT_CANCELLABLE
not:
500
This test protects our domain-to-HTTP translation।
Unexpected Failure Test
If a test double makes a Repository fail unexpectedly:
RuntimeException
API should return:
500
INTERNAL_ERROR
and must not expose the underlying message।
Avoid Over-Specifying Messages in Tests
For stable machine semantics, tests should strongly assert:
status
code
type
field
field code
Exact human-readable message assertions can be used selectively।
If every test depends on exact punctuation:
"Order cannot be cancelled."
minor copy improvements become unnecessarily expensive।
Error Contract Checklist
For every API error, ask:
Does the HTTP status represent the broad outcome?
Does `type` identify a stable problem category?
Does `code` provide stable machine-readable meaning?
Is `title` concise?
Is `detail` useful and safe?
Does `instance` reflect the request path?
Are field errors included only when relevant?
Are internal exception/database/provider details hidden?
Could a client handle this error without parsing human text?
Common Mistake 1 — Different Error Shape Per Endpoint
Clients should not need custom parsers for Products, Orders, and Inventory।
Common Mistake 2 — Error Code Equals Java Exception Name
Public contract becomes coupled to implementation classes։
Common Mistake 3 — Raw Spring Default Error JSON
Framework defaults are not our API contract।
Common Mistake 4 — Catch Everything in Every Controller
Creates duplication and inconsistent behaviour।
Common Mistake 5 — exception.getMessage() Returned for 500
May leak internal details।
Common Mistake 6 — errors Added to Every Problem
Field errors only make sense for field-oriented problems।
Common Mistake 7 — Human Message Used as Machine Identifier
Clients should use code, not parse detail।
Common Mistake 8 — Inaccessible Order Returns Different Error
Could leak another customer's resource existence।
Common Mistake 9 — External Provider Error Returned Raw
Integration details should be translated into our API vocabulary।
Common Mistake 10 — Hundreds of Error Codes
Codes should represent meaningful client-observable categories, not every internal branch।
Our Canonical LiveKlass Error Contract
Base problem:
{
"type": "https://api.liveklass.io/problems/order-not-found",
"title": "Order not found",
"status": 404,
"detail": "The requested order could not be found.",
"instance": "/api/v1/orders/O-1001",
"code": "ORDER_NOT_FOUND"
}
Validation problem:
{
"type": "https://api.liveklass.io/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 greater than 0."
}
]
}
This is the API error shape we will carry forward through the rest of the course।
Responsibility Map
Domain
Expresses business validity:
Order cannot be cancelled
Inventory cannot go negative
No HTTP knowledge।
UseCase
Coordinates application workflow and raises/propagates meaningful application failures:
Product not found
Order inaccessible
Insufficient Inventory
No JSON construction।
Handler
Handles successful HTTP request/response mapping।
Does not repeatedly catch application exceptions।
Central API Error Handler
Owns:
exception/failure → HTTP status
problem type
public error code
safe detail
instance path
validation field errors
This is the proper transport error boundary।
Engineering Principle
The core principle:
Every failure should have one business meaning internally and one predictable public representation at the API boundary.
Another:
HTTP status describes the broad category,
codedescribes the application-specific problem, and human-readable text explains it without becoming the machine contract.
And:
Centralize error translation so Controllers remain focused on successful transport flow and domain/application code remains HTTP-independent.
Summary
In this lesson, we learned that:
- All LiveKlass API errors should follow one consistent Problem Details-style representation.
- Our canonical fields are
type,title,status,detail,instance,code, and optionalerrors. typeis a stable LiveKlass problem URI such ashttps://api.liveklass.io/problems/order-not-found.codeis the stable machine-readable application identifier such asORDER_NOT_FOUND.- Human-readable
titleanddetailshould not be parsed by clients for application logic. statusmust match the actual HTTP response status.instanceshould identify the API request path that produced the problem.- Field-level
errorsshould containfield,code, andmessage. - Basic field error codes include
REQUIRED,INVALID_VALUE, andINVALID_FORMAT. - Validation failures use
VALIDATION_ERROR. - Malformed request bodies can use
INVALID_REQUEST. - Missing Products and Orders use specific
404application codes. - Business-state conflicts such as insufficient Inventory or invalid Order transitions use
409with specific application codes. - Authentication and authorization errors should eventually use the same error structure as application errors.
- Inaccessible customer-owned Orders should remain indistinguishable from missing Orders when existence hiding is desired.
- Unexpected failures should return a safe
500 / INTERNAL_ERRORproblem without exposing internal exception details. - Raw stack traces, SQL errors, Java class names, and provider details must not leak through API responses.
- Spring
@RestControllerAdviceand@ExceptionHandlerprovide a natural centralized error translation boundary. - Controllers should not repeat
try/catchmapping logic. - Domain objects and UseCases should not know HTTP status codes or construct API problem documents.
- Error-response tests should focus strongly on stable fields such as status, type, code, and field-level error codes.
- Our canonical error format will remain consistent across Product, Inventory, Order, Security, and later Payment APIs.
Next lesson:
Pagination and Filtering
There we will design bounded collection APIs for Product browsing and Order history, decide what pagination information belongs in request and response models, enforce server-side limits, and avoid implementations that load unbounded datasets before slicing them in memory.