Implementing Business Workflows

Business Errors vs System Errors

ReadingPreview

আপনি একটি free preview lesson দেখছেন।

এখন পর্যন্ত আমরা Order workflow implement করতে গিয়ে অনেক ধরনের failure দেখেছি:

Product missing

Product inactive

Inventory insufficient

Order not cancellable

Order not payable

database unavailable

constraint violation

arithmetic overflow

unexpected missing Inventory

সব failure একই ধরনের নয়।

একটি mature backend-এর জন্য শুধু:

something failed

জানা যথেষ্ট নয়।

আমাদের জানতে হবে:

Why did it fail?

Was the request invalid?

Was the current business state incompatible?

Was access denied?

Did infrastructure fail?

Did an application invariant break?

কারণ এই distinction affect করে:

HTTP status

public error code

logging

monitoring

retry behaviour

incident investigation

এই lesson-এর goal:

Expected application failures এবং unexpected system failures-এর মধ্যে একটি clear error model তৈরি করা, যাতে clients stable API semantics পায় এবং production problems কখনো misleading business errors-এর আড়ালে না যায়।


Error Categories

আমাদের application-এ useful high-level categories:

1. Transport / Validation Errors

2. Authentication / Authorization Errors

3. Resource / Business State Errors

4. Infrastructure / System Errors

5. Programming / Invariant Failures

সবগুলো শেষ পর্যন্ত HTTP response হতে পারে।

কিন্তু তাদের meaning আলাদা।


1. Transport and Validation Errors

এই category-এর failure request-এর shape বা basic input-এর কারণে হয়।

Examples:

malformed JSON

invalid UUID

missing required field

quantity <= 0

priceCents < 0

page < 0

size > 100

Client request application workflow শুরু করার আগেই invalid।

Example:

{
  "items": [
    {
      "productId": null,
      "quantity": 0
    }
  ]
}

এখানে Product Repository query করার প্রয়োজন নেই।

Transport boundary already জানে request invalid।


Validation Error Response

Canonical error:

{
  "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."
    }
  ]
}

HTTP:

400 Bad Request

Appropriate because request representation itself violates the contract।


Transport Validation Is Not Business Validation

Consider:

{
  "productId": "valid-uuid",
  "quantity": 5
}

Structurally:

valid

But Product might have only:

2 units

available।

That is not:

VALIDATION_ERROR

because quantity 5 is a valid positive quantity।

The failure is caused by current business state:

INSUFFICIENT_INVENTORY

This distinction matters.


2. Authentication and Authorization Errors

Security failures answer questions such as:

Who are you?

Are you allowed to perform this operation?

Does this resource belong to you?

Examples:

no authenticated user

invalid/expired authentication

Customer calling admin Inventory endpoint

Customer attempting to cancel another Customer's Order

Module 8 will implement the actual security mechanics।

But our error model should already distinguish them।


Authentication Failure

If endpoint requires authentication but no valid authenticated identity exists:

AUTHENTICATION_REQUIRED

typically maps to:

401 Unauthorized

Despite the historical HTTP name, 401 represents authentication being required or unsuccessful।

The Domain should never throw this error।

It belongs to the security/HTTP boundary।


Authorization Failure

Authenticated user exists, but lacks permission:

Customer
→ tries to set Inventory

Expected:

ACCESS_DENIED

typically:

403 Forbidden

Again, Product/Inventory domain objects do not inspect roles।


Ownership Can Be Hidden as Not Found

Customer-owned Order endpoints can use scoped queries:

WHERE order_id = ?
AND customer_id = ?

If nothing matches:

ORDER_NOT_FOUND

can represent both:

Order does not exist

and:

Order belongs to another Customer

This avoids exposing another Customer's Order existence।

So authorization is not always publicly represented as:

403

The application's resource-access policy determines the public semantics।


3. Resource and Business State Errors

These are expected application outcomes।

The system itself is healthy।

