Building REST APIs

Consistent API Error Responses

ReadingPreview

আপনি একটি 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 situationHTTPcode
Request validation failed400VALIDATION_ERROR
Malformed request body400INVALID_REQUEST
Authentication missing/invalid401AUTHENTICATION_REQUIRED
Permission denied403ACCESS_DENIED
Product not found404PRODUCT_NOT_FOUND
Order not found/visible404ORDER_NOT_FOUND
Product inactive for ordering409PRODUCT_NOT_ORDERABLE
Inventory insufficient409INSUFFICIENT_INVENTORY
Order cannot be cancelled409ORDER_NOT_CANCELLABLE
Order cannot be paid409ORDER_NOT_PAYABLE
Unexpected server failure500INTERNAL_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, code describes 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 optional errors.
  • type is a stable LiveKlass problem URI such as https://api.liveklass.io/problems/order-not-found.
  • code is the stable machine-readable application identifier such as ORDER_NOT_FOUND.
  • Human-readable title and detail should not be parsed by clients for application logic.
  • status must match the actual HTTP response status.
  • instance should identify the API request path that produced the problem.
  • Field-level errors should contain field, code, and message.
  • Basic field error codes include REQUIRED, INVALID_VALUE, and INVALID_FORMAT.
  • Validation failures use VALIDATION_ERROR.
  • Malformed request bodies can use INVALID_REQUEST.
  • Missing Products and Orders use specific 404 application codes.
  • Business-state conflicts such as insufficient Inventory or invalid Order transitions use 409 with 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_ERROR problem without exposing internal exception details.
  • Raw stack traces, SQL errors, Java class names, and provider details must not leak through API responses.
  • Spring @RestControllerAdvice and @ExceptionHandler provide a natural centralized error translation boundary.
  • Controllers should not repeat try/catch mapping 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.