State Pattern: Replacing Conditionals with State Objects
How to let an object change its behavior when its internal state changes, by replacing sprawling status conditionals with a family of state objects.
State Pattern
"Allow an object to alter its behavior when its internal state changes. The object will appear to change its class." Most systems have entities that move through a lifecycle — an Order goes PLACED → PAID → SHIPPED → DELIVERED, a Connection goes CLOSED → CONNECTING → OPEN. The naive implementation stores the lifecycle as an enum or string field and branches on it everywhere. The State pattern converts each lifecycle stage into its own object that owns the behavior and the legal transitions for that stage.
This guide pairs naturally with Mediator Pattern, which also centralizes logic that would otherwise be scattered — but across peer objects rather than across time.
1. The Problem: Status Fields and Conditional Sprawl
Consider an order-management system. Every operation — pay(), ship(), cancel(), refund() — needs to know the order's current status to decide what's legal, and every operation duplicates the same branching logic.
// VIOLATION: status is a plain enum; every method re-implements the state machine
enum OrderStatus { PLACED, PAID, SHIPPED, DELIVERED, CANCELLED }
class Order {
private OrderStatus status = OrderStatus.PLACED;
private double amount;
void pay() {
if (status == OrderStatus.PLACED) {
// charge card...
status = OrderStatus.PAID;
} else if (status == OrderStatus.CANCELLED) {
throw new IllegalStateException("Cannot pay a cancelled order");
} else {
throw new IllegalStateException("Order already paid or beyond payment stage");
}
}
void ship() {
if (status == OrderStatus.PAID) {
// dispatch to courier...
status = OrderStatus.SHIPPED;
} else if (status == OrderStatus.PLACED) {
throw new IllegalStateException("Cannot ship an unpaid order");
} else if (status == OrderStatus.CANCELLED) {
throw new IllegalStateException("Cannot ship a cancelled order");
} else {
throw new IllegalStateException("Order already shipped or delivered");
}
}
void cancel() {
if (status == OrderStatus.PLACED || status == OrderStatus.PAID) {
// refund if paid...
status = OrderStatus.CANCELLED;
} else {
throw new IllegalStateException("Cannot cancel a shipped or delivered order");
}
}
void deliver() {
if (status == OrderStatus.SHIPPED) {
status = OrderStatus.DELIVERED;
} else {
throw new IllegalStateException("Order must be shipped before delivery");
}
}
}Every new status — say, RETURNED or ON_HOLD — means editing pay(), ship(), cancel(), and deliver() simultaneously, each risking a missed branch. This is the same shape of problem the SOLID Principles guide calls out under Open/Closed: a type-keyed conditional that grows without bound violates OCP because every new case means editing tested code instead of adding new code.
The tell-tale signal for a State pattern opportunity: the same enum/status field is switched on in three or more unrelated methods, and the set of legal transitions differs by method. If only one method branches on the field, a plain conditional is often still fine.
2. Structure
The State pattern has three participants:
- State — an interface declaring one method per context operation (
pay(),ship(),cancel(),deliver()). - Concrete States — one class per lifecycle stage, implementing only the transitions legal from that stage; illegal ones throw or no-op.
- Context (
Order) — holds a reference to the current state object and delegates every operation to it. The context never branches on "what state am I in" — the state object it's currently holding is the answer.
Two design choices recur in real implementations:
- Who triggers the transition? Either the concrete state calls
context.transitionTo(new PaidState())itself (state-driven — the state "knows" what comes next), or the context decides after the state validates the operation (context-driven). State-driven is more idiomatic GoF and keeps the context genuinely dumb. - Are states stateless singletons or per-context instances? If a concrete state holds no instance fields (the common case), a single shared instance (an enum constant, or a static final field) is enough and avoids allocation on every transition.
3. Full Java Implementation
// The State interface — one method per operation the context supports
interface OrderState {
OrderState pay(OrderContext context);
OrderState ship(OrderContext context);
OrderState cancel(OrderContext context);
OrderState deliver(OrderContext context);
String name();
}
// Base class supplies the "illegal transition" default so concrete
// states only override what's actually legal from that stage.
abstract class BaseOrderState implements OrderState {
public OrderState pay(OrderContext context) { throw illegal("pay"); }
public OrderState ship(OrderContext context) { throw illegal("ship"); }
public OrderState cancel(OrderContext context) { throw illegal("cancel"); }
public OrderState deliver(OrderContext context) { throw illegal("deliver"); }
private IllegalStateException illegal(String op) {
return new IllegalStateException(op + "() is not valid from state " + name());
}
}
class PlacedState extends BaseOrderState {
public OrderState pay(OrderContext context) {
context.chargeCard();
return new PaidState();
}
public OrderState cancel(OrderContext context) {
return new CancelledState();
}
public String name() { return "PLACED"; }
}
class PaidState extends BaseOrderState {
public OrderState ship(OrderContext context) {
context.dispatchToCourier();
return new ShippedState();
}
public OrderState cancel(OrderContext context) {
context.refund();
return new CancelledState();
}
public String name() { return "PAID"; }
}
class ShippedState extends BaseOrderState {
public OrderState deliver(OrderContext context) {
return new DeliveredState();
}
// Note: no cancel() override here — cancelling a shipped order is
// illegal, and BaseOrderState.cancel() already throws for us.
public String name() { return "SHIPPED"; }
}
class DeliveredState extends BaseOrderState {
public String name() { return "DELIVERED"; } // terminal state — nothing overridden
}
class CancelledState extends BaseOrderState {
public String name() { return "CANCELLED"; } // terminal state
}
// The Context — delegates every call to the current state and swaps
// itself out for whatever state the operation returns.
class OrderContext {
private OrderState state = new PlacedState();
private final double amount;
OrderContext(double amount) { this.amount = amount; }
void pay() { state = state.pay(this); }
void ship() { state = state.ship(this); }
void cancel() { state = state.cancel(this); }
void deliver() { state = state.deliver(this); }
String currentState() { return state.name(); }
// Side-effecting operations the states invoke on the context
void chargeCard() { System.out.println("Charging $" + amount); }
void dispatchToCourier() { System.out.println("Dispatching order to courier"); }
void refund() { System.out.println("Refunding $" + amount); }
}// Usage — no conditional on status anywhere in client code
OrderContext order = new OrderContext(149.99);
order.pay(); // PLACED -> PAID
order.ship(); // PAID -> SHIPPED
order.deliver(); // SHIPPED -> DELIVERED
System.out.println(order.currentState()); // DELIVERED
order.cancel(); // throws IllegalStateException: cancel() is not valid from state DELIVEREDAdding a RETURNED state now means writing one new class and wiring the one or two states that can transition into it — no existing state class is touched, satisfying OCP the same way DiscountStrategy did in the SOLID Principles guide.
Returning the next OrderState from each transition method (rather than mutating a field inside the state) keeps state objects immutable and side-effect-free with respect to transition logic — the context is the only mutable thing. This makes states trivially shareable as singletons.
4. Finite State Machines as the State Pattern
A finite state machine (FSM) is the formal model; the State pattern is one concrete way to implement it in an object-oriented language. Every FSM has:
- A finite set of states → concrete State classes.
- A finite set of events/inputs → the methods on the State interface.
- A transition function
(state, event) → state→ each method's return value. - Optionally, entry/exit actions → code at the top/bottom of each transition method (
chargeCard(),refund()).
For state machines with many states and a regular transition table (traffic lights, TCP connection states, game character states), some teams prefer a table-driven FSM — a Map<State, Map<Event, State>> — over one class per state, because the full transition table is visible in one place rather than scattered across files. The trade-off: the State pattern lets each state carry behavior, not just a transition target (e.g. PaidState.cancel() triggers a refund side effect that a plain lookup table can't express without an extra action-dispatch layer).
For comparison, here's the same state machine as a table-driven implementation instead of one class per state — useful when you want the full transition table visible in a single place and states carry no meaningful behavior beyond "what comes next":
// Table-driven alternative: a single map replaces five classes.
// Trade-off: legal transitions are visible at a glance, but per-transition
// side effects (refund, dispatch) need a separate action map or become
// awkward lambdas embedded in the table itself.
class TableDrivenOrder {
enum Status { PLACED, PAID, SHIPPED, DELIVERED, CANCELLED }
enum Event { PAY, SHIP, CANCEL, DELIVER }
private static final Map<Status, Map<Event, Status>> TRANSITIONS = Map.of(
Status.PLACED, Map.of(Event.PAY, Status.PAID, Event.CANCEL, Status.CANCELLED),
Status.PAID, Map.of(Event.SHIP, Status.SHIPPED, Event.CANCEL, Status.CANCELLED),
Status.SHIPPED, Map.of(Event.DELIVER, Status.DELIVERED),
Status.DELIVERED, Map.of(),
Status.CANCELLED, Map.of()
);
private Status status = Status.PLACED;
void fire(Event event) {
Map<Event, Status> legal = TRANSITIONS.get(status);
Status next = legal.get(event);
if (next == null) {
throw new IllegalStateException(event + " is not valid from " + status);
}
status = next;
}
Status getStatus() { return status; }
}Both implementations enforce the same legal-transition rules; the choice is really about where you want complexity to live — spread across small classes (State pattern) or concentrated in one data structure plus a side-action dispatcher (table-driven FSM).
5. When to Use vs. When It's Overkill
| Use State when... | It's overkill when... |
|---|---|
| An object has 3+ statuses, each with a different set of legal operations | There are only 2 states (effectively a boolean) — an if/else is clearer |
| The same status field is branched on in multiple methods | Only one method ever checks the status |
Transitions have distinct side effects per state (refund only from PAID, not PLACED) | All states behave identically except for one trivial flag check |
| You want the compiler/IDE to help you find all handling for a given state (one class = one place) | The state machine is unlikely to ever grow new states |
| Illegal transitions should be a clear, structural error rather than a silently-skipped branch | A quick script or one-off batch job — the ceremony isn't worth it |
6. State vs. Strategy
State and Strategy have identical UML structure — a context holding a reference to an interface, with interchangeable implementations — which is why they're so often confused. The difference is entirely about intent and who controls the swap:
| State | Strategy | |
|---|---|---|
| Who changes the active implementation? | The state objects themselves, as part of handling a request (self-aware transitions) | The client, chosen once upfront or swapped explicitly by the caller |
| Do implementations know about each other? | Often yes — PaidState explicitly returns new ShippedState() | No — PremiumDiscount has no idea VipDiscount exists |
| Purpose | Model a lifecycle / finite state machine | Make one algorithm's implementation swappable |
| Typical trigger | An operation on the context (ship()) causes a transition | The client picks the algorithm before calling the operation |
// Strategy: the CLIENT decides which implementation to use, and it doesn't change itself
DiscountStrategy strategy = customer.isVip() ? new VipDiscount() : new RegularDiscount();
double price = strategy.apply(amount); // strategy never swaps itself for another strategy
// State: the STATE decides what comes next, as a side effect of handling the call
OrderState next = state.pay(context); // PlacedState hands control to a PaidState it creates itselfA useful heuristic: if you find yourself asking "what happens after this operation, does the object's behavior change for future calls?" — that's State. If the object's behavior for this one call is simply parameterized by which implementation was injected, and nothing about future calls changes as a result, that's Strategy.
7. Testing States in Isolation
A concrete benefit of the State pattern that rarely gets mentioned: each state becomes independently unit-testable without constructing the whole lifecycle first.
class PaidStateTest {
@Test
void shipTransitionsToShippedAndDispatchesCourier() {
OrderContext order = new OrderContext(100.0);
order.pay(); // drive it to PAID first, or construct a context already in PAID for speed
order.ship();
assertEquals("SHIPPED", order.currentState());
}
@Test
void cancelFromPaidTriggersRefundAndMovesToCancelled() {
OrderContext order = new OrderContext(100.0);
order.pay();
order.cancel();
assertEquals("CANCELLED", order.currentState());
// in a real test, verify refund() was actually invoked via a spy/mock OrderContext
}
@Test
void deliverFromPaidThrowsBecauseOrderHasNotShippedYet() {
OrderContext order = new OrderContext(100.0);
order.pay();
assertThrows(IllegalStateException.class, order::deliver);
}
}Because illegal transitions throw a specific exception rather than silently no-op-ing, tests can assert both the legal transition graph and the illegal one — which is much harder to do systematically against a monolithic switch statement, where an untested branch simply falls through to a default case (or worse, no default at all).
A useful test-suite technique: generate a test for every (state, event) pair in the transition table, asserting either the expected next state or an IllegalStateException. This turns the state diagram itself into a test coverage checklist — if a cell in the table has no test, the state machine has an unverified transition.
8. Common Pitfalls
| Pitfall | Why it happens | Fix |
|---|---|---|
| State explosion — 20+ states with a dense transition graph become as hard to navigate as the conditional they replaced | Every lifecycle stage variant (e.g. PAID_PARTIAL, PAID_FULL) gets its own class even when the difference is a single flag | Consider whether some "states" are really just data on a smaller set of states, not distinct behavioral states |
| Mutable shared singleton states | A state class declared with instance fields is reused as a shared singleton across multiple contexts, and one context's transition corrupts another's | Keep concrete states stateless (no instance fields) if sharing instances; otherwise create a fresh instance per context |
| Context and state disagreeing on legality | The state object allows a transition but the context's own data doesn't support it yet (e.g. ship() succeeds structurally but trackingNumber was never set) | Validate domain invariants inside the transition method, before returning the next state, not just "is this transition structurally legal" |
| Leaking the concrete state type to clients | Client code does if (order.getState() instanceof PaidState) — reintroducing the exact conditional-on-type problem State was meant to remove | Expose only context.currentStateName() or domain queries like context.isCancellable(), never the state object itself |
| Forgetting terminal states need no overrides | Writing empty override methods for DeliveredState.pay() etc. that just rethrow, duplicating the base class's default | Let the abstract base class's default "illegal transition" behavior do the work — terminal states should have almost no code |
9. Real-World Examples
- TCP connection state machine —
CLOSED,LISTEN,SYN_SENT,ESTABLISHED,FIN_WAIT, etc. Each state accepts a different set of segments and produces different transitions; textbook State pattern territory, often implemented as a table-driven FSM in kernels for performance. - Order/shipment lifecycles in e-commerce backends (as above) — Stripe, Shopify, and most order-management systems model payment and fulfillment status this way internally, even when the public API exposes a flat status string.
javax.faces.lifecycle/ JSF request lifecycle and workflow engines (Camunda, Temporal, AWS Step Functions) — a workflow "state" determines which activities are legal next, which is State pattern at a distributed-systems scale.- Media player UI —
Playing,Paused,Stopped,Bufferingstates each interpret the same "press play" button differently. - Game character states —
Idle,Running,Jumping,Attacking— a very common State pattern teaching example because the behavior difference per state is visually obvious.
Interview Questions
- Walk through refactoring a
switch(status)block scattered across five methods into the State pattern. What signals told you it was the right refactor? - What is the structural difference between State and Strategy? If they look identical in a UML diagram, how do you tell them apart from the code alone?
- Where should the transition logic live — inside the concrete state (
return new PaidState()) or inside the context after asking the state "is this legal"? What are the trade-offs of each? - How would you implement a finite state machine for a traffic light — one class per state, or a table-driven transition map? When would you pick one over the other?
- How do you prevent an object from ending up in an "impossible" state (e.g.
SHIPPEDwith nopaidAttimestamp) when the state and the context's data fields are stored separately? - Are State pattern instances safe to share as singletons across requests? What has to be true about a concrete state class for that to be safe?
- How does the State pattern relate to the Open/Closed Principle when a new lifecycle stage is added?
- Compare a
Statepattern implementation to modeling status purely as an enum with aMap<Status, Set<Status>>legal-transitions table. When would you prefer the simpler map?