Implementing Business Workflows
Rollback and Failure Scenarios
আপনি একটি free preview lesson দেখছেন।
একটি backend workflow production-ready হয় তখনই, যখন শুধু success path নয়, failure path-ও predictable হয়।
আমাদের CreateOrderUseCase এখন roughly এই flow follow করে:
validate request
↓
validate Products
↓
capture priceCents
↓
consume Inventory
↓
create Order
↓
persist Order + OrderItems
↓
commit
এবং পুরো local workflow:
@Transactional
boundary-এর মধ্যে।
এখন প্রশ্ন:
এই flow-এর যেকোনো জায়গায় failure হলে database-এর final state কী হওয়া উচিত?
একটি reliable backend-এর answer হওয়া উচিত:
predictable
consistent
testable
এই lesson-এ আমরা Create Order-এর failure scenarios systematically analyse করব।
The Golden Rule
Create Order-এর জন্য fundamental rule:
Either the complete Order exists and all required Inventory was consumed, or none of those changes exist.
Successful state:
Inventory correctly consumed
Order persisted
OrderItems persisted
Failure state:
Inventory unchanged
no partial Order
no partial OrderItems
There should be no middle state such as:
Inventory consumed
but no Order
or:
Order exists
but only some OrderItems
Failure Categories
Create Order failures broadly fall into two groups:
Business / expected failures
and:
System / unexpected failures
Examples of business failures:
Product missing
Product inactive
duplicate Product
insufficient Inventory
Examples of system failures:
database unavailable
constraint violated because of a bug
SQL failure
transaction deadlock
unexpected programming error
These categories affect:
error semantics
but both may require:
transaction rollback
when persistent mutation has started.
Scenario 1 — Duplicate Product Before Database Mutation
Request:
{
"items": [
{
"productId": "8fa1858e-1651-4eaa-9869-3e89de902c4c",
"quantity": 2
},
{
"productId": "8fa1858e-1651-4eaa-9869-3e89de902c4c",
"quantity": 1
}
]
}
The UseCase detects:
same ProductId appears twice
before Product or Inventory mutation.
Flow:
ensureNoDuplicateProducts()
↓
failure
No Inventory SQL has executed.
No Order exists.
Final database state:
unchanged
Does This Still Need a Transaction?
The method is already transactional.
But this specific failure happens before persistent mutation.
Rollback has almost nothing to undo.
That's fine.
We do not create separate transactional methods based on:
"this error probably happens early"
The business operation retains one consistent boundary.
Scenario 2 — First Product Does Not Exist
Request:
Product X × 2
But:
ProductRepository.findById(X)
→ empty
UseCase produces:
PRODUCT_NOT_FOUND
Flow:
load Product
↓
not found
↓
throw
No Inventory has been consumed yet.
Final state:
Inventory unchanged
no Order
Why Validate Product State Before Inventory?
Suppose request contains:
Product A
Product B
Product C
We deliberately validate Products and capture prices before starting Inventory consumption.
This allows failures such as:
missing Product
inactive Product
to happen before database mutations begin.
That reduces:
unnecessary locking
unnecessary updates
rollback work
Even though transaction rollback would protect correctness, good sequencing still matters.
Scenario 3 — Product Is Inactive
Product exists:
active = false
Create Order requires:
active = true
So:
PRODUCT_NOT_ORDERABLE
is raised.
Again:
no Inventory mutation
should have occurred because Product validation comes first.
Final:
Product unchanged
Inventory unchanged
no Order
Scenario 4 — Product Becomes Invalid Between Browse and Order
Customer browses Product:
active = true
Then admin deactivates it.
Later Customer creates Order.
The Create Order operation loads current Product state:
active = false
Result:
PRODUCT_NOT_ORDERABLE
This is not a system failure.
It is normal concurrent business state change.
The earlier browse result was not a guarantee.
Scenario 5 — First Inventory Consumption Fails
Products are valid.
Now first requested Product has:
availableQuantity = 1
Request needs:
2
Conditional update:
UPDATE inventory
SET available_quantity =
available_quantity - 2
WHERE product_id = ?
AND available_quantity >= 2;
affects:
0 rows
Repository returns:
false
UseCase throws:
INSUFFICIENT_INVENTORY
Since no previous Inventory line has been consumed:
rollback has no Inventory changes to undo
Final state:
Inventory unchanged
no Order
Scenario 6 — Second Inventory Consumption Fails
This is much more important.
Initial:
Product A Inventory = 10
Product B Inventory = 1
Request:
A × 2
B × 5
Transaction begins.
A:
10 → 8
Then B conditional update:
0 rows
UseCase throws:
InsufficientInventoryException
Without a transaction:
A = 8
could remain.
With our transaction:
ROLLBACK
Final:
A = 10
B = 1
no Order
This is one of the most important rollback guarantees in the system.
Scenario 7 — Third Inventory Consumption Fails
Same principle scales to any number of lines.
Initial:
A = 10
B = 20
C = 1
Request:
A × 2
B × 3
C × 5
During transaction:
A
10 → 8
B
20 → 17
C
fails
Required final state:
A = 10
B = 20
C = 1
No partial Inventory consumption is allowed.
Scenario 8 — Order Persistence Fails
Suppose all Inventory operations succeed.
Initial:
A = 10
B = 8
After conditional updates inside transaction:
A = 8
B = 5
Then:
orderRepository.save(
order
);
fails.
Potential causes:
database connection problem
unexpected SQL error
mapping issue
constraint failure
The transaction cannot commit.
Required final state:
A = 10
B = 8
no Order
no OrderItems
Do Not Catch and Continue
Bad:
try {
orderRepository.save(
order
);
} catch (Exception exception) {
log.error(
"Failed to save order",
exception
);
}
return order;
This is catastrophic.
The Handler could return:
201 Created
for an Order that was never safely persisted.
Required persistence failure means:
the application operation failed
The failure should propagate.
Scenario 9 — Order Persisted, OrderItem Insert Fails
JPA may persist:
Order
and then:
OrderItems
during flush.
Suppose:
Order insert succeeds
then an OrderItem persistence operation violates:
PRIMARY KEY
FOREIGN KEY
CHECK
constraint.
Inside one transaction:
Order INSERT success
does not mean Order is committed.
The later constraint failure causes transaction failure.
Final committed state:
no Order
no OrderItems
Inventory unchanged
This Is Why Commit Is More Important Than Individual SQL Success
During a transaction:
SQL statement succeeded
means only:
This statement succeeded inside the current transaction.
It does not mean:
The business operation is now permanently committed.
Only:
COMMIT
gives us that finality.
Scenario 10 — Duplicate OrderItem Reaches Database
Our application prevents duplicate Product lines twice:
UseCase validation
and:
Order domain invariant
Database also protects:
PRIMARY KEY (
order_id,
product_id
)
Suppose a bug somehow bypasses both application protections.
Persistence tries:
same order_id
same product_id
twice
PostgreSQL rejects it.
This is a:
system/programming integrity failure
not normal Customer duplicate-input handling.
The transaction should roll back.
Why Database Constraint Failure Is Not Always a Business Error
There is a subtle difference.
If UseCase deliberately detects duplicate Product IDs:
expected application failure
If database unexpectedly detects duplicate OrderItem rows despite our application/domain protections:
likely implementation defect
Same underlying invariant.
Different failure location and meaning.
Do not automatically map every unique/PK constraint violation to:
INVALID_REQUEST
without understanding why it happened.
Scenario 11 — Database CHECK Constraint Fails
Suppose application bug attempts to persist:
unit_price_cents = -1
Database rejects:
CHECK (
unit_price_cents >= 0
)
This should normally never happen because:
Product
OrderItem
already prevent negative prices.
Therefore database failure indicates:
internal inconsistency / bug
Transaction rolls back.
Public error should generally remain:
INTERNAL_ERROR
rather than exposing SQL constraint details.
Scenario 12 — PostgreSQL Becomes Unavailable
Suppose Product validation completed.
Then database connection drops during Inventory consumption.
This is not:
INSUFFICIENT_INVENTORY
because we do not know that stock is insufficient.
It is:
system/infrastructure failure
The request should fail.
We do not:
create Order anyway
assume Inventory was consumed
retry blindly inside arbitrary loops
Preserve Error Meaning
Bad:
try {
boolean consumed =
inventoryRepository
.consumeIfAvailable(...);
if (!consumed) {
throw new InsufficientInventoryException();
}
} catch (Exception exception) {
throw new InsufficientInventoryException();
}
Now:
DB unavailable
becomes:
INSUFFICIENT_INVENTORY
which is false.
Correct:
updated rows = 0
→ business stock failure
while:
SQL/database exception
→ system failure
Scenario 13 — Arithmetic Overflow
Our Order monetary model uses:
long cents
and:
Math.multiplyExact(...)
Math.addExact(...)
Suppose extreme input causes:
ArithmeticException
while calculating an item total or Order total.
This is not a normal:
INSUFFICIENT_INVENTORY
or:
PRODUCT_NOT_ORDERABLE
condition.
It means the monetary result cannot be represented safely.
The operation should fail rather than return a corrupted amount.
Calculate Before Persistent Mutation Where Possible
This suggests useful sequencing.
If OrderItems and their totals can be fully constructed and validated before Inventory consumption:
do that first
Then arithmetic/domain failures occur before persistent mutation begins.
Good sequence:
validate request
load Products
construct OrderItems
validate/calculate monetary state
then consume Inventory
This reduces rollback work.
Rollback Is a Safety Net, Not an Excuse for Bad Ordering
Because transaction can roll back, we could theoretically:
mutate first
validate later
But that is poor workflow design.
Prefer:
cheap/read-only validation first
persistent mutation later
where practical.
Transaction protects the remaining unavoidable failure window.
Scenario 14 — Concurrent Customer Wins the Last Stock
Initial:
Inventory = 1
Two Customers:
A wants 1
B wants 1
Conditional updates contend.
One returns:
true
The other:
false
Winner may proceed to persist Order.
Loser gets:
INSUFFICIENT_INVENTORY
This is a normal business concurrency outcome.
Not:
500
Scenario 15 — Winner's Order Later Fails
More interesting:
Inventory = 1
Transaction A successfully conditionally consumes:
1 → 0
Transaction B is waiting/contending.
Then A fails while persisting its Order.
A rolls back.
That means A's Inventory mutation is rolled back too.
The row becomes available according to transaction semantics, allowing a competing transaction to proceed based on the committed state it can observe.
The important guarantee is:
An Order that does not commit must not permanently consume stock.
The Transaction Decides Stock Ownership
It is not enough for:
conditional UPDATE
to succeed temporarily inside the transaction.
Stock is truly consumed for business purposes only if:
the entire Order transaction commits
This distinction matters under concurrency.
Scenario 16 — Deadlock
We reduce obvious Inventory deadlock risk by consuming Product rows in deterministic ProductId order.
Still, database deadlocks can occur in a complex system.
If PostgreSQL detects one, a transaction may fail.
That failure is:
technical concurrency failure
not:
INSUFFICIENT_INVENTORY
The failed transaction is not allowed to leave partial committed Order state.
Should We Automatically Retry Deadlocks?
Not yet.
Retry policy requires decisions about:
which errors are transient
maximum retries
backoff
whether operation is safe to retry
request idempotency
Create Order itself can create a new Order identity and consume stock.
Blind retry at arbitrary layers could accidentally complicate operation semantics.
We will not introduce a retry framework without a clear requirement.
Scenario 17 — Application Crashes Before Commit
Imagine application process terminates after:
Inventory SQL executed
but before transaction commit.
A database transaction that never commits must not be treated as a completed business operation.
Our application should rely on PostgreSQL transaction semantics rather than implementing:
"maybe this SQL happened"
recovery based on in-memory state.
Persisted committed state remains the source of truth.
Scenario 18 — Application Crashes After Commit but Before HTTP Response
This is a different and very important problem.
Flow:
Order transaction commits
↓
application crashes
↓
Customer never receives 201 response
Database now contains:
valid committed Order
consumed Inventory
Customer may believe:
request failed
and retry.
This is where:
request-level idempotency
can become important.
We Have Not Solved Create Order Idempotency Yet
Our current Create Order operation:
POST /orders
generates:
new OrderId
per execution.
If the same logical request is repeated after an uncertain client/network outcome, duplicate Orders could theoretically be created.
This is a real production concern.
But our approved curriculum currently focuses explicit idempotency in the external payment module.
We should recognize the risk without silently inventing a Create Order idempotency-key contract now.
It can later become:
production change request
or incident-driven improvement
if the course uses duplicate Orders as the incident scenario.
Transaction Atomicity Does Not Solve Network Ambiguity
Important distinction:
transaction
answers:
Did the database operation commit atomically?
It does not answer:
Did the client receive the response?
You can have:
database success
client sees timeout
Both statements can be true.
This is why transaction safety and request idempotency are separate concepts.
Scenario 19 — Customer Disconnects Mid-Request
A client closing the connection does not automatically define business rollback semantics.
If the server-side transaction has already reached commit successfully, the Order may exist even if the Customer is no longer listening for the response.
Backend correctness should be determined by:
transaction outcome
not by assuming:
HTTP connection still open
equals business success.
Scenario 20 — Error Mapping Itself Fails
Suppose business operation correctly throws:
InsufficientInventoryException
Transaction rolls back.
Then a bug occurs in HTTP error serialization.
Customer might receive:
500
instead of the intended:
409
But database consistency should still be intact.
This demonstrates another boundary:
transaction safety
should not depend on:
HTTP response formatting succeeding
Business Failure Matrix
A useful review table:
| Failure | Expected type | Inventory committed? | Order committed? |
|---|---|---|---|
| Duplicate Product | Business | No | No |
| Product missing | Business | No | No |
| Product inactive | Business | No | No |
| First Inventory insufficient | Business | No | No |
| Later Inventory insufficient | Business | No | No |
| Order persistence failure | System | No | No |
| OrderItem constraint failure | System | No | No |
| Database unavailable | System | No | No |
| Arithmetic overflow | System | No | No |
| Concurrent stock lost | Business | No for failed request | No for failed request |
| Deadlock/transaction failure | System | No | No |
The key pattern:
failed Create Order
→ no partial committed state
Business Error vs System Error
Let's make the distinction clearer.
Business error
The system is healthy.
The request cannot succeed because current business state disallows it.
Examples:
Product inactive
Inventory insufficient
Expected HTTP semantics:
4xx
System Error
The request might have been valid, but application/infrastructure could not safely complete it.
Examples:
PostgreSQL unavailable
unexpected persistence exception
arithmetic overflow
programming bug
Expected public result is generally:
5xx
with internal details logged, not exposed.
A Business Failure Is Not a Log-Level Emergency
Suppose Customer attempts:
5 units
but only:
2
remain.
That is normal competition for limited stock.
We should not necessarily log it as:
ERROR
with a giant stack trace as though PostgreSQL broke.
Expected business failures and system incidents should be operationally distinguishable.
Detailed logging strategy comes later, but the semantic distinction begins in application design.
A System Failure Should Not Pretend to Be Business-As-Usual
Likewise:
database connection refused
should not return:
"Product is out of stock"
just to give the client a friendly message.
That would hide an actual production problem and mislead users.
Failure-Safe UseCase Structure
A clean CreateOrderUseCase should not become:
try {
validate();
consume();
save();
} catch (Exception exception) {
// manually inspect and undo everything
}
Instead:
validate expected conditions explicitly
then:
perform database work in transaction
then:
allow failures to propagate correctly
Transaction manager handles local rollback.
UseCase Should Not Return Partial Success
Bad result:
{
"status": "PARTIAL_SUCCESS",
"acceptedItems": [...],
"rejectedItems": [...]
}
Our approved Order semantics are:
all-or-nothing
If any line is invalid:
whole Order fails
No partial Order concept exists.
Don't Silently Remove Invalid Items
Bad:
A valid
B inactive
C valid
backend creates:
A + C
That changes Customer intent.
Correct:
reject entire Order
and let client decide how to adjust the request.
Don't Silently Reduce Quantity
Suppose:
requested = 5
available = 3
Do not create:
quantity = 3
unless explicit product requirements define partial fulfilment.
Our v1 model does not.
Expected:
INSUFFICIENT_INVENTORY
Testing Strategy
Failure scenarios need multiple test layers.
Unit tests
UseCase/domain unit tests verify:
correct business error is selected
Product validation ordering
duplicate detection
price capture
no attempt to save Order after known failure
These do not prove actual transaction rollback.
Repository Integration Tests
Verify:
conditional Inventory update
constraint behaviour
JPA mappings
PostgreSQL persistence
using Testcontainers.
Transaction Integration Tests
These are critical.
They prove:
successful earlier SQL changes
are undone
when later workflow step fails
This cannot be reliably demonstrated with mocks.
API Tests
Verify public semantics:
Product missing
→ correct 404 Problem response
Product inactive
→ correct conflict response
Inventory insufficient
→ correct conflict response
unexpected persistence failure
→ safe 500 Problem response
without leaking:
SQL
stack trace
database credentials
The Most Valuable Rollback Test
Setup:
Product A
Inventory = 10
Product B
Inventory = 1
Request:
A × 2
B × 5
Execute real:
CreateOrderUseCase
Expected exception:
INSUFFICIENT_INVENTORY
Then query fresh database state.
Assert:
A Inventory = 10
B Inventory = 1
no Order for request
This is the test that tells us:
Our transaction boundary is real.
Order Persistence Failure Test
Use an integration setup where OrderRepository.save() deliberately fails after Inventory mutation.
Then assert:
Inventory rolled back
The exact mechanism for forcing failure should remain controlled and test-specific.
Don't corrupt production domain rules merely to create a test hook.
Constraint Failure Test
We should also have persistence-layer tests for schema safeguards, but not every database constraint needs to be triggered through CreateOrderUseCase.
For example:
negative unit_price_cents
is more directly a repository/schema integration test because the Domain normally makes such state impossible.
Test at the boundary where the risk actually exists.
Concurrency Test
Initial:
Inventory = 1
Two concurrent Order creations target the same Product.
Expected:
one commits
one fails
Final:
Inventory = 0
exactly one matching Order exists
This is stronger than testing consumeIfAvailable() alone because it proves:
Inventory concurrency
+
Order transaction
work together.
What If Winning Order Fails?
An even stronger test can simulate:
Transaction A consumes final stock
Transaction A later fails before Order commit
Then another valid attempt should eventually be able to consume that stock because A rolled back.
This is advanced integration behaviour, but it captures the actual guarantee we care about:
only committed Orders permanently consume Inventory
Testing Committed State
After failure, always inspect:
new/fresh database view
not merely objects held in the same persistence context.
Rollback can leave application objects in memory with values that do not represent committed database state.
Our truth is:
PostgreSQL committed state
In-Memory Domain State After Rollback
Suppose an object in Java was mutated:
Inventory object says 8
but transaction rolled back to:
database says 10
That Java object should not be treated as authoritative after the failed transaction.
The request is already failing.
Do not try to continue business logic using mutated in-memory objects after rollback.
Don't Recover Inside a Failed UseCase
Avoid:
try {
...
} catch (...) {
reload everything;
continue with another strategy;
}
inside the same failed business operation unless there is a deliberately designed recovery path.
For expected business failure:
fail clearly
and allow the caller/client to decide what to do next.
Fail Fast vs Rollback
These are complementary.
Fail fast means:
detect invalid state
as early as reasonably possible
Rollback means:
if failure occurs after mutations begin,
undo the local transaction
A robust workflow uses both.
Error Information Must Be Stable
Customers should receive stable machine-readable error codes such as:
PRODUCT_NOT_FOUND
PRODUCT_NOT_ORDERABLE
INSUFFICIENT_INVENTORY
not:
"could not execute statement"
"0 rows affected"
"constraint order_items_pk violated"
Those are internal implementation details.
Don't Leak Concurrency Mechanism
When another Customer wins the final stock, response should say:
INSUFFICIENT_INVENTORY
not:
"conditional SQL update returned 0"
The persistence mechanism can change later without breaking the API contract.
Failure Scenario Review
For every mutating workflow, ask:
What can fail before mutation?
What can fail after the first mutation?
What can fail during persistence?
What can fail because of concurrent state changes?
Which failures are expected business outcomes?
Which failures indicate system problems?
What must rollback?
Could any partial state remain?
Can the client safely retry?
Could the database commit but the HTTP response be lost?
These questions are more important than merely asking:
Does the happy path return 201?
Create Order Failure Model
Our final mental model:
Request arrives
↓
validate transport
↓
validate business input
↓
validate Products
↓
build OrderItems / calculate cents
↓
BEGIN persistent mutation phase
↓
consume Inventory
↓
persist Order
↓
persist OrderItems
↓
COMMIT
At any failure:
before mutation
→ nothing to undo
after mutation begins
→ transaction rollback
Result:
no partial committed Order state
What We Deliberately Do Not Add
We do not add:
partial Order fulfilment
automatic quantity reduction
manual transaction compensation
generic retry framework
Redis locks
Create Order idempotency key
reservation recovery process
distributed transaction coordinator
without a requirement.
We recognize some of these as future production concerns, but we do not silently expand v1 scope.
Engineering Principle
The core principle:
Failure behaviour is part of the business workflow. A Create Order implementation is not correct unless every failure path leaves the database in a valid, predictable state.
Another:
Fail early where possible, but rely on the transaction to protect correctness once persistent mutation begins. Validation reduces unnecessary work; rollback prevents partial state.
And:
Keep business failures and system failures semantically separate. “Inventory is unavailable” and “the database is unavailable” are not the same problem and must never be reported as though they are.
Summary
In this lesson, we established that:
- Create Order is all-or-nothing.
- A failed Order must never leave permanently consumed Inventory.
- A failed Order must never leave partial Order or OrderItem rows.
- Duplicate Product input should fail before persistent mutation.
- Missing or inactive Products should be detected before Inventory consumption.
- Product state can legitimately change between browsing and Order creation.
- Inventory failure on the first item leaves state unchanged.
- Inventory failure after earlier successful item consumption requires transaction rollback.
- All previously successful Inventory updates must roll back when a later item fails.
- Order persistence failure must roll back Inventory consumption.
- OrderItem persistence/constraint failure must roll back both Order and Inventory state.
- Individual successful SQL statements are not equivalent to a committed business operation.
- PostgreSQL constraints remain a final integrity boundary.
- Unexpected constraint violations often indicate application defects rather than normal business errors.
- Database outages are system failures, not insufficient-stock errors.
- Arithmetic overflow is a safety/system failure and must not silently corrupt totals.
- Monetary/domain validation should happen before persistent mutation where possible.
- Transaction rollback is a safety net, not an excuse for poor operation ordering.
- Concurrent Customers competing for the final stock produce one successful Order and one normal business failure.
- Only a committed Order permanently consumes Inventory.
- Deadlocks and transaction failures remain technical failures rather than business stock failures.
- Transaction atomicity does not solve client/network ambiguity after commit.
- A transaction may commit even when the client never receives the success response.
- Create Order request idempotency is a separate concern and is not silently added to the current API.
- The backend never returns partial Order success or silently removes invalid lines.
- Business errors should map to stable application error codes.
- Persistence and concurrency implementation details must not leak into API responses.
- Unit tests verify error selection and orchestration.
- Repository integration tests verify SQL/constraints.
- Transaction integration tests verify actual PostgreSQL rollback.
- Concurrency integration tests should prove that Inventory and Order commit behaviour work together.
- Fresh committed database state should be checked after rollback, not stale in-memory entities.
- Failure behaviour must be designed and tested with the same care as the happy path.
Next lesson:
Order State Transitions
There we will move from Order creation into lifecycle behaviour and implement the only v1 transitions:
UNPAID
├──→ PAID
└──→ CANCELLED
We will define exactly which transitions are valid, which states are terminal, why arbitrary setStatus() is dangerous, and how Domain state transitions remain separate from external payment workflow and Inventory restoration.