Implementing Business Workflows

Order Cancellation

ReadingPreview

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

Order creation-এর সময় আমরা একটি deliberate business decision নিয়েছি:

successful Order creation
→ Inventory immediately consumed

Order শুরু হয়:

UNPAID

এবং v1-এ Customer শুধুমাত্র নিজের unpaid Order cancel করতে পারে।

Cancellation মানে শুধু:

status = CANCELLED

নয়।

একটি successful cancellation-এর complete business effect:

Order
UNPAID → CANCELLED

AND

every OrderItem quantity
→ returned to Inventory

এই দুই change অবশ্যই:

atomically

ঘটতে হবে।

Endpoint:

POST /api/v1/orders/{orderId}/cancel

এই lesson-এর goal:

একটি concurrency-safe cancellation workflow implement করা, যেখানে ownership, Order lifecycle, Inventory restoration, transaction atomicity এবং repeated cancellation correctness একসঙ্গে কাজ করবে।


Cancellation Requirement

Customer একটি Order cancel করতে পারবে যখন:

Order exists

Order belongs to Customer

Order status = UNPAID

Successful cancellation:

Order status
UNPAID → CANCELLED

এবং Order creation-এর সময় consumed Inventory:

restored

হবে।


What We Do Not Support

v1 cancellation does not support:

PAID → CANCELLED

কারণ paid Order cancellation imply করতে পারে:

refund
payment reversal
financial reconciliation

যেগুলো current scope-এর বাইরে।

তাই:

UNPAID
→ cancellable
PAID
→ not cancellable
CANCELLED
→ not cancellable

The Complete Flow

Conceptually:

authenticated CustomerId
    ↓
OrderId
    ↓
load owned Order for mutation
    ↓
order.cancel()
    ↓
restore Inventory
    ↓
persist Order
    ↓
commit

More precisely:

BEGIN TRANSACTION
    ↓
load + lock owned Order
    ↓
verify UNPAID through Domain
    ↓
UNPAID → CANCELLED
    ↓
restore each OrderItem quantity
    ↓
persist CANCELLED Order
    ↓
COMMIT

Failure anywhere:

ROLLBACK

Why Cancellation Is a UseCase

Cancellation crosses multiple concepts:

authenticated Customer

Order

Order lifecycle

Inventory

persistence

transaction

No single Entity should own this entire workflow.

Order knows:

Can I transition to CANCELLED?

CancelOrderUseCase knows:

Who is requesting this?

Which Order should be loaded?

What Inventory must be restored?

Which changes must commit together?

Domain Responsibility

The Order owns:

order.cancel();

which enforces:

only UNPAID → CANCELLED

It does not know:

current authenticated Customer

InventoryRepository

OrderRepository

PostgreSQL transaction

This remains the correct separation.


Why Cancellation Needs Concurrency Protection

Consider:

Order = UNPAID

Product A quantity = 2

Two cancellation requests arrive simultaneously:

Request A
→ cancel
Request B
→ cancel

If both independently load:

UNPAID

both could locally execute:

order.cancel();

and both could restore:

2 units

Result:

Inventory restored twice

That is a serious correctness bug.


Example of Double Restoration

Initial Inventory after Order creation:

8

The Order had consumed:

2

Correct cancellation:

8 → 10

But two concurrent cancellations could produce:

8 → 10 → 12

Now Inventory claims more units exist than before the Order was created.

So:

Domain rejecting CANCELLED → CANCELLED is necessary, but concurrent requests must also be prevented from both observing the Order as UNPAID.


We Need an Order Mutation Lock

Cancellation needs the current Order state and its OrderItems anyway.

Unlike Inventory consumption, where a conditional UPDATE alone expressed the operation perfectly, cancellation needs the full Order aggregate to:

check lifecycle

know item quantities

restore Inventory

So a good persistence strategy here is:

load Order row
while acquiring a database row-level mutation lock

Conceptually:

SELECT
    id,
    customer_id,
    status,
    created_at
FROM orders
WHERE id = :orderId
  AND customer_id = :customerId
FOR UPDATE;

This says:

Load this owned Order for a state-changing operation and prevent another competing transaction from changing the same Order row concurrently until this transaction completes.


Why This Fits Cancellation Better Than Inventory Consumption

Inventory consumption required:

subtract N
only if enough exists

A conditional atomic UPDATE expressed that directly.

Cancellation requires:

load complete Order

inspect lifecycle

use OrderItems

restore several Inventory rows

change Order state

So locking the Order mutation boundary before executing the workflow is a pragmatic choice.

Different concurrency problems can legitimately use different persistence techniques.


Repository Contract

Our application Repository can expose:

Optional<Order> findOwnedByIdForUpdate(
        OrderId orderId,
        CustomerId customerId
);

The name communicates:

load this owned Order
for a state-changing operation

The UseCase does not know:

FOR UPDATE

JPA lock mode

EntityManager

Those remain persistence details.


Why Include CustomerId in the Query?

We could do:

findByIdForUpdate(orderId)

then:

if (!order.customerId().equals(customerId)) {
    ...
}

But ownership can be scoped directly in the persistence query:

WHERE id = ?
AND customer_id = ?

This gives us two useful properties:

unauthorized Customer does not obtain the Order

and:

ownership filtering happens at the database boundary

From the Customer-facing UseCase:

missing Order

or another Customer's Order

can both appear as:

ORDER_NOT_FOUND

if that is our access policy.

This avoids revealing whether another Customer's Order exists.


CancelOrderCommand

Application input:

package io.liveklass.ordermanagement.order.usecase;

import io.liveklass.ordermanagement.customer.CustomerId;
import io.liveklass.ordermanagement.order.domain.OrderId;

public record CancelOrderCommand(
        CustomerId customerId,
        OrderId orderId
) {
}

No:

status

No:

inventory quantity

No:

product price

The server already owns those facts.


Inventory Restoration Semantics

Order cancellation restores exactly:

OrderItem.quantity

for every OrderItem.

Suppose Order contains:

Product A × 2
Product B × 5

Cancellation restores:

A +2

B +5

Not current Product availability.

Not a client-provided quantity.

Not Product price.

The Order itself contains the historical quantities required to reverse its Inventory consumption.


Do Not Set Inventory From a Stale Value

Bad:

Inventory inventory =
        inventoryRepository
                .findByProductId(productId)
                .orElseThrow();

inventory.setAvailableQuantity(
        inventory.availableQuantity()
                + orderItem.quantity()
);

inventoryRepository.save(
        inventory
);

This is another:

read
→ calculate
→ write

pattern.

Concurrent Orders may also be consuming or restoring the same Product.

We can express restoration more safely as an atomic database increment.


Restore Inventory Atomically

Repository contract:

boolean restore(
        ProductId productId,
        int quantity
);

Persistence intent:

Add this positive quantity to the current Inventory row.

Conceptual SQL:

UPDATE inventory
SET available_quantity =
        available_quantity + :quantity
WHERE product_id = :productId;

Because:

product_id

is the primary key, expected result is:

1 row updated

Why No Availability Condition?

Consumption requires:

enough Inventory exists

before subtraction.

Restoration is different.

If cancellation returns:

2

units, we simply add:

2

to whatever quantity currently exists.

Example:

Inventory after Order creation = 8

another Order consumes 3
→ 5

our Order cancels
→ restore 2

final = 7

That is correct.

We should not restore to the old historical value:

10

because other legitimate Inventory changes may have happened since this Order was created.


Restoration Is a Delta Operation

This distinction is critical.

Admin:

set quantity = X

Order creation:

subtract N if available

Order cancellation:

add N

These are three different operations:

setAvailableQuantity(...)
consumeIfAvailable(...)
restore(...)

Avoid collapsing them into:

updateInventory(...)

What If Inventory Row Is Missing?

This should be extremely unusual.

Remember:

Order creation

could succeed only if:

consumeIfAvailable()

successfully updated an existing Inventory row.

So for an existing OrderItem, later finding:

no Inventory row

during cancellation suggests:

data integrity problem

unexpected manual deletion

application bug

It should not be treated as a normal Customer business failure.


Do Not Recreate Missing Inventory Silently

