Implementing Business Workflows
Order Cancellation
আপনি একটি 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 → CANCELLEDis necessary, but concurrent requests must also be prevented from both observing the Order asUNPAID.
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
UNPAIDOrders are cancellable. PAIDandCANCELLEDOrders produceORDER_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
unitPriceCentsandtotalCentsremain 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.