Implementing Business Workflows
Business Errors vs System Errors
আপনি একটি 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
| Situation | Application Code | HTTP |
|---|---|---|
| Malformed/basic invalid request | VALIDATION_ERROR / INVALID_REQUEST | 400 |
| Authentication missing | AUTHENTICATION_REQUIRED | 401 |
| Authenticated but forbidden admin action | ACCESS_DENIED | 403 |
| Product missing | PRODUCT_NOT_FOUND | 404 |
| Order missing/not visible | ORDER_NOT_FOUND | 404 |
| Product inactive | PRODUCT_NOT_ORDERABLE | 409 |
| Inventory insufficient | INSUFFICIENT_INVENTORY | 409 |
| Order cannot cancel | ORDER_NOT_CANCELLABLE | 409 |
| Order cannot pay | ORDER_NOT_PAYABLE | 409 |
| Unexpected backend failure | INTERNAL_ERROR | 500 |
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 updatedcan 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_FOUNDfor both missing and non-owned Orders. - Expected business failures happen while the system itself is healthy.
PRODUCT_NOT_FOUNDandORDER_NOT_FOUNDare resource failures.PRODUCT_NOT_ORDERABLE,INSUFFICIENT_INVENTORY,ORDER_NOT_CANCELLABLE, andORDER_NOT_PAYABLEare current-state conflicts.- Stable error codes are more important to clients than exact message wording.
- Repository
0 rows updatedcan 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
IllegalArgumentExceptionshould not automatically map to400. - 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.
@RestControllerAdvicecentralizes 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