Bad:

Inventory inventory =
        inventoryRepository
                .findByProductId(productId)
                .orElseGet(
                        () -> Inventory.create(
                                productId,
                                quantity
                        )
                );

during cancellation.

That would hide a broken invariant.

We expect:

Inventory row exists

because the Order previously consumed it.

If restoration updates:

0 rows

the cancellation transaction should fail.


System Failure, Not INVENTORY_NOT_FOUND

For admin:

GET /inventory/{productId}

missing Inventory can legitimately mean:

INVENTORY_NOT_FOUND

But cancellation has different semantics.

A missing row during restore means:

system consistency problem

not something the Customer should fix.

So the operation should fail safely and rollback.

The next Business Errors vs System Errors lesson will formalize this distinction.


InventoryRepository

Our Repository now conceptually contains:

public interface InventoryRepository {

    Optional<Inventory> findByProductId(
            ProductId productId
    );

    void save(
            Inventory inventory
    );

    PageResult<InventorySummary> findPage(
            PageQuery pageQuery
    );

    boolean consumeIfAvailable(
            ProductId productId,
            int quantity
    );

    boolean restore(
            ProductId productId,
            int quantity
    );
}

Again:

consumeIfAvailable

and:

restore

make the persistence semantics explicit.


Persistence Implementation

Conceptually:

@Override
public boolean restore(
        ProductId productId,
        int quantity
) {
    if (quantity <= 0) {
        throw new IllegalArgumentException(
                "Quantity must be positive"
        );
    }

    int updatedRows =
            repository.restore(
                    productId.value(),
                    quantity
            );

    return updatedRows == 1;
}

The exact SQL remains behind the Repository boundary.


Why Check Positive Quantity Again?

OrderItem already guarantees:

quantity > 0

So a valid Order should never request:

restore 0

restore -5

Still, Repository-level defensive checking helps ensure a programming mistake never turns:

+ (-5)

into accidental Inventory consumption.

This is boundary protection, not duplicated business workflow.


Restore Multiple Products in Deterministic Order

Cancellation may restore several Product rows.

Example:

Product B

Product A

Product C

Like Order creation, we should update Inventory rows using a consistent technical order:

ProductId ASC

This reduces obvious multi-row deadlock patterns when concurrent transactions update overlapping Products.

It does not change the OrderItems' business order.


Full CancelOrderUseCase

Conceptually:

package io.liveklass.ordermanagement.order.usecase;

import io.liveklass.ordermanagement.inventory.repository.InventoryRepository;
import io.liveklass.ordermanagement.order.domain.Order;
import io.liveklass.ordermanagement.order.domain.OrderItem;
import io.liveklass.ordermanagement.order.repository.OrderRepository;

import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.Comparator;
import java.util.List;

@Component
public class CancelOrderUseCase {

    private final OrderRepository
            orderRepository;

    private final InventoryRepository
            inventoryRepository;

    public CancelOrderUseCase(
            OrderRepository orderRepository,
            InventoryRepository inventoryRepository
    ) {
        this.orderRepository =
                orderRepository;

        this.inventoryRepository =
                inventoryRepository;
    }

    @Transactional
    public Order execute(
            CancelOrderCommand command
    ) {
        Order order =
                orderRepository
                        .findOwnedByIdForUpdate(
                                command.orderId(),
                                command.customerId()
                        )
                        .orElseThrow(
                                OrderNotFoundException::new
                        );

        order.cancel();

        restoreInventory(
                order.items()
        );

        orderRepository.save(
                order
        );

        return order;
    }

    private void restoreInventory(
            List<OrderItem> items
    ) {
        List<OrderItem> sortedItems =
                items.stream()
                        .sorted(
                                Comparator.comparing(
                                        item ->
                                                item.productId()
                                                        .value()
                                )
                        )
                        .toList();

        for (OrderItem item : sortedItems) {
            boolean restored =
                    inventoryRepository
                            .restore(
                                    item.productId(),
                                    item.quantity()
                            );

            if (!restored) {
                throw new IllegalStateException(
                        "Inventory row missing for ordered product"
                );
            }
        }
    }
}

