Enforcing Legal Order Lifecycles with the State Pattern
Model order statuses as explicit State objects, validate all 20 state-command combinations, and distinguish in-memory transition rules from persistence and concurrency safeguards.
Learning outcome
After this lesson, you can model an order lifecycle with explicit State objects, turn a transition decision table into exhaustive tests, and explain why validation must also occur at the persistence boundary. You will also be able to distinguish State from Strategy in an interview.
Intuition
An order status is not merely a label. It determines which commands are legal. A paid order may be shipped, while a cancelled order must reject every command. If callers can assign a status directly, they can create impossible histories such as Draft becoming Shipped without submission or payment.
The State pattern gives the order a current state object. The order delegates commands to that object, and only the state object can authorize a transition. The context therefore composes a state implementation rather than exposing a writable status.
A transition decision table makes the rules reviewable:
| Current state | Submit | Pay | Ship | Cancel |
|---|---|---|---|---|
| Draft | Submitted | Reject | Reject | Reject |
| Submitted | Reject | Paid | Reject | Cancelled |
| Paid | Reject | Reject | Shipped | Reject |
| Shipped | Reject | Reject | Reject | Reject |
| Cancelled | Reject | Reject | Reject | Reject |
Each cell is a test case, not just documentation. The executable example checks all 20 state-command combinations.
Deep dive
Order is the context. It owns an OrderState, exposes the state's read-only status, and delegates commands through Apply. Concrete state classes contain only transitions legal from their state. A small base class centralizes rejection, while the context-to-state relationship remains composition. The state types are private nested implementation details, and TransitionTo is private. Callers cannot pick a different state object and apply its rules to an order that is currently in another state.
Two important invariants follow:
- Every accepted command produces exactly the destination specified by the table.
- Every rejected command leaves the order unchanged.
Shipped and Cancelled are terminal states. Their implementations inherit rejection for every command, and the tests verify that terminal orders cannot escape. This is stronger than checking one likely command such as Cancel after shipment.
State and Strategy both delegate through polymorphism, but their intents differ. A pricing Strategy is an interchangeable algorithm selected to calculate a result; replacing it normally does not represent lifecycle progress. A State object represents the context's current condition, restricts available behavior, and commonly selects the next state after a legal command. If an interviewer asks whether a discount policy should transition an order to Paid, the answer is normally no: pricing policy and lifecycle state are separate responsibilities.
The example stores an order total as decimal. Decimal arithmetic is generally suitable for base-10 currency amounts because common decimal fractions can be represented without the binary approximation behavior of double. Currency systems still need an explicit rounding policy, scale, and currency code.
Failure modes
A public status setter bypasses transition validation. A giant switch in every service duplicates rules and eventually becomes inconsistent. Conversely, introducing one class per state when behavior consists of a tiny, stable table may add unnecessary ceremony; a centralized transition function can still enforce the same invariants.
State objects should not perform unrelated orchestration such as charging cards, sending email, and updating inventory in an uncontrolled sequence. Those effects belong in application services or transactional workflows. A transition should be committed only when its required operation succeeds.
An in-memory check is not sufficient under concurrency. Two workers can both load Submitted, both accept Pay, and both issue side effects. Persist a version or concurrency token and conditionally update the row using both the expected state/version and order identifier. If no row is updated, reload and resolve the conflict. The state change and an outbox record for downstream effects should normally share a transaction. A failed conditional save cannot undo a payment already sent to an external provider. Payment attempts need an idempotency key and a recovery path for an uncertain outcome; an outbox consumer must also tolerate duplicate delivery. These safeguards are separate from the in-memory State implementation.
Persistence also needs a stable representation such as a constrained status code. Rehydration should map only known values to state objects and fail on corrupt or unsupported values. Do not serialize implementation type names as a long-term storage contract. Restore is an internal trusted-persistence seam in this example, not a command endpoint: a recognized status does not prove that its history was legal. With event sourcing, replay must likewise reject or explicitly migrate histories that violate the current transition model.
Interview drill
Question: Why not use an enum and switches?
Answer: An enum can be sufficient when the model is small and centralized. State becomes valuable when state-specific behavior is substantial, switches are spreading, or transitions and terminal behavior need encapsulation. The invariant matters more than the pattern label.
Question: How would you derive tests?
Answer: Treat every table cell as a case. Accepted cells assert the exact destination. Rejected cells assert the exception and unchanged status. Add invariant-focused tests for terminal states and persistence conflicts.
Exercise: Add a Refund command that is legal only from Paid and transitions to Refunded. Make Refunded terminal.
Solution: Add OrderCommand.Refund and OrderStatus.Refunded. Add the accepted cell (Paid, Refund) -> Refunded to the transition table and dictionary. Handle Refund in PaidState.Apply by transitioning to a private nested RefundedState; map it in Restore. Let RefundedState inherit the rejection behavior. The enum-driven test now covers 6 states x 5 commands = 30 cells: 5 accepted and 25 rejected. Refund is rejected in every non-Paid state, and all 5 commands are rejected in Refunded. Add Refunded to the explicit terminal-state loop too. This exercise models permission to transition; it does not execute a financial refund.
Follow-up: Should State implementations be registered in dependency injection?
Answer: Stateless singleton states usually need no per-order allocation and can be shared. If a state needs services, inject collaborators deliberately, but do not store mutable order-specific data in a singleton state object.
Question: Is a read-only status enough to prevent bypassing the lifecycle?
Answer: No. If concrete states expose Apply(Order, command), a caller could invoke the Paid state against a Draft order and ship it. An internal transition setter is also callable from other code in the same assembly. Keep both the state types and transition mutation private inside the context. Trusted restoration is a separate boundary, and access modifiers do not provide database concurrency or authorization.
Revision checklist
- Keep status externally read-only.
- Express legal transitions in a reviewable decision table.
- Test every accepted and rejected table cell.
- Assert that rejection does not mutate the order.
- Make terminal states reject every command.
- Use optimistic concurrency for persisted transitions.
- Keep external side effects transactionally coordinated or idempotent.
- Use State for condition-dependent behavior, not merely interchangeable algorithms.
Runnable in-memory model
The runnable console example implements five singleton State objects and a complete table-driven test. It is intentionally free of external packages: failed assertions throw, while successful execution prints deterministic confirmation. Restore illustrates mapping a persisted status to a known state object; a production repository would also verify a concurrency token during save. Run the complete Program.cs in a .NET 10 console project using C# 14. No database, payment provider, outbox, or concurrent worker is exercised. The states are stateless and shared, but each Order is mutable and is not thread-safe. The illustrative total is only checked for non-negativity; currency, scale, and rounding policy belong to the monetary contract described above.
Code walkthrough
The legal-transition dictionary contains only accepted cells. Nested loops generate the full Cartesian product of statuses and commands. If a cell is present, the test asserts its destination. Otherwise, it requires InvalidOperationException and verifies that status did not change.
The happy-path assertion then demonstrates lifecycle progression from Draft through Shipped. Finally, every command is attempted against both terminal states. Private nested state types can call the context's private TransitionTo, while callers use Order.Apply, which always delegates to the current state. Additional checks reject unknown enum values and negative totals, and verify that changing one order does not change another order sharing the singleton states.
Executable code examples
Table-tested order lifecycle using State objects
Program.cs
using System;
using System.Collections.Generic;
var legalTransitions = new Dictionary<(OrderStatus, OrderCommand), OrderStatus>
{
[(OrderStatus.Draft, OrderCommand.Submit)] = OrderStatus.Submitted,
[(OrderStatus.Submitted, OrderCommand.Pay)] = OrderStatus.Paid,
[(OrderStatus.Submitted, OrderCommand.Cancel)] = OrderStatus.Cancelled,
[(OrderStatus.Paid, OrderCommand.Ship)] = OrderStatus.Shipped
};
var statuses = Enum.GetValues<OrderStatus>();
var commands = Enum.GetValues<OrderCommand>();
var checkedCases = 0;
foreach (var status in statuses)
{
foreach (var command in commands)
{
var order = Order.Restore(status, 49.95m);
var originalStatus = order.Status;
if (legalTransitions.TryGetValue((status, command), out var expected))
{
order.Apply(command);
Assert(order.Total == 49.95m, "A transition changed the total.");
Assert(order.Status == expected,
$"Expected {status} + {command} to become {expected}.");
}
else
{
AssertRejected(order, command);
Assert(order.Status == originalStatus,
$"Rejected command {command} changed {status}.");
}
checkedCases++;
}
}
var happyPath = Order.Create(49.95m);
var history = new List<OrderStatus> { happyPath.Status };
foreach (var command in new[]
{
OrderCommand.Submit,
OrderCommand.Pay,
OrderCommand.Ship
})
{
happyPath.Apply(command);
history.Add(happyPath.Status);
}
Assert(happyPath.Status == OrderStatus.Shipped, "Happy path must end in Shipped.");
foreach (var terminal in new[] { OrderStatus.Shipped, OrderStatus.Cancelled })
{
foreach (var command in commands)
{
AssertRejected(Order.Restore(terminal, 49.95m), command);
}
}
foreach (var status in statuses)
{
AssertRejected(Order.Restore(status, 49.95m), (OrderCommand)999);
}
AssertArgumentRejected(() => Order.Restore((OrderStatus)999, 49.95m), "status");
AssertArgumentRejected(() => Order.Create(-0.01m), "total");
AssertArgumentRejected(() => Order.Restore(OrderStatus.Paid, -0.01m), "total");
Assert(Order.Create(0m).Total == 0m, "A zero total must remain valid.");
var firstOrder = Order.Create(49.95m);
var secondOrder = Order.Create(49.95m);
firstOrder.Apply(OrderCommand.Submit);
Assert(secondOrder.Status == OrderStatus.Draft, "Shared states leaked order-specific data.");
Console.WriteLine($"Transition table: {checkedCases} cases passed.");
Console.WriteLine($"Happy path: {string.Join(" -> ", history)}");
Console.WriteLine("Terminal states reject every command.");
Console.WriteLine("Invalid values rejected; order instances stay independent.");
static void AssertRejected(Order order, OrderCommand command)
{
var before = order.Status;
var totalBefore = order.Total;
try
{
order.Apply(command);
throw new Exception($"Expected {before} to reject {command}.");
}
catch (InvalidOperationException)
{
Assert(order.Status == before, "A rejected command mutated the order.");
Assert(order.Total == totalBefore, "A rejected command changed the total.");
}
}
static void AssertArgumentRejected(Action action, string parameter)
{
try
{
action();
}
catch (ArgumentOutOfRangeException error)
{
Assert(error.ParamName == parameter, "The wrong argument was rejected.");
return;
}
throw new Exception($"Expected rejection of {parameter}.");
}
static void Assert(bool condition, string message)
{
if (!condition)
{
throw new Exception(message);
}
}
public enum OrderStatus
{
Draft,
Submitted,
Paid,
Shipped,
Cancelled
}
public enum OrderCommand
{
Submit,
Pay,
Ship,
Cancel
}
public sealed class Order
{
private OrderState _state;
private Order(OrderState state, decimal total)
{
if (total < 0m)
{
throw new ArgumentOutOfRangeException(nameof(total));
}
_state = state;
Total = total;
}
public OrderStatus Status => _state.Status;
public decimal Total { get; }
public static Order Create(decimal total) => new(DraftState.Instance, total);
// Trusted repository/test seam; never bind directly to a client-supplied status.
internal static Order Restore(OrderStatus status, decimal total) =>
new(status switch
{
OrderStatus.Draft => DraftState.Instance,
OrderStatus.Submitted => SubmittedState.Instance,
OrderStatus.Paid => PaidState.Instance,
OrderStatus.Shipped => ShippedState.Instance,
OrderStatus.Cancelled => CancelledState.Instance,
_ => throw new ArgumentOutOfRangeException(nameof(status))
}, total);
public void Apply(OrderCommand command) => _state.Apply(this, command);
private void TransitionTo(OrderState next) => _state = next;
private abstract class OrderState
{
public abstract OrderStatus Status { get; }
public virtual void Apply(Order order, OrderCommand command) =>
throw new InvalidOperationException(
$"Command {command} is not legal while the order is {Status}.");
}
private sealed class DraftState : OrderState
{
public static DraftState Instance { get; } = new();
private DraftState() { }
public override OrderStatus Status => OrderStatus.Draft;
public override void Apply(Order order, OrderCommand command)
{
if (command == OrderCommand.Submit)
{
order.TransitionTo(SubmittedState.Instance);
return;
}
base.Apply(order, command);
}
}
private sealed class SubmittedState : OrderState
{
public static SubmittedState Instance { get; } = new();
private SubmittedState() { }
public override OrderStatus Status => OrderStatus.Submitted;
public override void Apply(Order order, OrderCommand command)
{
switch (command)
{
case OrderCommand.Pay:
order.TransitionTo(PaidState.Instance);
return;
case OrderCommand.Cancel:
order.TransitionTo(CancelledState.Instance);
return;
default:
base.Apply(order, command);
return;
}
}
}
private sealed class PaidState : OrderState
{
public static PaidState Instance { get; } = new();
private PaidState() { }
public override OrderStatus Status => OrderStatus.Paid;
public override void Apply(Order order, OrderCommand command)
{
if (command == OrderCommand.Ship)
{
order.TransitionTo(ShippedState.Instance);
return;
}
base.Apply(order, command);
}
}
private sealed class ShippedState : OrderState
{
public static ShippedState Instance { get; } = new();
private ShippedState() { }
public override OrderStatus Status => OrderStatus.Shipped;
}
private sealed class CancelledState : OrderState
{
public static CancelledState Instance { get; } = new();
private CancelledState() { }
public override OrderStatus Status => OrderStatus.Cancelled;
}
}Expected output
Transition table: 20 cases passed. Happy path: Draft -> Submitted -> Paid -> Shipped Terminal states reject every command. Invalid values rejected; order instances stay independent.
TechInterviewNotes Editorial Team · Updated 7 Oct 2026 ·