Implementing Business Workflows
Order State Transitions
আপনি একটি free preview lesson দেখছেন।
Order create হলে আমাদের v1 lifecycle শুরু হয়:
UNPAID
এরপর Order-এর মাত্র দুটি valid transition আছে:
UNPAID
├──→ PAID
└──→ CANCELLED
এবং:
PAID
CANCELLED
দুটি state-ই v1-এ terminal।
অর্থাৎ:
PAID
→ আর কোনো transition নেই
CANCELLED
→ আর কোনো transition নেই
এই lesson-এর goal:
Order lifecycle-কে explicit domain behaviour হিসেবে implement করা, যাতে application-এর কোনো layer arbitrary status mutation করতে না পারে।
Order Status Is Business State
OrderStatus শুধু database column নয়।
এটি Order-এর business lifecycle represent করে।
public enum OrderStatus {
UNPAID,
PAID,
CANCELLED
}
এই তিনটি value-এর meaning:
UNPAID
→ Order created successfully
→ Inventory already consumed
→ payment not yet confirmed
PAID
→ payment confirmed
→ Order completed from payment perspective
CANCELLED
→ unpaid Order cancelled
→ consumed Inventory must be restored by cancellation workflow
State Is Not Just a String
Bad design:
order.setStatus(
OrderStatus.PAID
);
এতে caller যেকোনো জায়গা থেকে করতে পারে:
UNPAID → PAID
কিন্তু একইভাবে:
CANCELLED → PAID
বা:
PAID → UNPAID
ও করতে পারে।
Compiler থামাবে না।
Domain-ও rule enforce করবে না।
এই কারণেই generic setter dangerous।
Prefer Behaviour
Instead:
order.markPaid();
and:
order.cancel();
এগুলো intent communicate করে।
Caller বলে না:
status = X
Caller বলে:
mark this Order as paid
বা:
cancel this Order
তারপর Order নিজে decide করে transition valid কিনা।
State Transition Table
আমাদের v1 rules:
| Current State | Operation | Result |
|---|---|---|
UNPAID | markPaid() | PAID |
UNPAID | cancel() | CANCELLED |
PAID | markPaid() | Reject |
PAID | cancel() | Reject |
CANCELLED | markPaid() | Reject |
CANCELLED | cancel() | Reject |
এটাই complete lifecycle।
No hidden state।
No fallback transition।
Why Orders Start UNPAID
Create Order এবং Pay Order আলাদা operations।
Create Order:
validate Products
consume Inventory
persist Order
তারপর Order:
UNPAID
Payment পরে explicit endpoint দিয়ে হবে:
POST /api/v1/orders/{orderId}/pay
So Order creation কখনও শুরু হবে না:
PAID
state-এ।
Order Creation Owns Initial State
Client request-এ আমরা নিই না:
{
"status": "PAID"
}
বা:
{
"status": "CANCELLED"
}
Domain factory decides:
public static Order create(
OrderId id,
CustomerId customerId,
List<OrderItem> items,
Instant createdAt
) {
return new Order(
id,
customerId,
items,
OrderStatus.UNPAID,
createdAt
);
}
New Order-এর initial status server-controlled।
Reconstituting Existing Orders
Persistence থেকে Order load করলে status database থেকে আসে।
তাই creation আর reconstruction আলাদা হওয়া উচিত।
Example:
public static Order reconstitute(
OrderId id,
CustomerId customerId,
List<OrderItem> items,
OrderStatus status,
Instant createdAt
) {
return new Order(
id,
customerId,
items,
status,
createdAt
);
}
Difference:
create()
→ new business Order
→ always UNPAID
reconstitute()
→ existing persisted Order
→ preserve stored status
Don't Use Reconstitution as Business Mutation
Bad:
Order paidOrder =
Order.reconstitute(
order.id(),
order.customerId(),
order.items(),
OrderStatus.PAID,
order.createdAt()
);
to "pay" an Order।
That bypasses lifecycle rules।
Reconstitution exists for:
loading existing state
not:
performing business transitions
Use:
order.markPaid();
instead।
Implementing markPaid()
Our Order can define:
public void markPaid() {
if (
status !=
OrderStatus.UNPAID
) {
throw new OrderNotPayableException(
id,
status
);
}
status =
OrderStatus.PAID;
}
This ensures:
only UNPAID
→ PAID
is valid।
Why PAID → PAID Is Rejected
You might ask:
Why not make
markPaid()idempotent and allow calling it twice?
Because:
Order domain idempotency
and:
payment idempotency
are different problems।
A second markPaid() call might mean:
application tried to apply payment twice
That can be a serious bug।
So Domain rejecting:
PAID → PAID
helps surface incorrect workflow execution।
Payment Idempotency Belongs Elsewhere
Later Payment integration may need to handle:
same payment request repeated
or:
provider response retried
without charging twice।
That will use concepts such as:
provider idempotency
payment reference
payment outcome handling
But once application decides:
this successful payment should be applied
Order domain still expects a valid:
UNPAID → PAID
transition।
Implementing cancel()
Cancellation:
public void cancel() {
if (
status !=
OrderStatus.UNPAID
) {
throw new OrderNotCancellableException(
id,
status
);
}
status =
OrderStatus.CANCELLED;
}
Again:
only UNPAID
→ CANCELLED
is valid।
Why Paid Order Cannot Be Cancelled
Our v1 scope explicitly excludes:
refund workflow
Once:
Order = PAID
the operation:
cancel
would imply additional business behaviour such as:
refund payment
payment reversal
financial reconciliation
We do not have those capabilities।
Therefore:
PAID → CANCELLED
is invalid in v1।
Why Cancelled Order Cannot Be Paid
Suppose Order was:
UNPAID
then Customer cancels it।
Cancellation workflow will restore Inventory।
After that:
CANCELLED → PAID
would create a contradiction।
We could have:
stock restored
but Order later paid
That would violate our business model।
So markPaid() rejects cancelled Orders।
Why Cancelled Order Cannot Be Cancelled Again
Repeated cancellation is not valid domain transition।
Unlike Product deactivation, where:
inactive → inactive
was harmless, Order cancellation has important side effects outside the aggregate:
restore Inventory
If we treated repeated cancellation as successful without careful workflow handling, application code might restore Inventory twice।
Therefore:
CANCELLED → cancel()
must reject।
This is an important distinction।
Product Deactivation and Order Cancellation Are Different
Product deactivation:
active = false
repeated call:
still false
No compensating side effect occurs।
Order cancellation:
UNPAID → CANCELLED
is tied to:
restore Inventory
So repeated cancellation must not look like a fresh successful transition।
Domain Transition vs Workflow Side Effects
Order.cancel() only changes:
Order status
It does not:
load Inventory
restore Inventory
save repositories
Those belong to:
CancelOrderUseCase
Conceptually:
load Order
↓
check ownership
↓
order.cancel()
↓
restore Inventory
↓
persist
The Domain owns:
is cancellation valid?
The UseCase owns:
what else must happen
when cancellation succeeds?
Same Principle for Payment
Order.markPaid() does not:
call Payment Provider
Instead future flow:
PayOrderUseCase
↓
load Order
↓
check ownership/payability
↓
PaymentService
↓
confirmed success
↓
order.markPaid()
↓
persist
The Domain does not know HTTP or Payment Provider protocols।
Keep Payment Attempt State Separate
This is important।
External payment attempt can have outcomes such as:
success
failure
timeout
That does not mean we should expand OrderStatus to:
PAYMENT_FAILED
PAYMENT_TIMEOUT
PAYMENT_PENDING
Our current Order lifecycle remains:
UNPAID
PAID
CANCELLED
Payment attempt state is a different concept।
Payment Failure Does Not Change Order Status
Suppose:
Order = UNPAID
Payment Provider returns failure।
Expected:
Order stays UNPAID
Not:
PAYMENT_FAILED
because Customer may potentially attempt payment again later, depending on provider workflow।
Order has simply:
not become paid
Payment Timeout Does Not Mean Paid or Failed
Suppose external request times out।
We might not know whether provider processed the payment।
Therefore we cannot safely conclude:
PAID
or invent:
PAYMENT_FAILED OrderStatus
Timeout belongs to integration workflow reasoning।
Order lifecycle only changes when application has sufficient confirmation to call:
order.markPaid();
State Machine
Our current Order state machine is intentionally tiny:
markPaid()
┌──────────────→ PAID
│
UNPAID
│
└──────────────→ CANCELLED
cancel()
There is no transition out of:
PAID
or:
CANCELLED
Why Small State Machines Are Valuable
Every new state increases:
possible transitions
edge cases
tests
API behaviour
persistence cases
operational reasoning
If we added:
CREATED
PENDING_PAYMENT
PAYMENT_FAILED
PROCESSING
SHIPPED
COMPLETED
REFUND_PENDING
REFUNDED
without requirements, we would massively expand system complexity।
Our current application only needs three states।
Use three।
Do Not Model Future Ecommerce Features
This course is not building a full marketplace lifecycle।
We currently do not need:
SHIPPED
DELIVERED
RETURNED
REFUNDED
Those states would imply workflows we have not designed।
Avoid enum values "for later"।
Status in PostgreSQL
Schema:
status TEXT NOT NULL
with:
CONSTRAINT orders_status_valid
CHECK (
status IN (
'UNPAID',
'PAID',
'CANCELLED'
)
)
Domain:
OrderStatus
Database and application reinforce the same allowed state vocabulary।
Why Keep the Database CHECK?
JPA enum mapping already uses:
@Enumerated(
EnumType.STRING
)
But database could theoretically receive writes from:
migration scripts
manual SQL
future bugs
other persistence paths
The CHECK prevents invalid persisted values such as:
"UNKNOWN"
"FAILED"
"done"
Database CHECK Does Not Protect Transitions
Important distinction।
This constraint:
status IN (
'UNPAID',
'PAID',
'CANCELLED'
)
will happily allow:
PAID → UNPAID
because both values individually are valid।
It cannot understand application lifecycle history by itself।
Domain transition methods protect:
which state changes are valid
Database CHECK protects:
which values may be stored
Different concerns।
No Public setStatus()
Our Order should not expose:
public void setStatus(
OrderStatus status
) {
this.status = status;
}
That method destroys the lifecycle boundary।
Persistence adapter also should not mutate the Domain through arbitrary setters।
Use reconstitute() when loading stored state।
Use behaviour methods when changing business state।
No changeStatus()
This is only slightly better:
order.changeStatus(
OrderStatus.PAID
);
The caller still decides arbitrary destination state।
Prefer operation-specific behaviour:
markPaid()
cancel()
These encode meaningful transitions।
Domain Exceptions
A focused domain/application error can preserve context।
For example:
public final class
OrderNotPayableException
extends RuntimeException {
private final OrderId orderId;
private final OrderStatus status;
public OrderNotPayableException(
OrderId orderId,
OrderStatus status
) {
super(
"Order cannot be paid from status "
+ status
);
this.orderId =
orderId;
this.status =
status;
}
}
Likewise:
OrderNotCancellableException
The Domain does not attach:
HTTP 409
to the exception।
HTTP mapping comes later at the boundary।
Why Include Current State?
For debugging and application reasoning:
OrderNotPayable
status = CANCELLED
is more informative than:
"invalid order"
But public responses should still avoid leaking unnecessary internal implementation details।
HTTP Error Semantics
Conceptually:
ORDER_NOT_PAYABLE
→ 409
and:
ORDER_NOT_CANCELLABLE
→ 409
because Order exists, but its current state conflicts with requested operation।
Example:
{
"type": "https://api.liveklass.io/problems/order-not-cancellable",
"title": "Order cannot be cancelled",
"status": 409,
"detail": "The order cannot be cancelled in its current state.",
"instance": "/api/v1/orders/36a25516-2af2-4644-b55d-d0cabf389c9e/cancel",
"code": "ORDER_NOT_CANCELLABLE"
}
Again:
Domain
→ business meaning
HTTP boundary
→ status + Problem response
Ownership Is Not a Domain State Transition
Suppose Customer A tries to cancel Customer B's Order।
Order.cancel() should not know:
current authenticated Customer
The UseCase checks ownership before invoking the Domain behaviour।
Conceptually:
CancelOrderUseCase
↓
load Order
↓
ensure order.customerId == currentCustomerId
↓
order.cancel()
This keeps security context out of the Domain Entity।
Why Check Ownership Before cancel()?
Suppose an unauthorized Customer targets a paid Order।
If we call:
order.cancel();
first, we may expose:
ORDER_NOT_CANCELLABLE
about someone else's Order।
Ownership/scoping rules may intentionally return a less revealing:
ORDER_NOT_FOUND
style response।
Therefore application authorization/ownership should happen before lifecycle mutation।
Detailed access policy comes in Module 8।
State Transition and Transaction
order.cancel() only mutates the Java object:
UNPAID
→ CANCELLED
But cancellation workflow also restores Inventory।
These persistent changes must occur inside:
one transaction
Likewise:
order.markPaid()
changes persistent Order state and must eventually be saved safely।
Domain transition does not replace transaction handling।
Persistence of Status
After:
order.cancel();
application Repository persists new Order state।
Conceptually:
UPDATE orders
SET status = 'CANCELLED'
WHERE id = ?;
But UseCase does not execute this SQL directly।
Still:
UseCase
→ OrderRepository
→ persistence adapter
JPA Dirty Checking vs Repository Contract
If persistence adapter loads a managed OrderEntity and updates its status, JPA dirty checking may persist that change at flush/commit।
If the adapter maps detached Domain objects back into persistence entities, it may use a different persistence technique।
Application layer should not depend on those mechanics।
Its contract remains:
Order changed
→ OrderRepository persists it
No Generic updateStatus() Repository Method Needed
Avoid immediately creating:
orderRepository.updateStatus(
orderId,
status
);
just because only status changed।
For lifecycle mutation, loading the Order domain matters because:
transition validation
lives there।
A specialized persistence update may later become useful for concurrency reasons, but it should not bypass domain rules casually।
Concurrent State Transitions
Suppose Order is:
UNPAID
and concurrently:
Request A
→ pay
Request B
→ cancel
Both might initially read:
UNPAID
and both domain operations appear locally valid।
This is a persistence concurrency problem।
Domain state machine alone cannot guarantee that both workflows will not commit conflicting results।
Domain Validity Is Necessary, Not Sufficient
This is similar to Inventory।
Domain guarantees:
given this Order object in this state,
is this transition valid?
Database/application transaction must also protect:
what happens when multiple requests
change the same Order concurrently?
We should not pretend:
if (status == UNPAID)
alone solves cross-request races।
Do We Add @Version Now?
Not yet।
We have identified the concurrency concern, but cancellation implementation is the next lesson and payment integration comes later।
We should choose concrete Order mutation persistence semantics when implementing those workflows rather than adding speculative versioning immediately।
The important lesson here is:
state machine correctness
≠
concurrent persistence correctness
Order Status Should Not Be Derived From Payment Table
Our current Order aggregate owns:
UNPAID / PAID / CANCELLED
We do not calculate Order status dynamically from hypothetical:
payment_attempts
table because such persistence does not even exist yet।
Order lifecycle remains explicit Order state।
Order Status Should Not Be Derived From Inventory
Likewise:
Inventory restored
does not automatically mean:
Order = CANCELLED
Both operations are part of cancellation workflow and must persist consistently।
Never infer one business state from another table merely because they usually change together।
Testing the State Machine
These tests require:
no Spring
no PostgreSQL
no Repository
They are pure Domain tests।
New Order Starts UNPAID
@Test
void newOrderStartsUnpaid() {
Order order =
createOrder();
assertEquals(
OrderStatus.UNPAID,
order.status()
);
}
Unpaid Order Can Be Marked Paid
@Test
void unpaidOrderCanBeMarkedPaid() {
Order order =
createOrder();
order.markPaid();
assertEquals(
OrderStatus.PAID,
order.status()
);
}
Unpaid Order Can Be Cancelled
@Test
void unpaidOrderCanBeCancelled() {
Order order =
createOrder();
order.cancel();
assertEquals(
OrderStatus.CANCELLED,
order.status()
);
}
Paid Order Cannot Be Cancelled
@Test
void paidOrderCannotBeCancelled() {
Order order =
createOrder();
order.markPaid();
assertThrows(
OrderNotCancellableException.class,
order::cancel
);
}
State remains:
PAID
Cancelled Order Cannot Be Marked Paid
@Test
void cancelledOrderCannotBeMarkedPaid() {
Order order =
createOrder();
order.cancel();
assertThrows(
OrderNotPayableException.class,
order::markPaid
);
}
State remains:
CANCELLED
Paid Order Cannot Be Marked Paid Again
@Test
void paidOrderCannotBeMarkedPaidAgain() {
Order order =
createOrder();
order.markPaid();
assertThrows(
OrderNotPayableException.class,
order::markPaid
);
}
This helps catch repeated application of a successful payment outcome।
Cancelled Order Cannot Be Cancelled Again
@Test
void cancelledOrderCannotBeCancelledAgain() {
Order order =
createOrder();
order.cancel();
assertThrows(
OrderNotCancellableException.class,
order::cancel
);
}
This is important because cancellation eventually restores Inventory।
Reconstitution Test
Persistence reconstruction:
Order order =
Order.reconstitute(
orderId,
customerId,
items,
OrderStatus.PAID,
createdAt
);
Expected:
status = PAID
Reconstitution should not forcibly reset existing Orders to:
UNPAID
State Is Preserved Across Persistence
Integration tests should eventually verify:
create UNPAID Order
persist
reload
→ UNPAID
then after valid state transition:
markPaid
persist
reload
→ PAID
or:
cancel
persist
reload
→ CANCELLED
This proves enum/JPA/database mapping consistency।
Do Not Test Domain Through HTTP Only
If every lifecycle rule is tested only through:
POST /pay
POST /cancel
then failures are harder to isolate।
The state machine itself deserves direct unit tests because it is pure business logic।
HTTP tests later verify mapping and authorization around it।
State Transition Error Matrix
| Current | Operation | Outcome |
|---|---|---|
| UNPAID | Pay | PAID |
| UNPAID | Cancel | CANCELLED |
| PAID | Pay | ORDER_NOT_PAYABLE |
| PAID | Cancel | ORDER_NOT_CANCELLABLE |
| CANCELLED | Pay | ORDER_NOT_PAYABLE |
| CANCELLED | Cancel | ORDER_NOT_CANCELLABLE |
This matrix should remain small and explicit।
What About canPay()?
We could add:
public boolean canPay() {
return status ==
OrderStatus.UNPAID;
}
Likewise:
canCancel()
These can be useful for read/UI decisions।
But they must never replace the authoritative transition method।
Bad:
if (order.canPay()) {
order.setStatus(
PAID
);
}
Correct:
order.markPaid();
The command method must enforce the rule itself।
Why Not Expose Mutable Status?
Our Order's status field should stay:
private OrderStatus status;
with:
public OrderStatus status()
for reading։
No public setter।
Encapsulation here has real business value।
Avoid State Checks Spread Across UseCases
Bad:
if (
order.status() ==
OrderStatus.UNPAID
) {
// pay
}
inside PayOrderUseCase։
Then another UseCase may implement slightly different rules।
Prefer:
order.markPaid();
as the authoritative lifecycle check।
UseCase may still perform contextual checks such as:
ownership
or external integration requirements।
But Order owns its own state transition।
Avoid switch Everywhere
If every caller has:
switch (order.status()) {
...
}
to decide whether state can change, lifecycle rules become distributed।
Keep mutation rules inside Order।
Read/reporting code may legitimately inspect status।
But transition authority should remain centralized।
Don't Build a State Pattern Yet
We could create:
UnpaidOrderState
PaidOrderState
CancelledOrderState
with a State design pattern।
For three simple states and two transitions, that would add unnecessary classes and indirection।
An enum plus explicit methods is clear enough।
Use more elaborate patterns when lifecycle complexity actually requires them।
No Status History Table Yet
We also do not add:
order_status_history
because current requirements need current Order status, not an audit timeline।
A future audit/change requirement may justify transition history।
Not now।
No Domain Events Yet
We could imagine:
OrderPaidEvent
OrderCancelledEvent
But no requirement currently needs asynchronous consumers or domain-event processing।
Do not add events because state transitions exist।
Simple synchronous workflow is sufficient।
PAID Is Not Payment Provider State
This distinction is crucial।
External provider may have terminology such as:
authorized
captured
settled
failed
Our Order domain currently only needs:
has payment been successfully confirmed
for this Order?
If yes:
PAID
Provider-specific states stay inside integration logic unless product requirements require them in our domain।
CANCELLED Is Not Product Deactivation
Order cancellation has no effect on:
Product.active
A cancelled Order may contain Products that remain active and sellable।
Cancellation only affects:
that Order
and:
its consumed Inventory
through the cancellation workflow।
State Transition and Historical Pricing
Changing:
UNPAID → PAID
does not change:
OrderItem.unitPriceCents
Likewise:
UNPAID → CANCELLED
does not change historical purchase-time prices।
Order state and Order pricing are separate concerns।
State Transition and Order Total
Order total is still:
derived from OrderItems
Whether Order is:
UNPAID
PAID
CANCELLED
does not change:
totalCents()
Historical amount remains the amount represented by the OrderItems।
Order Lifecycle Review
A good review question:
If I only saw the
Orderclass, could I tell which lifecycle transitions are legal?
With:
markPaid()
cancel()
the answer should be:
yes
If legality is instead hidden across:
Controller
UseCase
Repository
SQL
the Domain is not protecting itself properly।
What We Deliberately Did Not Add
We did not add:
PENDING
PAYMENT_PENDING
PAYMENT_FAILED
PAYMENT_TIMEOUT
PROCESSING
SHIPPED
DELIVERED
REFUNDED
generic setStatus()
State pattern hierarchy
status history table
domain events
None are required by the current lifecycle।
Engineering Principle
The core principle:
A business status should be changed through meaningful domain behaviour, not arbitrary setters.
markPaid()andcancel()describe allowed operations;setStatus()merely exposes mutable data.
Another:
The Order aggregate owns whether a lifecycle transition is valid. The UseCase owns the surrounding workflow such as ownership checks, Inventory restoration, Repository coordination, and external Payment interaction.
And:
Keep Order lifecycle state separate from Payment attempt state. A provider failure or timeout does not automatically require a new Order status.
Summary
In this lesson, we established that:
- Every new Order starts as
UNPAID. - The only v1 Order states are
UNPAID,PAID, andCANCELLED. - The only valid transitions are
UNPAID → PAIDandUNPAID → CANCELLED. PAIDandCANCELLEDare terminal states in v1.- Order status is business state, not merely a mutable database field.
- Order does not expose a generic
setStatus()orchangeStatus()method. order.markPaid()owns the payment lifecycle transition.order.cancel()owns the cancellation lifecycle transition.- Paid Orders cannot be cancelled because refunds are outside v1 scope.
- Cancelled Orders cannot later be paid.
- Repeated
markPaid()is rejected rather than treated as payment idempotency. - Repeated cancellation is rejected because cancellation has Inventory-restoration consequences.
- Product deactivation can be idempotent while Order cancellation should not be treated the same way.
create()always produces anUNPAIDOrder.reconstitute()restores an existing persisted status without being used as a mutation mechanism.- Order domain does not know authenticated users, Repositories, Inventory, or Payment Provider APIs.
- Ownership checks belong to the application/security workflow.
- Inventory restoration belongs to
CancelOrderUseCase. - External payment calls belong to
PayOrderUseCaseandPaymentService. - Payment failure or timeout leaves the Order
UNPAID. - Payment attempt states should not be added to
OrderStatus. - PostgreSQL CHECK constraints protect valid status values but do not enforce valid state transitions.
- Domain transition methods protect lifecycle rules.
- State-machine correctness alone does not solve concurrent Pay-vs-Cancel races.
- We recognize that persistence concurrency concern without prematurely adding
@Version. - Order status changes do not alter historical
unitPriceCentsortotalCents. - A simple enum plus explicit behaviour is sufficient; no State pattern, history table, or domain-event infrastructure is needed.
Next lesson:
Order Cancellation
There we will implement:
POST /api/v1/orders/{orderId}/cancel
as a complete workflow:
authenticated CustomerId
↓
load owned Order
↓
verify cancellable state
↓
Order.cancel()
↓
restore Inventory for every OrderItem
↓
persist Order
↓
commit atomically
and we will address the important difference between restoring Inventory safely and simply setting Inventory quantities from stale values.