The workflow is compact because responsibilities are well separated.


Why order.cancel() Happens Before Inventory Restore

First we establish:

is the Order actually cancellable?

Domain answers that through:

order.cancel();

If Order is:

PAID

or:

CANCELLED

it throws before Inventory is restored.

That's exactly what we want.


But order.cancel() Mutates the Object Before Inventory Restore

Correct.

Inside the transaction:

Order Java state
→ CANCELLED

then Inventory restoration begins.

Suppose restoration later fails.

Transaction rolls back persistent changes.

The request fails.

The in-memory Order object may temporarily contain:

CANCELLED

but that failed request must not continue using it as authoritative state.

Committed PostgreSQL state remains the source of truth.


Could We Restore First and Cancel Later?

That would be worse.

Suppose Order is actually:

PAID

and we restored Inventory before asking Domain whether cancellation is valid.

We would perform unnecessary invalid mutation and rely on rollback to clean it up.

Prefer:

validate lifecycle first

then:

perform persistent side effects

Transaction Boundary

Cancellation must be:

@Transactional

because these operations belong together:

Order status mutation

Inventory restoration A

Inventory restoration B

Inventory restoration C

All succeed:

COMMIT

Any fails:

ROLLBACK

Failure While Restoring Second Product

Initial:

Order = UNPAID

Product A Inventory = 5

Product B Inventory row unexpectedly missing

Order contained:

A × 2

B × 1

Inside cancellation:

Order
UNPAID → CANCELLED

Restore A:

5 → 7

Restore B:

0 rows updated

System failure thrown.

Transaction rolls back.

Final committed state:

Order = UNPAID

Product A Inventory = 5

Product B still inconsistent/missing

No partial cancellation.


Why Order Returns to UNPAID

The Order's CANCELLED persistence was part of the same transaction.

Since cancellation did not complete safely:

CANCELLED

must not become committed state.

The Customer can receive a system error while operators investigate the Inventory inconsistency.


Repeated Cancellation

First request:

load UNPAID Order for update

acquires the mutation lock.

Then:

order.cancel()

and Inventory is restored.

Transaction commits:

Order = CANCELLED

Second request, after acquiring the same Order mutation lock, observes:

CANCELLED

Then:

order.cancel();

throws:

ORDER_NOT_CANCELLABLE

No Inventory restore occurs.

This prevents double-restoration.


Two Concurrent Cancellation Requests

Timeline:

Request A
→ lock Order row
→ sees UNPAID

Request B:

tries to acquire same Order mutation lock
→ waits

A:

cancel

restore Inventory

commit

Then B continues.

B observes the committed state:

CANCELLED

and rejects.

Expected result:

one cancellation succeeds

one cancellation fails

Inventory restored exactly once

This Is Why Domain + Persistence Both Matter

Domain provides:

CANCELLED cannot cancel again

Persistence lock provides:

two concurrent requests cannot both
make the lifecycle decision
from the same stale UNPAID state

Together they provide correct behaviour.


save() Alone Would Not Solve This

Without mutation locking:

A reads UNPAID

B reads UNPAID

A cancel()

B cancel()

A restore()

B restore()

Both Domain objects individually saw a valid transition.

So:

Domain transition validation

alone is insufficient for concurrent state changes.

Likewise:

orderRepository.save(order);

is not automatically a lifecycle concurrency strategy.


What About Concurrent Pay and Cancel?

This is more complicated.

Suppose:

Order = UNPAID

and concurrently:

Customer starts payment

Customer starts cancellation

Cancellation can serialize its local lifecycle mutation through the Order row.

But payment involves:

external Payment Provider

and we must not hold a database row lock open while waiting on a remote HTTP request.

Therefore:

Pay vs Cancel

cannot be solved merely by wrapping the entire payment provider call in the same row lock.

Module 9 will design that external-system workflow separately.


Important Convention for Future Payment

When payment eventually tries to commit:

Order → PAID

it must re-check the latest Order state at the local persistence boundary.

It cannot assume:

Order was UNPAID

before a potentially slow external call means:

Order is still UNPAID

afterward.