The requested operation simply cannot succeed because current business state does not permit it।

Examples:

PRODUCT_NOT_FOUND

ORDER_NOT_FOUND

PRODUCT_NOT_ORDERABLE

INSUFFICIENT_INVENTORY

ORDER_NOT_CANCELLABLE

ORDER_NOT_PAYABLE

These are normal application conditions।

They should be predictable and machine-readable।


Product Not Found

Example:

Create Order
→ requested ProductId does not exist

Application exception:

public final class
ProductNotFoundException
        extends RuntimeException {

    private final ProductId productId;

    public ProductNotFoundException(
            ProductId productId
    ) {
        this.productId =
                productId;
    }

    public ProductId productId() {
        return productId;
    }
}

Notice we do not need:

super(
    "Product 123 does not exist"
);

as the public API contract।

The exception represents application meaning।

HTTP mapping creates client-facing Problem Details।


Product Not Found Response

Conceptually:

{
  "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/orders",
  "code": "PRODUCT_NOT_FOUND"
}

We may include resource-specific context internally।

But public responses should avoid exposing unnecessary implementation details।


Product Not Orderable

Product exists but:

active = false

This is different from Product missing।

Application can raise:

PRODUCT_NOT_ORDERABLE

HTTP:

409 Conflict

because request conflicts with current Product state।


Insufficient Inventory

Conditional atomic update returns:

0 rows

within Create Order।

Application maps that persistence outcome to:

INSUFFICIENT_INVENTORY

This is an expected business result।

System is healthy।

No incident necessarily occurred।


0 Rows Updated Is Not the Public Error

Persistence knows:

updatedRows == 0

Application knows:

requested quantity could not be consumed

Public API knows:

INSUFFICIENT_INVENTORY

This layering matters।

Never return:

{
  "error": "update count was zero"
}

That leaks persistence mechanics।


Order Not Cancellable

Order exists but current status is:

PAID

or:

CANCELLED

Domain raises:

ORDER_NOT_CANCELLABLE

This is expected business behaviour।

HTTP:

409 Conflict

No infrastructure failure occurred।


Order Not Payable

Likewise:

CANCELLED
→ pay

is invalid।

Expected:

ORDER_NOT_PAYABLE

Again:

409

is appropriate because current resource state conflicts with requested action।


Business Failure Does Not Mean "Everything Is 409"

Different business/resource failures may use different statuses:

resource missing
→ 404
current state conflict
→ 409
authentication required
→ 401
known authorization failure
→ 403

The application code should not simply classify:

all expected exceptions
→ 400

That loses useful semantics।


Stable Error Codes Matter More Than Messages

Clients should depend on:

PRODUCT_NOT_FOUND

INSUFFICIENT_INVENTORY

ORDER_NOT_CANCELLABLE

not exact human messages।

Messages may change for:

wording

localization

clarity

Stable machine-readable code is the API contract।


4. Infrastructure and System Errors

Now consider:

PostgreSQL connection unavailable

This is not caused by a valid business rule।

The request may have been perfectly valid।

The backend simply could not safely complete it।

Examples:

database unavailable

connection timeout

unexpected SQL failure

disk/storage failure

transaction deadlock

unexpected integration failure

These belong to:

system failure

category।


System Errors Should Generally Become 5xx

For unexpected backend failure:

INTERNAL_ERROR

may map to:

500 Internal Server Error

Example:

{
  "type": "https://api.liveklass.io/problems/internal-error",
  "title": "Internal server error",
  "status": 500,
  "detail": "The request could not be completed.",
  "instance": "/api/v1/orders",
  "code": "INTERNAL_ERROR"
}

Do not expose:

SQLState

table name

stack trace

JDBC URL

database host

Hibernate class names

to the client।


Database Down Is Not Insufficient Inventory

This distinction deserves repetition।

Bad:

try {
    return repository
            .consumeIfAvailable(...);
} catch (Exception exception) {
    return false;
}

