State Pattern: Behavior Based on State
Every developer has written code like this at some point:
Key Insights
- The State pattern eliminates sprawling conditional logic by encapsulating state-specific behavior in dedicated classes, making each state’s rules explicit and independently testable.
- State transitions can be controlled by either the context or the states themselves—context-controlled transitions offer centralized logic, while state-controlled transitions keep related behavior cohesive.
- Reserve the full State pattern for objects with complex, behavior-rich states; simple status tracking with minimal behavior differences is better served by enums and switch statements.
The Problem with Conditional State Logic
Every developer has written code like this at some point:
public void handleRequest(Request request) {
if (status == Status.PENDING) {
if (request.getType() == RequestType.APPROVE) {
// 20 lines of approval logic
status = Status.APPROVED;
} else if (request.getType() == RequestType.REJECT) {
// 15 lines of rejection logic
status = Status.REJECTED;
} else if (request.getType() == RequestType.CANCEL) {
// 10 lines of cancellation logic
status = Status.CANCELLED;
}
} else if (status == Status.APPROVED) {
if (request.getType() == RequestType.PUBLISH) {
// more logic...
}
// and on it goes...
}
}
This pattern metastasizes. Each new state multiplies the conditional branches. Each new operation requires touching every state check. The logic for a single state scatters across multiple methods, making it nearly impossible to reason about what an object can do in any given state.
The State pattern addresses this directly: encapsulate state-specific behavior in separate classes, and delegate to the current state object. The context object’s behavior changes by swapping which state object it holds, not by checking flags and branching.
Pattern Structure and Components
The State pattern consists of three core participants:
// Structure Overview:
//
// ┌─────────────┐ ┌──────────────────┐
// │ Context │────────>│ State (interface) │
// │─────────────│ │──────────────────│
// │ -state │ │ +handle() │
// │ +request() │ └──────────────────┘
// └─────────────┘ △
// │
// ┌─────────────┼─────────────┐
// │ │ │
// ┌───────┴───┐ ┌──────┴────┐ ┌─────┴─────┐
// │ ConcreteA │ │ ConcreteB │ │ ConcreteC │
// └───────────┘ └───────────┘ └───────────┘
Context holds a reference to the current state and delegates state-dependent operations. It provides the interface clients interact with and may expose methods for states to trigger transitions.
State interface declares the operations that vary by state. All concrete states implement this interface, ensuring the context can treat them uniformly.
Concrete states implement state-specific behavior. Each class represents one state and contains all the logic relevant to that state—no more, no less.
Classic Implementation: Document Workflow
Consider a document that moves through Draft, Review, and Published states. Each state handles operations differently:
public interface DocumentState {
void edit(Document doc, String content);
void submit(Document doc);
void publish(Document doc);
void reject(Document doc);
}
public class DraftState implements DocumentState {
@Override
public void edit(Document doc, String content) {
doc.setContent(content);
System.out.println("Document edited in draft.");
}
@Override
public void submit(Document doc) {
doc.setState(new ReviewState());
System.out.println("Document submitted for review.");
}
@Override
public void publish(Document doc) {
System.out.println("Cannot publish a draft. Submit for review first.");
}
@Override
public void reject(Document doc) {
System.out.println("Cannot reject a draft.");
}
}
public class ReviewState implements DocumentState {
@Override
public void edit(Document doc, String content) {
System.out.println("Cannot edit during review. Reject to return to draft.");
}
@Override
public void submit(Document doc) {
System.out.println("Already in review.");
}
@Override
public void publish(Document doc) {
doc.setState(new PublishedState());
System.out.println("Document published.");
}
@Override
public void reject(Document doc) {
doc.setState(new DraftState());
System.out.println("Document rejected. Returned to draft.");
}
}
public class PublishedState implements DocumentState {
@Override
public void edit(Document doc, String content) {
System.out.println("Cannot edit published document.");
}
@Override
public void submit(Document doc) {
System.out.println("Already published.");
}
@Override
public void publish(Document doc) {
System.out.println("Already published.");
}
@Override
public void reject(Document doc) {
System.out.println("Cannot reject published document.");
}
}
public class Document {
private DocumentState state;
private String content;
public Document() {
this.state = new DraftState();
}
public void setState(DocumentState state) {
this.state = state;
}
public void setContent(String content) {
this.content = content;
}
public void edit(String content) { state.edit(this, content); }
public void submit() { state.submit(this); }
public void publish() { state.publish(this); }
public void reject() { state.reject(this); }
}
Each state class is focused and cohesive. Adding a new state means creating one new class without modifying existing states. Adding a new operation requires adding a method to the interface and implementing it in each state—the compiler enforces completeness.
State Transitions: Who Owns the Logic?
The document example uses state-controlled transitions: each state decides what the next state should be. This keeps transition logic close to the behavior that triggers it.
Alternatively, context-controlled transitions centralize the logic:
// State-controlled (states trigger their own transitions)
public class ReviewState implements DocumentState {
@Override
public void publish(Document doc) {
doc.setState(new PublishedState()); // State decides next state
}
}
// Context-controlled (context manages all transitions)
public class Document {
private DocumentState state;
public void publish() {
if (state.canPublish()) {
state.onPublish(this);
transitionTo(new PublishedState()); // Context decides
}
}
private void transitionTo(DocumentState newState) {
this.state.onExit(this);
this.state = newState;
this.state.onEnter(this);
}
}
State-controlled works well when transitions are straightforward and tightly coupled to the triggering action. It keeps related code together.
Context-controlled shines when transitions involve complex conditions, when you need entry/exit hooks, or when the same transition logic applies across multiple states. It also makes the overall state machine easier to visualize since all transitions flow through one place.
Choose based on where the complexity lies. If states have intricate internal behavior but simple transitions, let states own it. If transitions involve coordination or validation spanning multiple states, centralize in the context.
Real-World Application: TCP Connection States
TCP connections demonstrate the State pattern naturally. A connection responds to the same events (open, close, acknowledge) differently depending on its current state:
public interface TcpState {
void open(TcpConnection conn);
void close(TcpConnection conn);
void acknowledge(TcpConnection conn);
}
public class ClosedState implements TcpState {
@Override
public void open(TcpConnection conn) {
conn.sendSyn();
conn.setState(new ListeningState());
}
@Override
public void close(TcpConnection conn) {
// Already closed, no-op
}
@Override
public void acknowledge(TcpConnection conn) {
// Unexpected in closed state, ignore or log
}
}
public class ListeningState implements TcpState {
@Override
public void open(TcpConnection conn) {
// Already opening, no-op
}
@Override
public void close(TcpConnection conn) {
conn.setState(new ClosedState());
}
@Override
public void acknowledge(TcpConnection conn) {
conn.setState(new EstablishedState());
}
}
public class EstablishedState implements TcpState {
@Override
public void open(TcpConnection conn) {
// Already open
}
@Override
public void close(TcpConnection conn) {
conn.sendFin();
conn.setState(new ClosingState());
}
@Override
public void acknowledge(TcpConnection conn) {
conn.processData();
}
}
public class TcpConnection {
private TcpState state = new ClosedState();
public void setState(TcpState state) { this.state = state; }
public void open() { state.open(this); }
public void close() { state.close(this); }
public void acknowledge() { state.acknowledge(this); }
void sendSyn() { /* ... */ }
void sendFin() { /* ... */ }
void processData() { /* ... */ }
}
This maps cleanly to the actual TCP state machine. Each state handles the same events but with completely different semantics. The pattern makes the protocol’s behavior explicit and testable.
State Pattern vs. Alternatives
Not every stateful object needs the full pattern. Consider the alternatives:
// Simple enum approach—good for status tracking with minimal behavior
public class Order {
public enum Status { PENDING, CONFIRMED, SHIPPED, DELIVERED }
private Status status = Status.PENDING;
public void ship() {
switch (status) {
case CONFIRMED:
this.status = Status.SHIPPED;
notifyCustomer();
break;
case PENDING:
throw new IllegalStateException("Confirm order first");
default:
throw new IllegalStateException("Cannot ship in " + status);
}
}
}
// Full State pattern—better when behavior is complex and varies significantly
public class Order {
private OrderState state = new PendingState();
public void ship() { state.ship(this); }
// Each state class handles ship() with its own logic
}
Use enums and switches when:
- States primarily affect validation, not behavior
- Operations are simple one-liners per state
- You have 2-3 states with 2-3 operations
- Performance is critical (no object allocation)
Use the State pattern when:
- Each state has substantial, distinct behavior
- You’re adding states or operations frequently
- States need to maintain their own data
- You want to test states in isolation
State tables and formal FSM libraries offer another alternative for complex state machines with many states and transitions. They trade code for configuration but can be harder to debug.
Practical Guidelines and Pitfalls
Signs you need the State pattern:
- Switch statements on a status field appear in multiple methods
- You’re adding comments like “// only valid in state X”
- Bug fixes require tracing through nested conditionals
- New states require changes across many files
Common mistakes to avoid:
State explosion: If you find yourself creating dozens of state classes for fine-grained distinctions, reconsider. Perhaps some states can be combined with internal flags, or the problem needs a different approach entirely.
Circular dependencies: States that create instances of other states can create tight coupling. Consider using a state factory or having the context provide state instances.
Forgetting the interface: Don’t let states accumulate methods not in the interface. If a method only makes sense in one state, it probably belongs in the context or shouldn’t exist.
Testing strategies:
Test each state class in isolation by instantiating it directly and calling methods with a mock context. Verify the correct behavior occurs and the expected state transition is triggered.
@Test
void draftState_submit_transitionsToReview() {
Document mockDoc = mock(Document.class);
DraftState draft = new DraftState();
draft.submit(mockDoc);
verify(mockDoc).setState(any(ReviewState.class));
}
Test the context for correct delegation and that the overall workflow behaves correctly through state sequences.
The State pattern transforms implicit, scattered state logic into explicit, cohesive classes. When your objects have rich, state-dependent behavior that’s becoming hard to follow, the pattern provides a clear structure. When you just need to track status, keep it simple. The goal is always maintainable code—choose the tool that gets you there.