The external success + concurrent cancellation problem requires integration-specific reasoning.

We intentionally do not solve it prematurely here.


Cancellation Does Not Call Payment Provider

Because only:

UNPAID

Orders are cancellable, there is no successful payment to reverse in this v1 cancellation flow.

So CancelOrderUseCase has no:

PaymentService

dependency.

This keeps cancellation entirely local:

Order

Inventory

PostgreSQL

and therefore safely transactional.


Product State Is Irrelevant During Cancellation

Suppose Product was:

active = true

when Order was created.

Later admin deactivates it:

active = false

Customer then cancels the unpaid Order.

Inventory must still be restored.

Therefore CancelOrderUseCase does not need:

ProductRepository

at all.

It already has everything required:

OrderItem.productId

OrderItem.quantity

Do Not Check Product Active State

Bad:

Product product =
        productRepository
                .findById(
                        item.productId()
                )
                .orElseThrow();

if (!product.active()) {
    throw ...;
}

during cancellation.

This would prevent correct Inventory restoration for an inactive Product.

Product active state determines:

future orderability

not:

whether historical Inventory consumption
can be reversed

Product Price Is Also Irrelevant

Cancellation does not need:

Product.priceCents

or current Product price.

Inventory restoration depends only on:

quantity

Order's historical monetary state remains unchanged.


Cancellation Does Not Modify Order Total

If Order represented:

totalCents = 24930

before cancellation, after:

CANCELLED

the historical Order still represents:

24930

We do not change:

unitPriceCents

quantity

totalCents

because cancellation changes lifecycle, not historical Order contents.


Cancellation Does Not Delete OrderItems

Bad:

cancel Order
→ delete OrderItems

That would destroy history.

The Order remains persisted:

status = CANCELLED

with the same OrderItems.

This allows future:

Order history

to show what was cancelled.


Cancellation Does Not Delete Order

Likewise we never:

DELETE FROM orders

during cancellation.

Cancellation is a state transition:

UNPAID → CANCELLED

not deletion.


Handler

Endpoint:

POST /api/v1/orders/{orderId}/cancel

Conceptually:

@PostMapping("/{orderId}/cancel")
public ResponseEntity<OrderResponse>
cancelOrder(
        @PathVariable
        UUID orderId
) {
    CustomerId customerId =
            currentAuthenticatedCustomerId();

    Order order =
            cancelOrderUseCase.execute(
                    new CancelOrderCommand(
                            customerId,
                            new OrderId(
                                    orderId
                            )
                    )
            );

    return ResponseEntity.ok(
            OrderResponse.from(
                    order
            )
    );
}

Again:

currentAuthenticatedCustomerId()

is wired properly in Module 8.

The request body is unnecessary.


Why No Request Body?

Cancellation needs only:

which Order?

and:

who is authenticated?

Both already come from:

path

security context

No need for:

{
  "status": "CANCELLED"
}

or:

{
  "restoreInventory": true
}

Those are server-defined semantics.


Successful Response

Conceptually:

200 OK
{
  "id": "36a25516-2af2-4644-b55d-d0cabf389c9e",
  "status": "CANCELLED",
  "items": [
    {
      "productId": "9ed4c9a9-8761-4e4f-9f50-aef83f14c13d",
      "quantity": 2,
      "unitPriceCents": 8990,
      "totalCents": 17980
    }
  ],
  "totalCents": 17980
}

Historical pricing remains visible.


Order Not Found

If:

Order does not exist

or, under our Customer ownership policy:

Order belongs to another Customer

the scoped Repository returns:

empty

UseCase returns:

ORDER_NOT_FOUND

HTTP boundary:

404

Why Not ACCESS_DENIED for Another Customer's Order?

For customer-owned resources, returning:

404

for both nonexistent and non-owned Orders can avoid revealing another Customer's resource existence.

Module 8 will formalize authorization behaviour.

The important point is:

Customer cannot cancel someone else's Order

Paid Order Cancellation

Order:

PAID

UseCase loads it.

Then:

order.cancel();

throws:

ORDER_NOT_CANCELLABLE

Inventory restoration never begins.

Expected HTTP:

409 Conflict

No refund workflow is started.


Already Cancelled Order

Order:

CANCELLED

then:

order.cancel();

rejects.

Response:

ORDER_NOT_CANCELLABLE

No Inventory changes.

This prevents accidental repeated restoration.


Why Not Return Success for Already Cancelled?

Some command APIs make repeated operations idempotent.

But here successful cancellation implies:

Inventory restored

and our Domain intentionally treats:

cancelled Order

as no longer cancellable.

Returning another fake success could hide:

duplicate command execution

client retry ambiguity

workflow bug

So current contract rejects it.


Cancellation and HTTP Retry

Suppose first cancellation:

commits successfully

but HTTP response is lost.

Customer retries.

Second request sees:

CANCELLED

and returns:

ORDER_NOT_CANCELLABLE

This tells us the operation is not request-idempotent from the client's response perspective, even though double Inventory restoration is prevented.

That's acceptable under our current contract.

If product requirements later demand retry-safe cancellation responses, we could deliberately change API semantics.

We do not invent that now.


Atomic Inventory Restore

Suppose current Inventory:

Product A = 5

Order cancellation restores:

2

At the same time another Order consumes:

1

Because both operations update the authoritative PostgreSQL row, the final result should reflect both committed deltas, not a stale read overwriting the other operation.

This is why:

available_quantity =
    available_quantity + :quantity

is preferable to:

read 5

calculate 7

save 7

Admin Set Is Different

Suppose admin simultaneously executes:

set Inventory = 20

while cancellation restores:

+2

Our current admin endpoint uses absolute set semantics.

Therefore final result depends on transaction ordering:

set 20
then restore 2
→ 22

or:

restore 2
then set 20
→ 20

This is consistent with what:

set current quantity

means.

If the business later requires audit-preserving stock adjustments instead of absolute reconciliation, Inventory model would need to evolve.


Overflow During Restoration

Inventory uses:

INTEGER

in PostgreSQL.

A pathological restoration that exceeds supported integer range should fail rather than silently wrap.

That would be a:

system/integrity failure

and cancellation transaction should rollback.

We do not invent arbitrary maximum Inventory limits merely to avoid thinking about overflow.

The storage type itself has a representable range.


Cancellation Transaction Example

Initial:

Order = UNPAID

A Inventory = 5
B Inventory = 10

Order contains:

A × 2
B × 3

Cancellation:

BEGIN

Lock/load Order.

Domain:

UNPAID → CANCELLED

Restore A:

5 → 7

Restore B:

10 → 13

Persist Order:

status = CANCELLED

Then:

COMMIT

Final:

Order = CANCELLED

A = 7

B = 13

Failure Example

Suppose B Inventory row unexpectedly disappeared.

Flow:

BEGIN

Order:

UNPAID → CANCELLED

A:

5 → 7

B:

restore
→ 0 rows

Throw system failure.

Then:

ROLLBACK

Final:

Order = UNPAID

A = 5

No partial cancellation.


Testing the Domain

Pure Order tests already cover:

UNPAID can cancel

PAID cannot cancel

CANCELLED cannot cancel

Cancellation UseCase tests focus on orchestration.


UseCase Test — Successful Cancellation

Given:

Order owner = Sakib

status = UNPAID

items:
A × 2
B × 3

Execute with:

CustomerId = Sakib

Expected:

Order = CANCELLED

restore A by 2

restore B by 3

Order persisted

Ownership Test

Given:

Order owner = Jalisa

Request from:

CustomerId = Sakib

Scoped Repository returns no Order.

Expected:

ORDER_NOT_FOUND

and:

no Inventory restoration

Paid Order Test

Given:

Order = PAID

Expected:

ORDER_NOT_CANCELLABLE

and Inventory Repository must not receive restore operations.

This is important:

lifecycle rejection happens before side effects

Already Cancelled Test

Given:

Order = CANCELLED

Expected:

ORDER_NOT_CANCELLABLE

and:

restore count = 0

This guards against double restoration.


Transaction Integration Test

Initial:

Order = UNPAID

A Inventory = 10

B Inventory = 5

Order items:

A × 2

