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.
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.
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. 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 the command and status, add one accepted Paid/Refunded table cell, implement Refund only in PaidState, map RefundedState during rehydration, and let that terminal state reject all commands. The cross-product test should automatically require rejection in every other row.
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.
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.
Production code
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.
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. Because transitions can occur only through Order.TransitionTo, callers cannot manufacture an illegal destination through a public setter.
<!-- tin:verified-code:start -->
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.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);
}
}
Console.WriteLine($"Transition table: {checkedCases} cases passed.");
Console.WriteLine($"Happy path: {string.Join(" -> ", history)}");
Console.WriteLine("Terminal states reject every command.");
static void AssertRejected(Order order, OrderCommand command)
{
var before = order.Status;
try
{
order.Apply(command);
throw new Exception($"Expected {before} to reject {command}.");
}
catch (InvalidOperationException)
{
Assert(order.Status == before, "A rejected command mutated the order.");
}
}
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);
public 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);
internal void TransitionTo(OrderState next) => _state = next;
}
public 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}.");
}
public 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);
}
}
public 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;
}
}
}
public 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);
}
}
public sealed class ShippedState : OrderState
{
public static ShippedState Instance { get; } = new();
private ShippedState() { }
public override OrderStatus Status => OrderStatus.Shipped;
}
public sealed class CancelledState : OrderState
{
public static CancelledState Instance { get; } = new();
private CancelledState() { }
public override OrderStatus Status => OrderStatus.Cancelled;
}
<!-- tin:verified-code:end -->