Now:

PostgreSQL unavailable

becomes:

false

which Create Order interprets as:

INSUFFICIENT_INVENTORY

The Customer sees:

Out of stock.

But actual problem:

database outage

This hides a production incident।

Never collapse technical failure into a business condition।


Expected Repository Result vs Repository Exception

For consumeIfAvailable():

updatedRows = 1
→ success
updatedRows = 0
→ expected business state
database throws exception
→ system failure

This is a clean contract।


Don't Return Optional.empty() for Database Failure

Another dangerous pattern:

try {
    return jpaRepository
            .findById(id);
} catch (Exception exception) {
    return Optional.empty();
}

Now:

database broken

becomes:

resource missing

Wrong।

Optional.empty() means:

Query succeeded and no matching resource exists.

It should never mean:

Query could not be performed.


5. Programming and Invariant Failures

Some failures indicate our own code reached a state that should be impossible।

Examples:

Inventory missing during cancellation
after it had previously been consumed

negative unitPriceCents reaches persistence

duplicate OrderItem reaches database
despite Domain and UseCase protection

invalid persisted OrderStatus

arithmetic overflow

These are not normal Customer-facing business conditions।

They indicate:

bug

corrupt data

broken assumption

unexpected system state

Missing Inventory During Cancellation

Recall:

Order creation succeeded

only after:

Inventory row was updated

Later cancellation attempts to restore that same Product.

If:

UPDATE inventory
...
→ 0 rows

during restoration, that should be impossible under normal application flow।

Do not return:

INVENTORY_NOT_FOUND

as though Customer merely requested a missing admin resource।

This is:

integrity/system failure

Cancellation must rollback।

Public response:

INTERNAL_ERROR

is safer।


Domain IllegalArgumentException

Our Domain may use:

IllegalArgumentException

for invariant violations such as:

negative priceCents

quantity <= 0

during direct object construction।

But not every IllegalArgumentException should automatically become:

400

at HTTP level।

Why?

Because if transport validation already happened and application code later constructs:

new OrderItem(
    productId,
    -5,
    price
);

that is likely an internal bug।

The source of the exception matters।


Do Not Map All IllegalArgumentException to 400

Bad global handler:

@ExceptionHandler(
        IllegalArgumentException.class
)
@ResponseStatus(
        HttpStatus.BAD_REQUEST
)

Now internal programming defects become client blame।

Better:

known request-validation failures
→ 400

while unexpected generic exceptions fall through to:

500

Stable application exception types make this easier।


Application Exceptions Should Be Specific

Prefer:

ProductNotFoundException

ProductNotOrderableException

InsufficientInventoryException

OrderNotCancellableException

over:

throw new RuntimeException(
        "Bad order"
);

Specific types give us:

clear meaning

clean HTTP mapping

focused tests

better observability

But Avoid an Exception Class Explosion

We don't need:

ProductPriceNegativeException

ProductNameBlankException

InventoryQuantityNegativeException

for every local invariant।

Some invariants can remain:

IllegalArgumentException

inside the Domain because they should normally be prevented before reaching that layer from an external request।

Create specific application errors where callers need meaningful operational behaviour।


Error Ownership by Layer

A useful model:

Handler / Transport

Owns:

malformed request

field validation

UUID parsing

pagination validation

Security Boundary

Owns:

authentication required

role/access policy

UseCase

Owns contextual application failures:

Product missing

Order missing

insufficient Inventory

ownership-scoped lookup

cross-entity business conditions

Domain

Owns intrinsic state rules:

Order cannot transition

quantity must be positive

priceCents cannot be negative

Repository / Infrastructure

Owns persistence mechanics:

SQL

JPA

database connectivity

atomic conditional update

It should translate database mechanics into application semantics only when that translation is unambiguous।


HTTP Exception Mapping

A centralized:

@RestControllerAdvice

is a natural place to convert known application errors into Problem Details responses।