B × 1

Run real cancellation.

Then query PostgreSQL from a fresh context.

Assert:

Order = CANCELLED

A = 12

B = 6

Rollback Integration Test

Force restoration for B to fail after A succeeds.

Expected committed database state:

Order = UNPAID

A = 10

B unchanged

This proves:

Order mutation
+
all Inventory restoration

share one transaction.


Concurrent Cancellation Test

Initial:

Order = UNPAID

Inventory = 8

Order had consumed:

2

Run two cancellation requests concurrently.

Expected:

exactly one succeeds

exactly one fails

Final:

Order = CANCELLED

Inventory = 10

Never:

12

This is one of the highest-value tests for the cancellation workflow.


Persistence Test for the Mutation Lock

The test should use:

real PostgreSQL

separate threads

separate transactions

so both cancellation attempts genuinely compete on the same Order row.

Mocks cannot prove row-level serialization.


Cancellation Does Not Need ProductRepository

A useful design review test:

Current dependencies should be:

OrderRepository

InventoryRepository

Not:

ProductRepository

Not:

PaymentService

Not:

CustomerRepository

That tells us the workflow is staying focused.


Capability Flow

We now have:

POST /api/v1/orders/{orderId}/cancel
    ↓
CancelOrderHandler
    ↓
CancelOrderUseCase
    ↓
OrderRepository
    ├── load owned Order for mutation
    └── persist cancelled Order
    ↓
InventoryRepository
    └── restore item quantities atomically
    ↓
PostgreSQL

Everything is local and transactional.


Why findOwnedByIdForUpdate() Is Not a Generic Read Method

Normal reads:

Order history

Order detail

should not acquire mutation locks unnecessarily.

The lock is for:

state-changing workflows

So a distinct Repository operation makes intent explicit.

Avoid changing normal:

findById(...)

to always lock rows.


Lock Only What We Need

We acquire the Order mutation lock because competing lifecycle transitions on the same Order must serialize.

We do not lock:

every Product

every Order

entire inventory table

Broad locking would unnecessarily reduce concurrency.

Protect the actual contention point.


Keep the Transaction Short

Cancellation transaction contains:

database load

domain transition

Inventory UPDATEs

Order persistence

No:

remote API calls

email

notification delivery

long-running computation

This keeps database lock duration limited.


What About Notifications?

A future product might want:

"Your order was cancelled"

email.

That should not become a reason to hold the Order row lock while an email provider responds.

No such notification requirement exists now anyway.

We keep cancellation local and focused.


Common Mistake 1 — Set status = CANCELLED

This bypasses the lifecycle rule.

Use:

order.cancel();

Common Mistake 2 — Restore Inventory Before Checking State

A paid or already cancelled Order must not restore stock.

Check lifecycle first.


Common Mistake 3 — Read Inventory, Add, Save

This can lose concurrent Inventory updates.

Use atomic delta restoration.


Common Mistake 4 — Restore Based on Current Product State

Product active state and current price are irrelevant to historical cancellation.

Use OrderItems.


Common Mistake 5 — Recreate Missing Inventory

Missing Inventory for a previously consumed Product indicates inconsistency.

Fail and rollback rather than hiding it.


Common Mistake 6 — Allow Repeated Cancellation Success

This risks hiding double-execution semantics and Inventory restoration bugs.


Common Mistake 7 — No Order Mutation Concurrency Strategy

Two concurrent cancellations must not both act on stale UNPAID state.


Common Mistake 8 — Generic updateInventory()

Set, consume, and restore are different business persistence operations.

Name them separately.


Common Mistake 9 — Delete Cancelled Order

Cancellation is historical lifecycle state, not deletion.


Common Mistake 10 — Remove OrderItems

Order history must retain what was ordered and at what purchase-time price.


Common Mistake 11 — Check Product Active

Inactive Products still receive restored Inventory.


Common Mistake 12 — Call Payment Provider

Unpaid cancellation has no payment reversal in v1.


Cancellation Checklist

Before calling a cancellation workflow complete, verify:

Does the Order belong to the authenticated Customer?

Is unauthorized ownership handled safely?

