Domain Modeling

Modeling Order State

ReadingPreview

আপনি একটি 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 StateOperationResult
UNPAIDmarkPaid()PAID
UNPAIDcancel()CANCELLED
PAIDmarkPaid()Reject
PAIDcancel()Reject
CANCELLEDmarkPaid()Reject
CANCELLEDcancel()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:

PayOrderUseCase already checked UNPAID, so markPaid() 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:

  • OrderStatus represents a business lifecycle, not merely a database field.
  • The v1 Order states are UNPAID, PAID, and CANCELLED.
  • New Orders always begin as UNPAID.
  • The valid transitions are UNPAID → PAID and UNPAID → CANCELLED.
  • PAID and CANCELLED are terminal states for the current requirements.
  • Public setStatus() should not exist for normal application workflows.
  • markPaid() and cancel() 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, not Order.cancel().
  • External Payment Provider communication belongs to PaymentService, not Order.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.