Conceptually:

@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(
            ProductNotFoundException.class
    )
    ResponseEntity<ApiProblem>
    handleProductNotFound(
            ProductNotFoundException exception,
            HttpServletRequest request
    ) {
        ...
    }

    @ExceptionHandler(
            InsufficientInventoryException.class
    )
    ResponseEntity<ApiProblem>
    handleInsufficientInventory(
            InsufficientInventoryException exception,
            HttpServletRequest request
    ) {
        ...
    }

    @ExceptionHandler(
            OrderNotCancellableException.class
    )
    ResponseEntity<ApiProblem>
    handleOrderNotCancellable(
            OrderNotCancellableException exception,
            HttpServletRequest request
    ) {
        ...
    }
}

No Handler should manually build the same errors repeatedly।


Known Errors First, Fallback Last

Conceptual mapping:

validation exceptions
→ 400 VALIDATION_ERROR

ProductNotFoundException
→ 404 PRODUCT_NOT_FOUND

OrderNotFoundException
→ 404 ORDER_NOT_FOUND

ProductNotOrderableException
→ 409 PRODUCT_NOT_ORDERABLE

InsufficientInventoryException
→ 409 INSUFFICIENT_INVENTORY

OrderNotCancellableException
→ 409 ORDER_NOT_CANCELLABLE

OrderNotPayableException
→ 409 ORDER_NOT_PAYABLE

Then:

unexpected exception
→ 500 INTERNAL_ERROR

Never Return Raw Exception Messages by Default

Bad:

detail =
    exception.getMessage();

for every error।

An infrastructure exception message may contain:

SQL

schema details

hostnames

driver details

constraint names

Public error messages should be deliberately authored।

Internal exception details belong in logs।


Public vs Internal Detail

Client:

{
  "code": "INTERNAL_ERROR",
  "detail": "The request could not be completed."
}

Internal logs may contain:

exception class

stack trace

SQLState

correlation/request ID

relevant application context

The two audiences are different।


Error Codes Should Describe Meaning, Not Technology

Bad:

JPA_ENTITY_NOT_FOUND

POSTGRES_UPDATE_ZERO

HIBERNATE_LOCK_FAILURE

Good:

PRODUCT_NOT_FOUND

INSUFFICIENT_INVENTORY

ORDER_NOT_CANCELLABLE

Public API should survive persistence refactoring։

If we replace JPA implementation later, clients should not need to change。


Problem type

Our canonical Problem Details-style structure includes:

type

Example:

https://api.liveklass.io/problems/insufficient-inventory

This identifies the problem category।

code remains a convenient stable machine-readable application identifier:

INSUFFICIENT_INVENTORY

instance

instance identifies the request/resource path associated with the specific occurrence।

Example:

{
  "instance": "/api/v1/orders"
}

or:

{
  "instance": "/api/v1/orders/36a25516-2af2-4644-b55d-d0cabf389c9e/cancel"
}

It should not contain:

stack trace

internal method

database query

Validation Field Errors Are Optional

Our canonical structure allows:

"errors": [...]

when several fields are invalid।

Business errors such as:

ORDER_NOT_CANCELLABLE

usually don't need a field-level errors array।

Example:

{
  "type": "https://api.liveklass.io/problems/order-not-cancellable",
  "title": "Order cannot be cancelled",
  "status": 409,
  "detail": "The order cannot be cancelled in its current state.",
  "instance": "/api/v1/orders/.../cancel",
  "code": "ORDER_NOT_CANCELLABLE"
}

Do Not Overexpose Business State

Suppose Order is:

PAID

We could return:

{
  "detail": "Order is PAID and cannot be cancelled."
}

Maybe useful।

But for some resource/authorization contexts, exposing exact current state may reveal information we don't want to expose।

Error detail should be intentionally chosen।

Stable code carries the machine meaning without requiring excessive detail।