Is the Order loaded for mutation with concurrency protection?

Does Domain enforce UNPAID → CANCELLED?

Can PAID Order cancellation ever restore Inventory?

Can repeated cancellation restore Inventory twice?

Does restoration use OrderItem quantities?

Does Product active state remain irrelevant?

Does restoration use an atomic increment?

Are multiple Inventory rows updated in deterministic order?

Does missing Inventory fail the whole transaction?

Do Order state and Inventory changes commit together?

Do integration tests prove rollback?

Do concurrency tests prove exactly one cancellation succeeds?

Final Cancellation Model

Canonical workflow:

POST /api/v1/orders/{orderId}/cancel
    ↓
authenticated CustomerId
    ↓
BEGIN TRANSACTION
    ↓
findOwnedByIdForUpdate()
    ↓
Order found?
    ├── no → ORDER_NOT_FOUND
    └── yes
          ↓
      order.cancel()
          ↓
      UNPAID?
      ├── no → ORDER_NOT_CANCELLABLE
      └── yes
          ↓
      sort OrderItems by ProductId
          ↓
      restore Inventory atomically
          ↓
      persist CANCELLED Order
          ↓
      COMMIT

Any required restoration failure:

ROLLBACK

Result:

either

Order = CANCELLED
and all Inventory restored

or

Order remains unchanged
and no Inventory restoration commits

Engineering Principle

The core principle:

Cancellation reverses the Inventory effect of a committed Order creation, not the historical Order itself. The Order and its OrderItems remain as history; only lifecycle state and available Inventory change.

Another:

When a business operation has side effects that must happen exactly once, domain state validation alone is not enough under concurrency. Competing lifecycle mutations must also be serialized at the persistence boundary.

And:

Restore Inventory as an atomic delta from the Order's historical quantities. Never reconstruct a stale absolute quantity and never make cancellation depend on current Product state.


Summary

In this lesson, we established that:

  • Customer cancellation is exposed through POST /api/v1/orders/{orderId}/cancel.
  • Only an authenticated Customer's own Order can be cancelled.
  • Ownership can be scoped directly in the Repository query.
  • Non-owned Orders may be treated as ORDER_NOT_FOUND.
  • Only UNPAID Orders are cancellable.
  • PAID and CANCELLED Orders produce ORDER_NOT_CANCELLABLE.
  • Order.cancel() remains the authoritative domain lifecycle transition.
  • Cancellation restores exactly each OrderItem's quantity.
  • Product active state is irrelevant during restoration.
  • Current Product price is irrelevant during restoration.
  • Historical unitPriceCents and totalCents remain unchanged.
  • Cancelled Orders and OrderItems are retained for history.
  • Inventory restoration is an atomic increment, not read-add-save.
  • InventoryRepository.restore() expresses the persistence intent explicitly.
  • Missing Inventory during cancellation indicates a system/integrity problem rather than a normal Customer error.
  • Missing Inventory must not be silently recreated.
  • Order lifecycle mutation and all Inventory restoration belong to one PostgreSQL transaction.
  • A restoration failure rolls back the Order status change and every earlier restoration.
  • Concurrent cancellation requires persistence-level serialization in addition to Domain transition validation.
  • Cancellation loads the owned Order for mutation using an Order-row lock.
  • Two concurrent cancellation requests cannot both successfully restore Inventory.
  • Repeated cancellation does not restore Inventory twice.
  • Multiple Inventory rows are restored in deterministic ProductId order to reduce obvious deadlock patterns.
  • Cancellation has no ProductRepository dependency.
  • Cancellation has no CustomerRepository dependency.
  • Cancellation has no PaymentService dependency.
  • We keep the transaction short and entirely local.
  • Concurrent Pay-vs-Cancel requires additional external-payment workflow reasoning and is intentionally handled later rather than by holding a DB lock across a remote provider call.

Next lesson:

Business Errors vs System Errors

There we will formalize the error taxonomy we've been using throughout Module 7: what counts as expected business failure, validation failure, authorization/ownership failure, persistence/infrastructure failure, and programming/invariant failure—and how those become stable API Problem responses without hiding real production problems.