Module 1 · Policies, State, and Invariants · Lesson 2 of 2
Model Order State Transitions and Protect Invariants
Watch
Behavior follows the lifecycle
An order cannot be shipped before it is paid. A shipped order cannot be cancelled as though nothing happened; it may require a separate returns process. These are lifecycle rules. State makes operations depend on the current state and encodes which transitions are legal. A Strategy is normally chosen to perform a calculation; a State represents where an entity is in its lifecycle. Similar class diagrams do not mean identical intent.
Write the transition table first
Use a deliberately small order model: Pending, Paid, Shipped, and Cancelled. In this example, cancellation is allowed only while the order is Pending, before it enters Paid. A real shop may allow paid cancellation with a refund, but that requires additional rules and must not be implied by this exercise.
| Current state | Command | New state | Outcome |
|---|---|---|---|
| Pending | Pay | Paid | Record the accepted payment reference |
| Pending | Cancel | Cancelled | Release any reservation |
| Paid | Ship | Shipped | Record shipment reference |
| Pending | Ship | Pending | Reject: payment required |
| Shipped | Cancel | Shipped | Reject: use a separate returns flow |
| Cancelled | Pay | Cancelled | Reject: order is closed |
Every rejected command leaves the state unchanged. Constructor defaults, deserialization, and database updates must preserve this same rule. A public setter that lets callers assign Shipped bypasses the transition model.
Start with the smallest implementation
An enum plus a method that validates the current state is often enough for four states and a few transitions. Use separate State objects when each state has substantial distinct behavior or the lifecycle keeps growing. Avoid creating a class for every value merely to match a pattern diagram.
Keep side effects visible. Validating Pay does not prove that a payment provider accepted money. In this lesson, Pay records an already confirmed, accepted payment; it does not initiate payment with a provider. Starting an asynchronous payment process is a different workflow and usually needs states such as PaymentPending and explicit failure handling.
Concurrency changes the problem
Suppose Pay and Cancel both read Pending. If each writes its preferred new state without a concurrency condition, both may report success. The final row hides one action. Protect the update with a version check or an equivalent database condition. Exactly one command should win; the other must reread state and apply the business rule again.
A State object alone does not provide transaction isolation, idempotency, or durable event delivery. If an accepted transition must publish an event, an outbox stored in the same transaction can make that intention durable. Consumers still need duplicate handling because delivery can be repeated.
Exercise and worked solution
A learner proposes a Retry command that sets every order back to Pending. Explain why that is dangerous.
Solution: It can revive a cancelled order or turn a shipped order into an apparently unpaid one. Retry should repeat a specific failed operation under explicit preconditions, not reset the lifecycle. Define the failed operation, retain its history, and permit only transitions that preserve payment and shipment facts.
Now test this sequence: Pending → Pay → Paid → Ship → Shipped → Cancel. The final Cancel must fail and the order must stay Shipped. Also test Pending → Cancel → Cancelled → Pay; the Pay must fail without changing the order.
Interview checklist
- Can you enumerate valid and invalid transitions?
- Do rejected operations preserve the original state?
- Can callers bypass the rules through setters or persistence?
- What happens when two commands race?
- Which operations are safe to retry, and how are duplicates recognized?
- Are external side effects and recovery states explicit?
Use Strategy when an algorithm varies. Use State when allowed behavior changes with lifecycle position. In both cases, the business invariant is more important than the pattern name.
When a confirmed payment loses a race
If Cancel wins the conditional save, a losing Pay must reload and revalidate against Cancelled. Preserve the winning cancellation and the external payment evidence. The failed local save does not undo a payment that already happened: route the mismatch to an explicitly designed reconciliation workflow, without overwriting Cancelled or inventing an automatic refund.
State analogy: A parcel counter with controlled stamps
Picture an order card at a parcel counter. A Pending card may receive a Paid stamp only when the clerk has an already confirmed payment receipt. A Paid card may receive a Shipped stamp when a valid shipment reference is recorded. While the card is still Pending, the clerk may instead stamp Cancelled and release the reservation. Each stamp changes which next actions are allowed. If a required reference is missing, the clerk rejects the action without altering the card or preparing a dispatch notice.
The counter analogy explains transition rules and evidence. It does not make a payment, guarantee a database transaction, resolve two clerks racing, or ensure that a notification is delivered only once. Those need their own mechanisms.
State cheat sheet
- Pending + Pay → Paid: record an already confirmed, accepted payment reference. Pay in this small model does not initiate a payment with a provider.
- Pending + Cancel → Cancelled: release the reservation. Cancellation is allowed only while the order is Pending, before it enters Paid, in this lesson.
- Paid + Ship → Shipped: record a valid shipment reference. Paid cancellation or refunds require an explicitly designed extension.
- Reject all other transitions, including Pending + Ship, Shipped + Cancel, and Cancelled + Pay. Rejection leaves the whole aggregate and its pending events unchanged.
- Validate required evidence before mutation. Protect construction, deserialization, imports, and database reconstruction as well as normal command methods; do not offer public status setters that bypass the rules.
- For a small stable lifecycle, an enum and a clear centralized guarded transition method can be sufficient. State objects become useful when meaningful state-specific behavior grows.
- Payment initiation is a different workflow: model in-flight requests and correlated success, failure, and uncertain outcomes explicitly, for example with PaymentPending.
- Use expected-version persistence or an equivalent concurrency mechanism. After a conflict, reread and revalidate; refreshing a token is not permission to replay a stale transition.
- Preserve completed states, references, and history during recovery. Retry the failed operation under its own preconditions. Match duplicate command identities to original request details and stored outcomes.
- State objects do not supply transactions, command idempotency, or reliable publication. Commit an outbox intention atomically with the transition when needed, and make consumers duplicate-safe. External effects require explicit reconciliation if local persistence fails.
Sources and conceptual video
- Microsoft Learn: Design validations in the domain model layer
- Microsoft Learn: Handling Concurrency Conflicts
Marco Lenzo: The State Design Pattern EXPLAINED with examples (on GitHub repo) is an optional conceptual supplement. The creator’s companion article was published on March 6, 2024. Its Java order-lifecycle example uses different transition rules; it is not C# code to copy. Keep this lesson’s Pending-only cancellation and confirmed-payment contract. The article’s atomic-property remark does not establish aggregate transactions or distributed concurrency safety. Reviewed through the companion article; video playback and Java version were not verified.