Transaction Rollback and Error Mapping Are Separate

Example:

Inventory A consumed

Inventory B insufficient

UseCase throws:

InsufficientInventoryException

Transaction layer:

ROLLBACK

HTTP layer:

409
INSUFFICIENT_INVENTORY

These are two distinct responsibilities।

Do not write transaction rollback logic inside ControllerAdvice

And do not construct HTTP Problem Details inside Domain exceptions।


Business Error Does Not Mean No Rollback

A common misconception:

Business errors are normal, therefore they shouldn't roll back.

Wrong।

Suppose:

Inventory A consumed

then Product B consumption returns insufficient।

That is a normal business failure।

But previous database mutation must still rollback।

Expectedness of the failure does not determine whether atomicity matters।


System Error Does Not Always Mean 500 Forever

Some infrastructure conditions could later get more specific public statuses।

For example:

external service temporarily unavailable

might eventually map to:

503 Service Unavailable

if we deliberately expose that distinction।

But we should not invent a large public taxonomy prematurely।

For unexpected local backend errors:

500 INTERNAL_ERROR

is a safe default।


Retry Behaviour Depends on Category

Client/application retry reasoning differs.

Validation failure

retry unchanged request?
→ pointless

Client must change input।


Business state conflict

INSUFFICIENT_INVENTORY

Immediate identical retry is usually unlikely to help unless business state changes।


Authentication failure

Client may need:

valid authentication

before retrying।


System failure

A retry may sometimes be useful if failure is transient।

But automatic retry policy requires more information than merely:

500

So we do not add generic retry loops in Module 7।


Logging Strategy by Error Type

Detailed logging comes in Module 11, but error taxonomy already tells us useful principles।

Expected business failure:

INSUFFICIENT_INVENTORY

should not automatically produce:

ERROR stack trace

for every occurrence।

It may be:

normal business event

Unexpected System Failure

database connection failure

is operationally important।

It should be surfaced to:

logs

metrics

alerts

appropriately later।

If we disguise it as business failure, observability becomes useless।


Programming Invariant Failure

Something such as:

Inventory row missing during cancellation

deserves strong operational visibility because our normal workflow says it should not happen।

This is exactly the kind of failure that may indicate:

data corruption

manual DB change

software bug

Don't Log Secrets or Sensitive Data

Even for system errors, avoid casually logging:

authorization token

password

provider credentials

full sensitive request bodies

Good error handling does not mean dumping everything into logs।

Security/observability details come later, but the principle applies now।


Error Translation Should Be One-Way

A persistence exception can become:

application/system failure

and then:

HTTP Problem

But application code should not depend on:

HTTP status

to make business decisions।

Avoid:

if (
    exception.getHttpStatus() == 409
) {
    ...
}

inside UseCase।

HTTP is an outer representation।


Do Not Throw ResponseStatusException From Domain

Bad:

throw new ResponseStatusException(
        HttpStatus.CONFLICT,
        "Order cannot be cancelled"
);

inside:

Order.cancel()

Now Domain depends on Spring Web/HTTP semantics।

Correct:

throw new OrderNotCancellableException(
        id,
        status
);

Then outer boundary decides:

409

Do Not Return ResponseEntity From UseCase

Bad:

public ResponseEntity<?> execute(...) {
    ...
}

UseCase returns:

application result

or throws:

application/domain error

Handler maps that to HTTP।

Architecture remains:

Handler
→ UseCase
→ Domain / Repository

not:

HTTP types everywhere

Repository Should Not Throw HTTP Exceptions

Likewise:

JpaProductRepository

should not throw:

404 exception

It returns:

Optional.empty()

when query successfully finds no Product।

UseCase decides whether absence means:

PRODUCT_NOT_FOUND

for that operation।


Same Absence Can Mean Different Things

Example:

Inventory row missing

In admin GET:

GET /inventory/{productId}

meaning:

INVENTORY_NOT_FOUND

Expected resource absence।

