Have you ever found yourself trapped in a labyrinth of if/else or switch statements, where each new feature request adds another layer of complexity? This is a common scenario when an object’s behavior needs to change based on its internal state. The State Design Pattern offers an elegant escape route.
Let’s explore how to transform a rigid, hard-to-maintain class into a flexible and scalable state machine.
The Problem: A State-Driven Workflow with Conditionals
Imagine a document management system. A document progresses through a workflow: it starts as a Draft, moves to Under Review, and can finally be Published. It can also be Rejected or require Revision.
A naive implementation might handle this logic within a single Document class:
public class Document {
private String state = "DRAFT"; // Initial state
public void edit(String content) {
if (state.equals("DRAFT") || state.equals("NEEDS_REVISION")) {
System.out.println("Editing the document...");
// ... logic to edit content
} else if (state.equals("UNDER_REVIEW")) {
System.out.println("ERROR: Cannot edit while under review.");
} else if (state.equals("PUBLISHED")) {
System.out.println("ERROR: Cannot edit a published document.");
} else {
System.out.println("ERROR: Action not allowed in current state.");
}
}
public void submit() {
if (state.equals("DRAFT") || state.equals("NEEDS_REVISION")) {
this.state = "UNDER_REVIEW";
System.out.println("Document submitted for review.");
} else {
System.out.println("ERROR: Cannot submit from state: " + state);
}
}
public void publish() {
if (state.equals("UNDER_REVIEW")) {
this.state = "PUBLISHED";
System.out.println("Document published successfully!");
} else if (state.equals("DRAFT")) {
System.out.println("ERROR: Cannot publish directly from draft.");
} else {
System.out.println("ERROR: Action not allowed in current state.");
}
}
// ... other methods like reject(), etc.
}
This approach quickly becomes a maintenance nightmare.
- Violates Open/Closed Principle: Adding a new state, like
Expired, requires modifying every single method in theDocumentclass. - Violates Single Responsibility Principle: The
Documentclass is responsible for both its own data and the complex state transition logic. - High Complexity: The code is hard to read and reason about. The flow of control is tangled in conditional branches.
This complex, conditional-based workflow can be visualized as a tangled web:
graph TD
A[Start Action] --> B{State?};
B -- Draft --> C[Do Draft Logic];
B -- Under Review --> D[Do Review Logic];
B -- Published --> E[Do Published Logic];
B -- ... --> F[...];
C --> G((End));
D --> G;
E --> G;
F --> G;
subgraph Legend
direction LR
L1[Every method contains this logic]
end
style B fill:#f9f,stroke:#333,stroke-width:2px
The Solution: Introducing the State Pattern
The State Pattern’s definition is key: It allows an object to alter its behavior when its internal state changes. The object will appear to change its class.
Instead of one monolithic class, we break the logic into three core components:
- The Context: The object that has a state. In our case, this is the
Documentclass. It maintains a reference to aStateobject that represents its current state. - The State Interface: This interface defines a common set of methods for all concrete states. These methods represent the actions that can be performed on the
Context. - Concrete States: These are individual classes that implement the
Stateinterface. Each class encapsulates the behavior associated with one specific state of theContext.
Here is the UML diagram representing our new structure:
classDiagram
class Document {
-ArticleState currentState
+setState(ArticleState state)
+edit(String content)
+submit()
+publish()
+reject()
}
class ArticleState {
<<interface>>
+edit(Document doc, String content)
+submit(Document doc)
+publish(Document doc)
+reject(Document doc)
}
class DraftState {
+edit(Document doc, String content)
+submit(Document doc)
+publish(Document doc)
+reject(Document doc)
}
class UnderReviewState {
+edit(Document doc, String content)
+submit(Document doc)
+publish(Document doc)
+reject(Document doc)
}
class PublishedState {
+edit(Document doc, String content)
+submit(Document doc)
+publish(Document doc)
+reject(Document doc)
}
Document o-- ArticleState
ArticleState <|.. DraftState
ArticleState <|.. UnderReviewState
ArticleState <|.. PublishedState
The “After” Scenario: Refactoring Our Code
Let’s apply this pattern to our Java code. We’ll assume a simple project structure.
src/
└── main/
└── java/
└── com/
└── example/
├── Document.java
└── states/
├── ArticleState.java
├── DraftState.java
├── UnderReviewState.java
└── PublishedState.java
1. The State Interface
First, we define the ArticleState interface. It includes all possible actions.
// src/main/java/com/example/states/ArticleState.java
package com.example.states;
import com.example.Document;
public interface ArticleState {
void edit(Document doc, String content);
void submit(Document doc);
void publish(Document doc);
void reject(Document doc);
}
2. Concrete State Implementations
Next, we create a class for each state. Each class implements the logic specific to its state and handles transitions.
DraftState: In a draft, you can edit and submit. Publishing is not allowed.
// src/main/java/com/example/states/DraftState.java
package com.example.states;
import com.example.Document;
public class DraftState implements ArticleState {
@Override
public void edit(Document doc, String content) {
doc.setContent(content);
System.out.println("Document content updated in Draft state.");
}
@Override
public void submit(Document doc) {
System.out.println("Submitting document for review...");
doc.changeState(new UnderReviewState()); // State Transition
}
@Override
public void publish(Document doc) {
System.out.println("ERROR: Cannot publish directly from Draft. Please submit for review first.");
}
@Override
public void reject(Document doc) {
System.out.println("ERROR: Cannot reject a document that is in Draft state.");
}
}
UnderReviewState: You can’t edit, but a reviewer can publish or reject it.
// src/main/java/com/example/states/UnderReviewState.java
package com.example.states;
import com.example.Document;
public class UnderReviewState implements ArticleState {
@Override
public void edit(Document doc, String content) {
System.out.println("ERROR: Cannot edit while document is under review.");
}
@Override
public void submit(Document doc) {
System.out.println("ERROR: Document is already under review.");
}
@Override
public void publish(Document doc) {
System.out.println("Publishing the document...");
doc.changeState(new PublishedState()); // State Transition
}
@Override
public void reject(Document doc) {
System.out.println("Rejecting the document. Returning to draft...");
doc.changeState(new DraftState()); // State Transition
}
}
[!NOTE] The
PublishedStatewould likely prevent all actions, as a published document is considered final in this workflow. Each method would simply print an error message.
3. The Refactored Context
Finally, the Document class becomes incredibly simple. It holds a reference to the current state and delegates all action calls to it.
// src/main/java/com/example/Document.java
package com.example;
import com.example.states.ArticleState;
import com.example.states.DraftState;
public class Document {
private ArticleState currentState;
private String content;
public Document() {
// The document starts in the Draft state
this.currentState = new DraftState();
this.content = "";
}
// This method is used by state objects to transition the context
public void changeState(ArticleState newState) {
this.currentState = newState;
}
public void setContent(String content) {
this.content = content;
}
// All actions are now delegated to the current state object
public void edit(String newContent) {
currentState.edit(this, newContent);
}
public void submit() {
currentState.submit(this);
}
public void publish() {
currentState.publish(this);
}
public void reject() {
currentState.reject(this);
}
}
The tangled if/else logic is gone. The Document class no longer knows or cares about the specific rules of each state. It simply trusts its currentState object to handle the request appropriately.
The Real Power: Adding a New State
Let’s add the Expired state requested by the business. An article can expire if it’s Under Review or Needs Revision for too long.
Before (The Nightmare): We would have to add else if (state.equals("EXPIRED")) to every single method in the original Document class.
After (The Dream):
- Create a new
ExpiredStateclass. - Update the states that can transition to it.
Here’s how simple it is. We only need to add one new file and modify one line in another.
--- a/src/main/java/com/example/states/UnderReviewState.java
+++ b/src/main/java/com/example/states/UnderReviewState.java
@@ -26,4 +26,9 @@
System.out.println("Rejecting the document. Returning to draft...");
doc.changeState(new DraftState()); // State Transition
}
+
+ public void expire(Document doc) {
+ System.out.println("Document has expired while under review.");
+ doc.changeState(new ExpiredState());
+ }
}
We create the new state class, which likely allows no further actions.
// src/main/java/com/example/states/ExpiredState.java
package com.example.states;
import com.example.Document;
public class ExpiredState implements ArticleState {
private void showExpiredError() {
System.out.println("ERROR: This document has expired and cannot be modified.");
}
@Override
public void edit(Document doc, String content) { showExpiredError(); }
@Override
public void submit(Document doc) { showExpiredError(); }
@Override
public void publish(Document doc) { showExpiredError(); }
@Override
public void reject(Document doc) { showExpiredError(); }
}
This is the essence of the Open/Closed Principle. Our system is open for extension (adding new states) but closed for modification (we didn’t have to touch the core Document class or other unrelated states).
Best Practices & Considerations
[!TIP] Use Singletons for Stateless States: If a state object does not have any instance-specific fields (most don’t), you can implement it as a Singleton. This prevents creating redundant objects and saves memory.
Deep Dive: State vs. Strategy Pattern
The State and Strategy patterns have very similar structures, but their intent is different.
- Strategy Pattern: The client chooses the algorithm (the “strategy”) to be used. The strategies are interchangeable at runtime, but the context is not aware of why a particular strategy is chosen. Think of a
Sorterclass that can be configured with aQuickSortorMergeSortstrategy. - State Pattern: The state transitions are managed internally, either by the context or by the states themselves. The context’s behavior changes as its state changes. The focus is on managing a workflow or lifecycle.
When to Use the State Pattern
- When an object’s behavior depends heavily on its state, and it must change its behavior at runtime.
- When you have large conditional blocks (
if/elseorswitch) that select behavior based on the object’s current state. - When you want to localize state-specific behavior and partition rules for different states.
The State Pattern is a powerful tool for cleaning up complex, state-driven logic, leading to code that is more organized, maintainable, and a pleasure to work with.