Implementing Business Workflows

Order State Transitions

ReadingPreview

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

CurrentOperationOutcome
UNPAIDPayPAID
UNPAIDCancelCANCELLED
PAIDPayORDER_NOT_PAYABLE
PAIDCancelORDER_NOT_CANCELLABLE
CANCELLEDPayORDER_NOT_PAYABLE
CANCELLEDCancelORDER_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 Order class, 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() and cancel() 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, and CANCELLED.
  • The only valid transitions are UNPAID → PAID and UNPAID → CANCELLED.
  • PAID and CANCELLED are terminal states in v1.
  • Order status is business state, not merely a mutable database field.
  • Order does not expose a generic setStatus() or changeStatus() 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 an UNPAID Order.
  • 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 PayOrderUseCase and PaymentService.
  • 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 unitPriceCents or totalCents.
  • 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.