In Create Order:

Inventory missing

we chose:

INSUFFICIENT_INVENTORY

because Customer cannot fulfil Order।

In cancellation:

Inventory missing

means:

system integrity failure

Same persistence fact।

Different application context।

This is exactly why Repository should not decide all business error semantics।


Error Meaning Belongs to the Operation

A Repository can tell us:

row absent

UseCase determines:

what does absence mean here?

That is application logic।


Exception Naming

Good names describe application meaning:

ProductNotFoundException

ProductNotOrderableException

InsufficientInventoryException

OrderNotFoundException

OrderNotCancellableException

OrderNotPayableException

Avoid vague:

BusinessException

BadRequestException

ApplicationErrorException

that require reading messages to understand what happened।


Do We Need a Base BusinessException?

Not necessarily।

We could create one, but our error hierarchy is currently small։

A base class becomes useful only if it simplifies real shared behaviour, such as:

stable application code

structured metadata

without turning every failure into the same generic bucket।

Do not add inheritance ceremony merely because exceptions exist।


We Can Introduce Stable Codes Separately

For example:

public enum ErrorCode {

    PRODUCT_NOT_FOUND,

    ORDER_NOT_FOUND,

    PRODUCT_NOT_ORDERABLE,

    INSUFFICIENT_INVENTORY,

    ORDER_NOT_CANCELLABLE,

    ORDER_NOT_PAYABLE
}

But even this should earn its place।

The HTTP adapter already needs stable codes, so a small centralized representation can be justified there।

We do not need Domain to depend on an API ErrorCode enum।


Error Code Is Part of API Contract

Once clients use:

INSUFFICIENT_INVENTORY

changing it casually to:

OUT_OF_STOCK

can break integrations even if HTTP remains:

409

Treat stable public error codes like endpoint contract elements।


Don't Use Exception Message as Error Code

Bad:

code =
    exception.getMessage();

Messages are for humans and may change।

Use explicit mapping:

InsufficientInventoryException
→ INSUFFICIENT_INVENTORY

Generic Internal Error Handler

At the outermost API boundary, we still need a safe fallback:

@ExceptionHandler(
        Exception.class
)
ResponseEntity<ApiProblem>
handleUnexpected(
        Exception exception,
        HttpServletRequest request
) {
    // log internally

    return ...
}

Public result:

500
INTERNAL_ERROR

This is not where we hide known application failures।

Specific handlers run for those first।


Don't Return Stack Traces

Never produce:

{
  "stackTrace": "...",
  "exception": "org.hibernate..."
}

in normal production API responses।

That creates:

security risk

unstable contracts

implementation leakage

and is rarely useful to clients।


Correlation IDs Later Help

When production observability is implemented, a response may eventually correlate with internal logs through a request/correlation identifier।

Then client can report:

request XYZ failed

while engineers inspect detailed internal diagnostics।

We do not need to expose stack traces for debugging।


Error Matrix for Current Application

SituationApplication CodeHTTP
Malformed/basic invalid requestVALIDATION_ERROR / INVALID_REQUEST400
Authentication missingAUTHENTICATION_REQUIRED401
Authenticated but forbidden admin actionACCESS_DENIED403
Product missingPRODUCT_NOT_FOUND404
Order missing/not visibleORDER_NOT_FOUND404
Product inactivePRODUCT_NOT_ORDERABLE409
Inventory insufficientINSUFFICIENT_INVENTORY409
Order cannot cancelORDER_NOT_CANCELLABLE409
Order cannot payORDER_NOT_PAYABLE409
Unexpected backend failureINTERNAL_ERROR500

This is enough for the current system।

We do not need dozens of status codes।


Example: Create Order Failure Flow

Request is structurally valid।

Product A valid।

Product B valid।

Inventory A succeeds।

Inventory B unavailable।

Flow:

InventoryRepository
→ consumeIfAvailable() returns false

UseCase:

throw InsufficientInventoryException

Transaction:

ROLLBACK

HTTP advice:

409
INSUFFICIENT_INVENTORY

Clean separation।


Example: Database Failure Flow

Request structurally valid।

Product valid।

Database connection fails during Inventory update।

Repository:

throws infrastructure exception

UseCase does not translate it to business stock failure।

Transaction:

fails / rolls back

HTTP fallback:

500
INTERNAL_ERROR

Internal monitoring:

sees actual database failure

This is what we want।


Example: Cancellation Integrity Failure

Order is valid and UNPAID।

First Inventory restore succeeds।

Second Product Inventory row unexpectedly missing।

Repository:

restore()
→ false

But in cancellation context that is not an expected business result।

UseCase throws an unexpected/integrity failure।

Transaction:

ROLLBACK

Client:

500
INTERNAL_ERROR

Operators:

investigate broken invariant

Why Not Create INVENTORY_RESTORE_FAILED Public Code?

We could।

But does a Customer know what to do with that?

Probably not।

It exposes an internal consistency mechanism।

Until there is a real client-facing recovery requirement, generic:

INTERNAL_ERROR

is a better public contract।

Internally we can still log a much more precise reason।


Business Errors Must Be Intentional

An error should only become:

expected business error

if we can clearly answer:

What business condition does this represent?

Can this happen during healthy operation?

Does the caller need to react differently?

If not, default toward treating it as an unexpected system problem rather than inventing a friendly business code।


Avoid Catching Everything for "Clean APIs"

A common anti-pattern:

try {
    ...
} catch (Exception exception) {
    throw new InvalidRequestException();
}

It may make the API look simple, but destroys operational truth।

You lose distinction between:

bad request

database outage

code bug

Clean APIs require deliberate mapping, not aggressive error hiding।


API Simplicity and Internal Precision Can Coexist

Client may see:

INTERNAL_ERROR

while internal logs know:

Inventory restoration expected 1 row
but updated 0
for ProductId ...

Public API does not need every internal error category।

Internal engineering diagnostics do।

Different levels of precision are appropriate for different boundaries।


Testing Error Semantics

Error handling deserves tests too।


Validation Test

Request:

{
  "items": []
}

Expected:

400
VALIDATION_ERROR

No UseCase execution should be required।


Missing Product Test

Create Order with unknown ProductId।

Expected:

404
PRODUCT_NOT_FOUND

Not:

500

Inactive Product Test

Expected:

409
PRODUCT_NOT_ORDERABLE

Insufficient Inventory Test

Expected:

409
INSUFFICIENT_INVENTORY

and transaction rollback।


Cancel Paid Order Test

Expected:

409
ORDER_NOT_CANCELLABLE

No Inventory restoration।


Database Failure Test

Force Repository technical failure।

Expected:

500
INTERNAL_ERROR

Not:

404

Not:

409 INSUFFICIENT_INVENTORY

This test protects error truthfulness।


Error Response Shape Test

For known business errors verify canonical fields:

type

title

status

detail

instance

code

For validation errors also verify:

errors[]

when relevant।

This protects API consistency।


Do Not Over-Test Human Wording

Tests should not become brittle because:

"The order cannot be cancelled."

changes to:

"This order is not cancellable."

Focus assertions on:

status

code

important structure

unless exact message is part of a deliberate contract।


Error Handling Checklist

When adding a new failure, ask:

Is this malformed input?

Is authentication missing?

Is access forbidden?

Is the resource absent?

Is this expected current business state?

Did infrastructure fail?

Did an invariant that should be impossible break?

What should the transaction do?

What stable code should the client receive?

Should the client reasonably retry?

What internal information should be logged?

Could this mapping hide a production incident?

Our Error Architecture

Conceptually:

HTTP / Security
    ↓
Handler
    ↓
UseCase
    ↓
Domain / Repository

Failures travel outward:

Domain/application exception
        ↓
