Domain Modeling
Modeling Order State
আপনি একটি free preview lesson দেখছেন।
আগের lesson-এ আমরা Order এবং OrderItem model করেছি।
একটি নতুন Order তৈরি হলে:
status = UNPAID
তারপর আমাদের current business requirements অনুযায়ী Order-এর lifecycle খুব ছোট:
┌──────→ PAID
│
UNPAID ──────┤
│
└──────→ CANCELLED
এটাই v1।
আমাদের system বর্তমানে support করে না:
PAID → CANCELLED
CANCELLED → PAID
PAID → UNPAID
CANCELLED → UNPAID
এবং আমরা এখন কোনো additional state invent করব না।
এই lesson-এর goal:
Order state-কে এমনভাবে model করা যাতে valid transitions domain-এর মাধ্যমে হয় এবং Handler, UseCase, Repository, বা অন্য caller arbitrary status mutation করতে না পারে।
State Is More Than a Field
একটি weak model হতে পারে:
public class Order {
private OrderStatus status;
public void setStatus(
OrderStatus status
) {
this.status = status;
}
}
Technically Order-এর status আছে।
কিন্তু business lifecycle protect হচ্ছে না।
Caller করতে পারে:
order.setStatus(
OrderStatus.PAID
);
without payment।
তারপর:
order.setStatus(
OrderStatus.CANCELLED
);
এবং শেষে:
order.setStatus(
OrderStatus.UNPAID
);
Domain কিছুই prevent করছে না।
তাহলে OrderStatus শুধু data হয়ে গেল।
আমাদের দরকার:
state
+
transition rules
Our Order States
Current enum:
public enum OrderStatus {
UNPAID,
PAID,
CANCELLED
}
প্রতিটি state-এর meaning:
UNPAID
Order successfully created হয়েছে, Inventory consumed হয়েছে, কিন্তু successful payment এখনও confirm হয়নি।
এই state থেকে Order:
can be paid
can be cancelled
PAID
Payment successfully confirmed হয়েছে।
Current v1 অনুযায়ী:
cannot be cancelled
Paid cancellation/refund out of scope।
CANCELLED
Eligible unpaid Order cancelled হয়েছে।
Its Inventory should have been restored by the cancellation workflow।
This is a terminal state for our current model।
New Orders Always Start UNPAID
Normal business creation should not allow caller to choose:
PAID
or:
CANCELLED
So this is undesirable:
new Order(
orderId,
customerId,
items,
requestedStatus
);
for new Order creation।
Instead:
this.status =
OrderStatus.UNPAID;
should be established by the Order creation path।
This protects the rule:
A newly created Order has not already been paid or cancelled.
State Changes Should Express Business Intent
Avoid:
order.setStatus(
OrderStatus.PAID
);
Prefer:
order.markPaid();
Avoid:
order.setStatus(
OrderStatus.CANCELLED
);
Prefer:
order.cancel();
The difference is important।
setStatus(...) says:
Replace a field.
markPaid() says:
Perform the business transition that means this Order has been successfully paid.
cancel() says:
Attempt to cancel this Order according to Order lifecycle rules.
Modeling markPaid()
Our rule:
UNPAID → PAID
is valid।
Anything else is invalid।
Conceptually:
public void markPaid() {
if (
status !=
OrderStatus.UNPAID
) {
throw new IllegalStateException(
"Only unpaid orders can be marked as paid"
);
}
status = OrderStatus.PAID;
}
Now:
order.markPaid();
can only succeed from the correct state।
Why Should Order Protect This?
Could PayOrderUseCase check:
if (
order.status() !=
OrderStatus.UNPAID
) {
...
}
and then:
order.setStatus(
OrderStatus.PAID
);
Technically yes।
But then every caller capable of changing Order state must remember the same rule।
Better:
order.markPaid();
The Entity owns:
its current status
its valid local transition
So it should protect the transition।
Modeling cancel()
Current rule:
Only unpaid Orders can be cancelled.
So:
public void cancel() {
if (
status !=
OrderStatus.UNPAID
) {
throw new IllegalStateException(
"Only unpaid orders can be cancelled"
);
}
status =
OrderStatus.CANCELLED;
}
This gives us:
UNPAID
↓
CANCELLED
and rejects:
PAID
↓
CANCELLED
as well as repeated cancellation।
Should Calling cancel() Twice Succeed?
We previously established:
already cancelled Order
cannot be cancelled again
So:
order.cancel();
order.cancel();
should not silently succeed twice।
Why?
Because cancellation is not only a local field change।
The complete cancellation workflow also restores Inventory।
If repeated cancellation were casually treated as success, a poorly implemented UseCase could risk restoring Inventory twice।
Therefore repeated cancellation should be treated as an invalid business operation।
Domain Rule and Workflow Side Effect
Important distinction:
Order.cancel() does:
Order lifecycle transition
It does not do:
Inventory restoration
Complete cancellation is:
CancelOrderUseCase
↓
load Order
↓
verify ownership
↓
order.cancel()
↓
restore Inventory
↓
persist changes
The Order protects whether cancellation is allowed।
The UseCase coordinates everything that cancellation affects outside Order।
Why Not Restore Inventory Inside Order.cancel()?
Bad:
public void cancel(
InventoryRepository repository
) {
status = CANCELLED;
// restore inventory
}
Now Order depends on persistence and Inventory capability।
Or:
public void cancel(
List<Inventory> inventories
) {
// mutate all inventory
}
Now Order owns state outside its aggregate responsibility।
Better:
Order.cancel()
→ protects Order state
CancelOrderUseCase
→ coordinates Order + Inventory
Payment Has the Same Separation
Order.markPaid() should not call:
Payment Provider
Correct flow:
PayOrderHandler
↓
PayOrderUseCase
↓
Order
UseCase first determines whether payment operation is allowed।
Then:
PayOrderUseCase
↓
PaymentService
↓
External Payment Provider
On confirmed successful payment:
PaymentResult
↓
PayOrderUseCase
↓
order.markPaid()
External payment execution and Order lifecycle remain separate responsibilities।
Do Not Mark Order Paid Before Provider Success
Bad:
order.markPaid()
↓
call provider
Suppose provider then fails।
Now local Order says:
PAID
while no payment succeeded।
Wrong ordering।
Conceptually:
verify payment eligibility
↓
call PaymentService
↓
confirmed success
↓
order.markPaid()
The exact transaction/failure strategy becomes important later in Payment integration।
Payment Failure Does Not Cancel Order
Our accepted behaviour:
payment failure
→ Order remains UNPAID
Likewise:
payment timeout
does not automatically mean:
Order = CANCELLED
Why?
Because:
payment outcome uncertainty
and:
order cancellation
are different concepts।
Payment State Is Not OrderStatus
Do not extend OrderStatus into:
public enum OrderStatus {
UNPAID,
PAYMENT_PENDING,
PAYMENT_FAILED,
PAYMENT_TIMEOUT,
PAID,
CANCELLED
}
merely because payment attempts have states।
Our design explicitly separates:
Order lifecycle
from:
Payment attempt state
Order currently needs:
UNPAID
PAID
CANCELLED
Payment integration may later need its own concepts such as:
attempt started
provider success
provider failure
outcome unknown
Those should not automatically become Order lifecycle states।
Why Keep These Concepts Separate?
Suppose payment fails three times।
Order lifecycle may still simply be:
UNPAID
while payment attempts could be:
Attempt 1 → FAILED
Attempt 2 → FAILED
Attempt 3 → TIMEOUT
Putting every attempt result inside OrderStatus would make Order lifecycle increasingly complicated and misleading।
Same Successful Payment Must Not Be Applied Twice
Our requirements say:
The same successful payment must not be applied twice.
There are two dimensions here।
Domain protection
Once:
Order = PAID
another:
order.markPaid();
must fail।
That prevents a second local lifecycle transition।
Integration/application protection
The system also needs to prevent duplicate external payment execution or duplicate processing of the same successful provider result।
That requires:
idempotency
provider references
payment workflow state
later।
Order.markPaid() alone cannot solve external payment duplication।
Domain Invariant vs Idempotency
This distinction is important।
Domain rule:
PAID Order cannot transition to PAID again.
External operation rule:
Do not charge the customer twice.
The first belongs to Order।
The second requires PayOrderUseCase + PaymentService integration design।
Do not confuse them।
A State Transition Table
Our lifecycle can be represented clearly:
| Current State | Operation | Result |
|---|---|---|
UNPAID | markPaid() | PAID |
UNPAID | cancel() | CANCELLED |
PAID | markPaid() | Reject |
PAID | cancel() | Reject |
CANCELLED | markPaid() | Reject |
CANCELLED | cancel() | Reject |
This table is small enough to reason about completely।
That is valuable।
Terminal States
For current requirements:
PAID
and:
CANCELLED
are terminal Order states।
Meaning:
no further OrderStatus transition
is currently supported।
Future requirements may change that।
For example refunds could introduce new lifecycle modelling।
But not now।
Don't Model Future Transitions Today
We don't need:
PAID → REFUND_PENDING
PAID → REFUNDED
because paid cancellation/refund is explicitly out of scope।
Likewise no:
UNPAID → EXPIRED
because Order expiry is not a current requirement।
Our model should be complete for current behaviour, not speculative future commerce behaviour।
Why enum Works Well Here
We have a small finite set of mutually exclusive states:
UNPAID
PAID
CANCELLED
An enum makes this explicit।
Better than:
boolean paid;
boolean cancelled;
because two booleans allow:
paid = true
cancelled = true
which is not a valid v1 Order state।
Illegal Boolean Combinations
With:
boolean paid;
boolean cancelled;
possible combinations:
false / false
→ UNPAID
true / false
→ PAID
false / true
→ CANCELLED
true / true
→ ???
The fourth combination should not exist।
An enum prevents that representational ambiguity։
Do We Need the State Pattern?
No।
We could create:
UnpaidOrderState
PaidOrderState
CancelledOrderState
with a full State design pattern।
But our lifecycle contains only:
3 states
2 valid transitions
An enum plus meaningful methods is much simpler and easier to maintain।
Use patterns when complexity requires them, not when a diagram looks sophisticated।
Avoid Generic transitionTo()
Another tempting abstraction:
order.transitionTo(
OrderStatus.PAID
);
This is better than a setter only if the method contains a transition matrix।
But it still loses business language।
Compare:
order.transitionTo(
OrderStatus.PAID
);
with:
order.markPaid();
The second communicates the business event that caused the transition।
Likewise:
order.cancel();
is clearer than:
order.transitionTo(
OrderStatus.CANCELLED
);
State Transition Methods Can Enforce Different Rules
Today both markPaid() and cancel() require:
status == UNPAID
But they represent different business operations।
Future rules could diverge।
For example, cancellation may later need additional domain conditions while payment does not।
Separate methods keep those behaviours independently evolvable।
A Better Order Lifecycle Model
Building on our previous Order:
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
public Order(
OrderId id,
CustomerId customerId,
List<OrderItem> items
) {
// validate identity and items
this.id = id;
this.customerId = customerId;
this.items = List.copyOf(items);
this.status =
OrderStatus.UNPAID;
}
public OrderStatus status() {
return status;
}
public void markPaid() {
requireUnpaid(
"mark as paid"
);
this.status =
OrderStatus.PAID;
}
public void cancel() {
requireUnpaid(
"cancel"
);
this.status =
OrderStatus.CANCELLED;
}
private void requireUnpaid(
String operation
) {
if (
status !=
OrderStatus.UNPAID
) {
throw new IllegalStateException(
"Cannot " +
operation +
" order in status " +
status
);
}
}
}
This reduces duplicated state guard logic while keeping separate business operations।
Should We Use a Shared requireUnpaid()?
This is reasonable because both transitions currently share exactly the same prerequisite।
But don't over-generalize into:
validateTransition(
source,
destination,
context,
flags,
...
);
for a three-state model।
Simple domain code is easier to understand।
More Explicit Alternative
We could also keep each method self-contained:
public void markPaid() {
if (
status !=
OrderStatus.UNPAID
) {
throw new IllegalStateException(
"Only unpaid orders can be paid"
);
}
status = OrderStatus.PAID;
}
public void cancel() {
if (
status !=
OrderStatus.UNPAID
) {
throw new IllegalStateException(
"Only unpaid orders can be cancelled"
);
}
status = OrderStatus.CANCELLED;
}
This repeats a few lines but may actually be clearer।
Don't remove small duplication at the cost of hiding business meaning।
Both approaches are acceptable।
Specific Domain Errors
IllegalStateException is useful while learning the model।
But the application eventually needs to distinguish expected business failures।
For example:
Order cannot be cancelled because it is PAID.
is not necessarily a programming bug।
It may be a normal business rejection।
A more explicit type could eventually be:
public class OrderNotCancellableException
extends RuntimeException {
}
Likewise:
OrderNotPayableException
could describe invalid payment eligibility।
Don't Build an Exception Hierarchy Yet
Avoid:
DomainException
↓
OrderException
↓
OrderStateException
↓
OrderTransitionException
↓
OrderCancellationException
unless the application genuinely benefits from those distinctions।
For now, focus on:
meaningful failure
rather than hierarchy sophistication।
Invalid Transition Is a Business Failure
Suppose customer tries to cancel a paid Order।
The system should not:
500 Internal Server Error
merely because Order.cancel() rejected the operation।
This is an expected business rejection।
Later the Handler/API error mapping will translate it into an appropriate client-facing response।
Domain should express the reason without knowing HTTP status codes।
Domain Does Not Throw HTTP Exceptions
Avoid:
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Paid order cannot be cancelled"
);
inside Order।
Domain should not know:
HTTP
409
REST response format
Those belong at the Handler/API boundary।
Query Methods Can Make State Intent Clear
Sometimes callers need to know state without attempting a mutation।
We already expose:
order.status();
Could we also expose:
order.isPaid();
or:
order.isUnpaid();
Possibly।
Example:
public boolean isUnpaid() {
return status ==
OrderStatus.UNPAID;
}
This can improve readability when UseCase needs to make a decision before an external operation।
Why PayOrderUseCase May Need Eligibility Before Calling Provider
Imagine:
Order = PAID
We should not call Payment Provider and only discover afterward that:
order.markPaid();
fails।
Before an external side effect, UseCase should verify that the Order is currently eligible for payment।
Conceptually:
load Order
↓
verify ownership
↓
verify unpaid/payable
↓
call PaymentService
Then provider success:
order.markPaid()
Isn't That Duplicating the Domain Check?
Potentially the eligibility is checked twice:
before provider call
and:
inside order.markPaid()
This can be justified because they protect different things।
Pre-check protects against unnecessary external side effects।
Domain method protects the Entity invariant no matter who calls it।
However, concurrency can still change state between checks।
That becomes a transaction/concurrency concern later।
Don't Remove Domain Protection Because UseCase Checks First
Bad reasoning:
PayOrderUseCasealready checked UNPAID, somarkPaid()can just assign PAID.
Another caller could bypass that UseCase।
Domain state should remain protected।
Querying Eligibility
A domain method might be:
public boolean canBePaid() {
return status ==
OrderStatus.UNPAID;
}
or UseCase could inspect:
order.status()
Given the lifecycle is currently very simple, either can work।
Prefer methods that add meaning rather than producing an API full of trivial boolean wrappers।
Cancellation Does Not Need a Pre-Side-Effect External Call
Cancellation is local:
Order state
+
Inventory state
within our application/database boundary।
A good flow:
load Order
verify ownership
order.cancel()
restore Inventory
persist transactionally
If order.cancel() fails, Inventory restoration never begins।
This ordering is deliberate।
Why Call order.cancel() Before Restoring Inventory?
Bad:
restore Inventory
↓
order.cancel()
Suppose Order is already PAID।
order.cancel() rejects।
But Inventory may already have been increased।
That would corrupt Inventory।
Better:
validate/change Order state
↓
restore Inventory
inside one local transaction।
If later persistence fails, transaction rollback should restore consistency।
Domain Mutation Before Persistence Is Fine
When we call:
order.cancel();
the Java object changes immediately।
The database has not changed yet।
That's normal।
The transaction will later persist:
Order status change
Inventory restoration
atomically।
If transaction fails, the request fails and persisted state should remain unchanged।
The in-memory object will simply be discarded with the failed operation।
Order Status Is Not Inventory State
Don't add:
CANCELLED_INVENTORY_RESTORED
to OrderStatus।
Inventory restoration belongs to the cancellation workflow transaction।
If cancellation transaction commits successfully:
Order = CANCELLED
Inventory restored
together।
No extra OrderStatus is needed to represent local implementation steps।
Order Status Is Not Deployment Workflow State Either
Avoid states like:
PERSISTING
SAVING
PROCESSING_REQUEST
These are technical process states, not business Order lifecycle states।
Domain status should reflect business meaning։
Order State and Persistence Reconstruction
New Order:
always starts UNPAID
But repository later needs to load:
PAID
and:
CANCELLED
Orders।
This means persistence reconstruction must restore existing status without pretending the Order just went through markPaid() or cancel() again।
Creation and Reconstruction Are Different
Business creation:
Order.create(...)
↓
UNPAID
Persistence reconstruction:
stored status
↓
restore existing Order
The reconstruction mechanism must still reject impossible stored state where appropriate, but it is not a business transition।
This will matter when we integrate JPA।
Do Not Expose Reconstruction as Normal Mutation
We should not solve ORM needs with:
public void setStatus(
OrderStatus status
) {
this.status = status;
}
available to every caller।
Persistence convenience should not destroy the domain boundary।
There are better reconstruction/mapping approaches we can evaluate later।
Order State and Historical Items
Lifecycle changes should not modify OrderItems।
When:
UNPAID → PAID
these remain unchanged:
ProductId
quantity
unitPrice
total
Likewise cancellation does not rewrite the Order history։
The Order still records what was originally created。
Should Cancelled Order Total Become Zero?
No।
Suppose Order originally contained:
total = 90
After cancellation, historical Order total is still:
90
because it describes the Order that was created।
Cancellation affects lifecycle, not the historical item prices।
If future financial reporting needs refund/settlement values, those are separate concepts।
Paid Order Also Keeps Same Total
Similarly:
UNPAID total = 90
then:
PAID total = 90
Payment status does not recalculate Product prices।
Order pricing remains based on captured OrderItems।
Don't Recalculate Eligibility From Current Product State
Suppose Product becomes inactive after Order creation।
Should:
order.markPaid();
check:
Product.isActive()
?
No।
Our accepted design explicitly says existing unpaid Order remains valid for payment even after Product deactivation।
Order lifecycle is now based on its own valid historical state।
Don't Recheck Current Inventory Before Payment
Inventory was consumed at successful Order creation।
Payment should not check whether current Inventory still has units available for those same items।
Doing so would misunderstand our v1 Inventory model।
Order creation already consumed the quantity।
Order Lifecycle Becomes Independent After Creation
After successful Order creation:
Product current price
Product active state
current Inventory quantity
do not determine whether the existing Order's recorded items suddenly change।
Lifecycle becomes primarily:
UNPAID
↓
PAID / CANCELLED
with external workflow conditions such as authenticated ownership and payment result coordinated by UseCases।
A Complete Conceptual Order
At this point:
public class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<OrderItem> items;
private OrderStatus status;
// construction and item validation omitted here
public OrderStatus status() {
return status;
}
public boolean belongsTo(
CustomerId customerId
) {
return this.customerId
.equals(customerId);
}
public BigDecimal total() {
return items.stream()
.map(OrderItem::total)
.reduce(
BigDecimal.ZERO,
BigDecimal::add
);
}
public void markPaid() {
if (
status !=
OrderStatus.UNPAID
) {
throw new IllegalStateException(
"Only unpaid orders can be paid"
);
}
status =
OrderStatus.PAID;
}
public void cancel() {
if (
status !=
OrderStatus.UNPAID
) {
throw new IllegalStateException(
"Only unpaid orders can be cancelled"
);
}
status =
OrderStatus.CANCELLED;
}
}
The Entity API now communicates its lifecycle directly।
Testing the Lifecycle
This is exactly the kind of behaviour that should be covered by plain domain unit tests।
No Spring।
No PostgreSQL।
No HTTP।
No Payment Provider।
Test: New Order Starts Unpaid
Conceptually:
@Test
void newOrderStartsUnpaid() {
Order order =
newOrder();
assertEquals(
OrderStatus.UNPAID,
order.status()
);
}
This protects the creation rule।
Test: Unpaid Order Can Be Paid
@Test
void unpaidOrderCanBeMarkedPaid() {
Order order =
newOrder();
order.markPaid();
assertEquals(
OrderStatus.PAID,
order.status()
);
}
Test: Unpaid Order Can Be Cancelled
@Test
void unpaidOrderCanBeCancelled() {
Order order =
newOrder();
order.cancel();
assertEquals(
OrderStatus.CANCELLED,
order.status()
);
}
Test: Paid Order Cannot Be Cancelled
@Test
void paidOrderCannotBeCancelled() {
Order order =
newOrder();
order.markPaid();
assertThrows(
IllegalStateException.class,
order::cancel
);
assertEquals(
OrderStatus.PAID,
order.status()
);
}
Notice second assertion।
After failed transition, state should remain:
PAID
Test: Cancelled Order Cannot Be Paid
@Test
void cancelledOrderCannotBePaid() {
Order order =
newOrder();
order.cancel();
assertThrows(
IllegalStateException.class,
order::markPaid
);
assertEquals(
OrderStatus.CANCELLED,
order.status()
);
}
Test: Paid Order Cannot Be Paid Again
@Test
void paidOrderCannotBeMarkedPaidAgain() {
Order order =
newOrder();
order.markPaid();
assertThrows(
IllegalStateException.class,
order::markPaid
);
}
This protects local lifecycle consistency।
Remember: external double-charge protection still requires payment idempotency later।
Test: Cancelled Order Cannot Be Cancelled Again
@Test
void cancelledOrderCannotBeCancelledAgain() {
Order order =
newOrder();
order.cancel();
assertThrows(
IllegalStateException.class,
order::cancel
);
}
This supports the cancellation workflow's Inventory correctness।
Test the Whole Transition Matrix
Given only three states, we can enumerate the lifecycle comprehensively।
Important scenarios:
UNPAID → PAID
✓
UNPAID → CANCELLED
✓
PAID → PAID
✗
PAID → CANCELLED
✗
CANCELLED → PAID
✗
CANCELLED → CANCELLED
✗
Small state machines should have complete tests。
Why Complete State Testing Is Valuable
If future engineer changes:
cancel()
they can immediately see which lifecycle guarantees break।
Order lifecycle is business-critical enough that these tests provide high value despite being simple।
Don't Assert Only Exceptions
When invalid transition occurs, also verify the original state remains unchanged where useful।
For example:
PAID
↓ cancel() fails
PAID remains
This protects against implementations that mutate before validating।
Validate Before Mutating
Bad:
public void cancel() {
status =
OrderStatus.CANCELLED;
if (previousStatus !=
OrderStatus.UNPAID) {
throw ...
}
}
State may already be corrupted when exception occurs।
Correct order:
validate
↓
mutate
Avoid Silent Invalid Transitions
Bad:
public void cancel() {
if (
status ==
OrderStatus.UNPAID
) {
status =
OrderStatus.CANCELLED;
}
}
If Order is PAID, method silently does nothing।
Caller may assume cancellation succeeded।
For business operations, explicit rejection is clearer।
Avoid Returning false Without Context by Default
Another possibility:
boolean cancelled =
order.cancel();
Returning false can work in some models, but failure reason becomes less expressive as rules grow।
For current command-style domain operation:
successful transition
or
business rejection
is usually clearer।
Exact error representation can evolve later।
Do We Need canCancel()?
Possible:
public boolean canCancel() {
return status ==
OrderStatus.UNPAID;
}
Then caller:
if (order.canCancel()) {
order.cancel();
}
But cancel() must still validate internally।
Otherwise callers can bypass the rule or race with another state change।
Use query methods for UI/decision support where useful, not as replacement for invariant protection।
Don't Add Status Mutation to Repository API
Avoid repository methods such as:
orderRepository.setStatus(
orderId,
OrderStatus.PAID
);
for normal business workflows if they bypass domain behaviour।
Prefer:
load Order
↓
order.markPaid()
↓
persist Order
unless a specific high-performance operation later justifies another approach and still preserves invariants।
Database Constraints Cannot Express the Full Lifecycle Easily
Database can enforce:
status belongs to allowed values
But expressing:
PAID can never become CANCELLED
as relational constraints is much more complicated because it depends on previous state।
The domain model is a natural place for lifecycle rules।
Persistence can reinforce structural validity, while application/domain protects transitions।
Concurrent State Changes
Suppose two requests operate on the same UNPAID Order:
Request A
→ pay
Request B
→ cancel
Both may initially load:
UNPAID
Each in-memory Order object can pass its domain rule।
Domain methods alone cannot solve this concurrency race।
We will later need transaction/persistence concurrency control so only a valid final outcome commits।
Domain Rules Are Necessary but Not Sufficient for Concurrency
This mirrors Inventory।
Domain can say:
UNPAID → PAID valid
But database/application transaction strategy must ensure two concurrent writers cannot both successfully commit incompatible transitions։
Keep these concerns separate।
Don't Add Locks to Order Domain
Avoid:
synchronized void markPaid()
as an attempt to solve database concurrency।
Multiple application instances/processes would not share the same Java object lock anyway।
Concurrency must be handled at the persistence/transaction boundary।
State Machine and Transactions Work Together
Think:
Domain
→ defines which transition is valid
Transaction/Persistence
→ ensures concurrent persisted transitions remain consistent
Neither replaces the other।
Order Lifecycle vs Request Lifecycle
Don't confuse:
Order UNPAID
with HTTP request state such as:
request pending
request succeeded
request timed out
An HTTP timeout does not automatically tell us the final business state।
This becomes particularly important with Payment Service timeouts later।
Payment Timeout Example
Suppose:
PayOrderUseCase
↓
PaymentService
↓
timeout
Can we safely call:
order.markPaid();
?
No, success was not confirmed।
Can we call:
order.cancel();
?
Also no।
Current Order may remain:
UNPAID
while payment integration resolves uncertainty according to provider semantics।
This is why technical failure states should not be mixed into OrderStatus।
State Transition Naming Should Match Business Language
Good:
markPaid()
cancel()
Weak:
updateStatus()
changeState()
transition()
Generic names push meaning back to callers।
Domain methods should make code readable without constantly inspecting enum values।
Example UseCase Readability
Weak:
order.setStatus(
OrderStatus.CANCELLED
);
Reviewer must ask:
Was this transition valid?
Better:
order.cancel();
The operation advertises its business meaning and centralizes validation।
State Modeling Should Stay Small
Current Order lifecycle implementation may be fewer than twenty meaningful lines।
That's fine।
Domain modeling quality is not measured by number of classes or patterns।
A small explicit state machine is often better than a large generic framework।
Common Mistake 1 — Public setStatus()
Any caller can bypass lifecycle rules।
Common Mistake 2 — Booleans for Independent State Flags
paid and cancelled can create impossible combinations।
Common Mistake 3 — Payment Attempt States Inside OrderStatus
External attempt lifecycle and Order lifecycle are different concepts।
Common Mistake 4 — Product State Rechecked During Payment
Existing Order remains valid independently of later Product deactivation or price changes।
Common Mistake 5 — Current Inventory Rechecked During Payment
Inventory was already consumed during Order creation।
Common Mistake 6 — Inventory Restored Inside Order.cancel()
Cross-capability coordination belongs to CancelOrderUseCase।
Common Mistake 7 — Payment Provider Called Inside Order.markPaid()
External communication belongs to PaymentService coordinated by UseCase।
Common Mistake 8 — Treating Payment Failure as Cancellation
Failure to pay does not mean customer cancelled the Order।
Common Mistake 9 — Domain State Check Used as Concurrency Strategy
In-memory validation does not prevent concurrent database updates।
Common Mistake 10 — Adding Future Commerce States
Do not add shipping/refund/expiry states before requirements exist।
Order State Review Checklist
Before changing Order lifecycle, ask:
What are the valid states?
What transitions are explicitly supported?
Which state does a new Order start in?
Can callers mutate status directly?
Does each transition have a meaningful domain method?
Does an invalid transition leave state unchanged?
Are external provider states leaking into OrderStatus?
Are cross-capability side effects incorrectly happening
inside Order?
Does the model depend on current Product or Inventory
after Order creation?
Are concurrency concerns being confused with
in-memory domain validation?
Our Current Lifecycle
The complete v1 lifecycle remains:
markPaid()
┌──────────────────→ PAID
│
│
UNPAID ───────┤
│
│ cancel()
└──────────────────→ CANCELLED
No outgoing transitions from:
PAID
or:
CANCELLED
at this stage।
Responsibility Map
Order
Owns:
current OrderStatus
valid state transitions
Provides:
markPaid()
cancel()
PayOrderUseCase
Owns coordination of:
authenticated ownership
payment eligibility
PaymentService call
successful result application
persistence
CancelOrderUseCase
Owns coordination of:
authenticated ownership
Order cancellation
Inventory restoration
persistence
PaymentService
Owns:
external provider communication
Repository
Owns:
persisting/loading Order state
and later participates in concurrency strategy।
This keeps state modelling focused।
Engineering Principle
The core principle:
Order status should change through explicit business transitions, never through unrestricted field mutation.
Another:
The domain defines which transition is valid; UseCases coordinate everything that must happen around that transition.
And:
Order lifecycle, Payment attempt lifecycle, and infrastructure failure states are separate concepts—do not collapse them into one status enum.
Summary
In this lesson, we learned that:
OrderStatusrepresents a business lifecycle, not merely a database field.- The v1 Order states are
UNPAID,PAID, andCANCELLED. - New Orders always begin as
UNPAID. - The valid transitions are
UNPAID → PAIDandUNPAID → CANCELLED. PAIDandCANCELLEDare terminal states for the current requirements.- Public
setStatus()should not exist for normal application workflows. markPaid()andcancel()express business intent and protect transition rules.- Paid Orders cannot be cancelled in v1.
- Cancelled Orders cannot be paid.
- Repeated payment-state application and repeated cancellation should be rejected by the Order lifecycle.
- Domain rejection of a second
markPaid()is not a complete payment-idempotency solution. - Payment idempotency must also be handled by the Payment workflow/integration later.
- Payment failure or timeout does not automatically cancel an Order.
- Payment attempt states should remain separate from
OrderStatus. - Cancellation Inventory restoration belongs to
CancelOrderUseCase, notOrder.cancel(). - External Payment Provider communication belongs to
PaymentService, notOrder.markPaid(). - Product deactivation or price changes after Order creation do not invalidate an existing eligible Order.
- Current Inventory should not be rechecked before payment because Inventory was consumed during Order creation.
- OrderItems and historical total remain unchanged when Order lifecycle state changes.
- Domain lifecycle tests can and should run without Spring, HTTP, PostgreSQL, or external Services.
- The complete transition matrix is small enough to test exhaustively.
- In-memory domain validation does not solve concurrent database state transitions.
- Persistence/transaction strategy will later ensure concurrent updates cannot commit incompatible Order states.
Next lesson:
Avoiding Anemic Domain Models
There we will pull together Product, Inventory, Order, and OrderItem and examine the difference between a domain model that genuinely protects business behaviour and one that is merely a collection of fields, getters, and setters controlled by giant UseCases.