Implementing Business Workflows

Rollback and Failure Scenarios

ReadingPreview

আপনি একটি 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:

FailureExpected typeInventory committed?Order committed?
Duplicate ProductBusinessNoNo
Product missingBusinessNoNo
Product inactiveBusinessNoNo
First Inventory insufficientBusinessNoNo
Later Inventory insufficientBusinessNoNo
Order persistence failureSystemNoNo
OrderItem constraint failureSystemNoNo
Database unavailableSystemNoNo
Arithmetic overflowSystemNoNo
Concurrent stock lostBusinessNo for failed requestNo for failed request
Deadlock/transaction failureSystemNoNo

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.