transaction rollback if needed
        ↓
ControllerAdvice
        ↓
Problem response

Infrastructure failures:

PostgreSQL/JPA exception
        ↓
rollback
        ↓
safe 5xx mapping
        ↓
internal diagnostics

No layer needs to know everything।


What We Deliberately Do Not Add

We do not add:

one generic BusinessException
for every failure
one HTTP exception type
inside every Domain object
database exception messages
in public API
stack traces
in responses
a public error code
for every internal bug
automatic retry
for every 500
a giant error hierarchy
without real behaviour

Our model stays small and intentional।


Engineering Principle

The core principle:

A business error means the system worked correctly and discovered that the requested operation is not allowed by current application state. A system error means the backend could not safely determine or complete the operation. Never confuse the two.

Another:

Persistence results may contribute to business decisions, but persistence failures must remain failures. 0 rows updated can mean insufficient Inventory; a database exception cannot.

And:

Public errors should expose stable application meaning, not implementation details. Internal diagnostics can be precise while the external API remains safe and durable.


Summary

In this lesson, we established that:

  • Not all failures have the same meaning.
  • Transport/basic validation failures generally map to 400.
  • Authentication and authorization are separate concerns.
  • Missing authenticated identity maps to AUTHENTICATION_REQUIRED.
  • Forbidden actions may map to ACCESS_DENIED.
  • Customer-owned resources can deliberately use ORDER_NOT_FOUND for both missing and non-owned Orders.
  • Expected business failures happen while the system itself is healthy.
  • PRODUCT_NOT_FOUND and ORDER_NOT_FOUND are resource failures.
  • PRODUCT_NOT_ORDERABLE, INSUFFICIENT_INVENTORY, ORDER_NOT_CANCELLABLE, and ORDER_NOT_PAYABLE are current-state conflicts.
  • Stable error codes are more important to clients than exact message wording.
  • Repository 0 rows updated can map to a business result when the operation defines that meaning.
  • A Repository exception must not be converted into an expected business condition.
  • Database outages are system failures, not INSUFFICIENT_INVENTORY.
  • Optional.empty() means successful query with no resource, not failed query.
  • Programming/invariant failures should generally remain system failures.
  • Missing Inventory during cancellation is an integrity failure, not normal admin-style INVENTORY_NOT_FOUND.
  • Generic IllegalArgumentException should not automatically map to 400.
  • Specific application exceptions improve error mapping and tests.
  • We should avoid unnecessary exception-class explosion.
  • Handler/transport owns request-shape validation.
  • Security owns authentication and access.
  • UseCases own contextual application failure semantics.
  • Domain owns intrinsic invariants and lifecycle rules.
  • Repository owns persistence mechanics, not universal business error meaning.
  • The same persistence fact can mean different application errors in different UseCases.
  • @RestControllerAdvice centralizes HTTP error translation.
  • Known application errors map explicitly; unexpected errors fall back safely to INTERNAL_ERROR.
  • Public responses should not expose raw exception messages, SQL, stack traces, or infrastructure details.
  • Transaction rollback and HTTP error translation are separate responsibilities.
  • Expected business failures may still require rollback.
  • Error taxonomy helps future logging, metrics, alerting, and retry decisions.
  • Tests should verify both business mappings and that infrastructure failures are not mislabeled.
  • Our current API needs only a small, stable error vocabulary rather than a huge hierarchy.

This completes Module 7 — Implementing Business Workflows.

The next module is:

Module 8 — Authentication and Authorization

Next lesson:

Authentication vs Authorization

We will establish the security model before adding Spring Security:

Authentication
→ Who is making the request?

Authorization
→ What is that authenticated identity allowed to do?

and connect that distinction to the rules we already have:

Customer
→ create Order
→ view own Orders
→ cancel own eligible Order
→ pay own eligible Order

Admin
→ create/update/deactivate Products
→ manage Inventory
→ view all Orders