State Pattern
The State pattern is a GoF behavioral pattern that groups behavior by state and lets a component delegate requests to its current state object. A document might allow editing while it is a draft, reject edits during review, and reject further submissions or approvals once published. Each state implementation then decides whether to accept the same Edit() or Approve() request.
A login-failure count, a stored variable, and the body being edited are all program state. State and its history covers the broader concept. This article explains where to group behavior when the same request needs different handling in different states.
State values, state machines, and the State pattern
A document-review feature needs to record its current stage, decide which requests are allowed there, and define what happens after each request. These are decisions about data and behavior. Whether to handle those rules with conditionals or a transition table, or group them in state-specific implementations, is a separate decision about code structure. We distinguish state values, state machines, and the State pattern to keep these decisions clear.
Term | Meaning | Example |
|---|---|---|
State value | Current information that affects the next operation | A document's |
State machine | A model of how events change state and behavior | Submitting a draft moves it into review |
State pattern | Separate implementations hold state-specific behavior, and the component delegates to its current state | The component's |
- Term
State value
- Meaning
Current information that affects the next operation
- Example
A document's
Draftvalue or the body being edited
- Term
State machine
- Meaning
A model of how events change state and behavior
- Example
Submitting a draft moves it into review
- Term
State pattern
- Meaning
Separate implementations hold state-specific behavior, and the component delegates to its current state
- Example
The component's
Submit()callsSubmit()on its current state object
A state machine can use enum + switch, a transition table, or a pure transition function. The State pattern is one way to implement such a machine with state-specific objects. Robert Nystrom's Game Programming Patterns first explains state machines and enum + switch, then discusses the GoF pattern that delegates to state-specific implementations.1
How to choose the states
Before creating state classes, find situations in which the same request needs different handling. This document-review feature separates editing from review and does not allow changes after approval. Those rules give us three states.
State | Why it is separate |
|---|---|
Draft ( | The author is still editing. The document has not been submitted for review, so editing and submission are allowed. |
In review ( | The submitted body stays fixed while a decision is pending. The document accepts approval or rejection instead of edits. |
Published ( | Approval is complete. This example does not allow further editing or review of the published document. |
- State
Draft (
Draft)- Why it is separate
The author is still editing. The document has not been submitted for review, so editing and submission are allowed.
- State
In review (
InReview)- Why it is separate
The submitted body stays fixed while a decision is pending. The document accepts approval or rejection instead of edits.
- State
Published (
Published)- Why it is separate
Approval is complete. This example does not allow further editing or review of the published document.
Two documents can have identical bodies while allowing different requests: a draft accepts edits; a document in review rejects them. The body alone cannot tell us which rule applies, so we keep track of the current stage. These three states are choices for this review feature, not a list prescribed by the GoF pattern.
Why is Rejected not a separate state? In this example, a rejected document can be edited and resubmitted just like a draft. It therefore returns to Draft and keeps the rejection reason as data. If rejected documents later need their own operations, reconsider whether they need a separate state.
Define the transitions first
Suppose the document-review feature follows these rules. This example does not support editing after publication. A rejected draft can be edited and submitted again.
Current state | Request | Result |
|---|---|---|
Draft | Edit | Change the body and remain a draft |
Draft | Submit | Move to InReview |
InReview | Approve | Move to Published |
InReview | Reject | Return to Draft with a rejection reason |
InReview | Edit or resubmit | Reject the request |
Published | Edit, submit, approve, or reject | Reject the request |
Draft | Approve or reject | Reject the request |
- Current state
Draft
- Request
Edit
- Result
Change the body and remain a draft
- Current state
Draft
- Request
Submit
- Result
Move to InReview
- Current state
InReview
- Request
Approve
- Result
Move to Published
- Current state
InReview
- Request
Reject
- Result
Return to Draft with a rejection reason
- Current state
InReview
- Request
Edit or resubmit
- Result
Reject the request
- Current state
Published
- Request
Edit, submit, approve, or reject
- Result
Reject the request
- Current state
Draft
- Request
Approve or reject
- Result
Reject the request
The arrows use the method names from the object implementation below. Edit() changes only the draft's body, so the state stays the same. Reject(reason) returns to a draft that carries the rejection reason. Requests absent from the diagram are rejected as shown in the table, and this example has no transition out of Published.
If the requirement is only to calculate transitions, the following function may be enough. It implements a state machine's transitions, not the State pattern yet. Invalid transitions and undefined enum values are rejected with an exception.
using System;
public enum ReviewStage { Draft, InReview, Published }
public enum ReviewAction { Submit, Approve, Reject }
public static class ReviewTransitions
{
public static ReviewStage Next(ReviewStage stage, ReviewAction action) =>
(stage, action) switch
{
(ReviewStage.Draft, ReviewAction.Submit) => ReviewStage.InReview,
(ReviewStage.InReview, ReviewAction.Approve) => ReviewStage.Published,
(ReviewStage.InReview, ReviewAction.Reject) => ReviewStage.Draft,
_ => throw new InvalidOperationException("This transition is not allowed.")
};
}This function calculates whether a transition is allowed and which state comes next. It does not store the body or process a rejection reason. That can be a useful starting point for a small workflow; whether classes are needed depends on the behavior that varies by state.
When the State pattern helps
There is a reason to group behavior by state when edit, submit, approve, and reject methods repeat state checks, and changing one state's rules means visiting several methods. Another sign is a growing set of fields on the component for data used only in particular states, such as a draft's rejection reason or temporary review data.
Many states may still be easy to read in one transition table, while a few states with substantial behavior and data may benefit from separate implementations. If a transition function is already short and clear, creating a file for every state can make the rules harder to follow.
The owner of the transitions must remain clear. State objects can choose the next state, or separate transition-coordination code can do it, but the same rule should not live independently in both places. In the implementation below, each state object handles its allowed requests and chooses the next state. The component holds the current state.
A document-review example in C#
ReviewDocument stores the body and current state, forwarding edit, submit, approve, and reject requests to that state object. DraftState, InReviewState, and PublishedState are private nested types, so callers cannot inject arbitrary state objects or directly replace the state. Stage is a value exposed for inspection, not another stored field used to branch on behavior.
ReviewTransitions above and the object implementation below express the same rules in different forms. They are independent implementations kept together for comparison and tests; the object implementation does not call the earlier transition function. In a real implementation, choose one owner for the transition rules instead of maintaining them separately in two places.
This diagram shows type relationships rather than transitions. Solid lines with hollow triangle heads point to the common parent, DocumentState. Each state box lists only the operations it overrides. PublishedState overrides none of these operations, so it inherits the parent's rejection behavior. Properties such as Stage and helper code are omitted.
public sealed class ReviewDocument
{
private string content;
private DocumentState state = new DraftState(null);
public string Content => content;
public ReviewStage Stage => state.Stage;
public string? RejectionReason => state.RejectionReason;
public ReviewDocument(string content)
{
this.content = RequireText(content, nameof(content));
}
public void Edit(string content) =>
state.Edit(this, RequireText(content, nameof(content)));
public void Submit() => state.Submit(this);
public void Approve() => state.Approve(this);
public void Reject(string reason) =>
state.Reject(this, RequireText(reason, nameof(reason)));
private static string RequireText(string value, string parameterName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Non-empty text is required.", parameterName);
return value;
}
private abstract class DocumentState
{
public abstract ReviewStage Stage { get; }
public virtual string? RejectionReason => null;
// Override only the operations allowed in each state. Reject all others explicitly.
public virtual void Edit(ReviewDocument document, string content) =>
throw Denied(nameof(Edit));
public virtual void Submit(ReviewDocument document) =>
throw Denied(nameof(Submit));
public virtual void Approve(ReviewDocument document) =>
throw Denied(nameof(Approve));
public virtual void Reject(ReviewDocument document, string reason) =>
throw Denied(nameof(Reject));
private InvalidOperationException Denied(string operation) =>
new($"In the {Stage} state, {operation} requests are not allowed.");
}
private sealed class DraftState : DocumentState
{
public override ReviewStage Stage => ReviewStage.Draft;
public override string? RejectionReason { get; }
public DraftState(string? rejectionReason)
{
RejectionReason = rejectionReason;
}
public override void Edit(ReviewDocument document, string content) =>
document.content = content;
public override void Submit(ReviewDocument document) =>
document.state = new InReviewState();
}
private sealed class InReviewState : DocumentState
{
public override ReviewStage Stage => ReviewStage.InReview;
public override void Approve(ReviewDocument document) =>
document.state = new PublishedState();
public override void Reject(ReviewDocument document, string reason) =>
document.state = new DraftState(reason);
}
private sealed class PublishedState : DocumentState
{
public override ReviewStage Stage => ReviewStage.Published;
}
}Who sets the initial and next states?
A new document has not been submitted, so it starts as a draft. The field initializer private DocumentState state = new DraftState(null); creates the initial state object for each document. Here, null means that the first draft has no rejection reason, not that the document has no state.
DocumentState defines the requests shared by the state implementations, while the state field refers to the one object currently handling them. Its first object is a DraftState, so the document forwards Submit() to that implementation.
The operation handling an event chooses the next state. When the caller requests Submit(), DraftState.Submit() executes document.state = new InReviewState();. Subsequent requests go to that new review state. Approval replaces it with a PublishedState in InReviewState.Approve(), while InReviewState.Reject(reason) replaces it with a DraftState carrying the reason. Time passing alone does not change the state.
There is no separate assignment to Stage. The getter Stage => state.Stage reads the current object's value, so replacing the state object also changes the reported stage. Callers cannot set state directly; they must use the permitted requests. Loading a saved document and restoring its state is outside this example.
ReviewDocument.Edit() calls the current state's Edit() rather than branching on if (Stage == Draft). Only Draft allows that operation. InReview rejects the same edit request but changes the document to Published when approval is requested. To inspect the rules for review, read that state's implementation.
The rejection reason belongs to the current draft, so editing preserves it. Resubmitting creates a new review state and discards the reason. This object does not retain review history. Recording who rejected what and when requires a separate history-storage contract.
Invalid text produces ArgumentException, while a request forbidden in the current state produces InvalidOperationException. Input-validation and rejection paths leave the body and state unchanged, and exceptions reach the caller so it can handle the failure.
Test allowed transitions and rejected requests
The tests cover the normal review flow, requests forbidden in each state, empty bodies and rejection reasons, and the rejection reason's lifetime after resubmission. They also compare transitions in the standalone function and the object implementation.
public static class Program
{
private static int checks;
public static void Main()
{
var document = new ReviewDocument("First draft");
Equal(ReviewStage.Draft, document.Stage);
Equal("First draft", document.Content);
Equal<string?>(null, document.RejectionReason);
document.Edit("Revised draft");
Equal("Revised draft", document.Content);
document.Submit();
Equal(ReviewStage.InReview, document.Stage);
document.Reject("Please add supporting evidence");
Equal(ReviewStage.Draft, document.Stage);
Equal("Please add supporting evidence", document.RejectionReason);
document.Edit("Body with supporting evidence");
Equal("Please add supporting evidence", document.RejectionReason);
document.Submit();
Equal<string?>(null, document.RejectionReason);
document.Approve();
Equal(ReviewStage.Published, document.Stage);
Equal("Body with supporting evidence", document.Content);
Expect<ArgumentException>(() => new ReviewDocument(" "));
Expect<ArgumentException>(() => new ReviewDocument(null!));
var draft = new ReviewDocument("Body");
Expect<ArgumentException>(() => draft.Edit(" "));
Equal("Body", draft.Content);
var inReview = At(ReviewStage.InReview);
Expect<ArgumentException>(() => inReview.Reject(" "));
Equal(ReviewStage.InReview, inReview.Stage);
// Check every state/request combination. Rejected requests preserve state and content.
foreach (var stage in Enum.GetValues<ReviewStage>())
{
foreach (var action in Enum.GetValues<ReviewAction>())
{
var item = At(stage);
var before = (item.Stage, item.Content, item.RejectionReason);
bool allowed = (stage, action) is
(ReviewStage.Draft, ReviewAction.Submit) or
(ReviewStage.InReview, ReviewAction.Approve) or
(ReviewStage.InReview, ReviewAction.Reject);
if (allowed)
{
var expected = ReviewTransitions.Next(stage, action);
Execute(item, action);
Equal(expected, item.Stage);
Equal("Body", item.Content);
}
else
{
Expect<InvalidOperationException>(() => ReviewTransitions.Next(stage, action));
Expect<InvalidOperationException>(() => Execute(item, action));
Equal(before, (item.Stage, item.Content, item.RejectionReason));
}
}
}
foreach (var stage in new[] { ReviewStage.InReview, ReviewStage.Published })
{
var item = At(stage);
var before = (item.Stage, item.Content, item.RejectionReason);
Expect<InvalidOperationException>(() => item.Edit("New body"));
Equal(before, (item.Stage, item.Content, item.RejectionReason));
}
Expect<InvalidOperationException>(() => ReviewTransitions.Next((ReviewStage)999, ReviewAction.Submit));
Expect<InvalidOperationException>(() => ReviewTransitions.Next(ReviewStage.Draft, (ReviewAction)999));
Console.WriteLine($"State pattern checks: {checks}");
}
private static ReviewDocument At(ReviewStage stage)
{
var item = new ReviewDocument("Body");
if (stage is ReviewStage.InReview or ReviewStage.Published) item.Submit();
if (stage is ReviewStage.Published) item.Approve();
return item;
}
private static void Execute(ReviewDocument document, ReviewAction action)
{
switch (action)
{
case ReviewAction.Submit: document.Submit(); break;
case ReviewAction.Approve: document.Approve(); break;
case ReviewAction.Reject: document.Reject("Review feedback"); break;
default: throw new ArgumentOutOfRangeException(nameof(action));
}
}
private static void Equal<T>(T expected, T actual)
{
if (!System.Collections.Generic.EqualityComparer<T>.Default.Equals(expected, actual))
throw new Exception($"Expected: {expected}, actual: {actual}");
checks++;
}
private static void Expect<TException>(Action action) where TException : Exception
{
try { action(); }
catch (TException) { checks++; return; }
throw new Exception($"Expected a {typeof(TException).Name} exception.");
}
}State and Strategy
Strategy Pattern replaces one way of doing a job with another. Choosing PDF or HTML as a document's export format is easy to describe as a strategy. Treating edit, submit, and approve requests differently according to whether the document is a draft or has been published is a state-modeling problem. Grouping that behavior into implementations for each state and delegating to them is the State pattern.
State and Strategy can work together. When exporting a published document, the current state can decide whether export is permitted, while the selected format strategy performs the conversion. There is no need to create every combination, such as DraftPdfState, DraftHtmlState, PublishedPdfState, and PublishedHtmlState. If the two dimensions vary independently, keep and compose them separately.
Do not rely on the shortcut "Strategy changes from outside; State changes from inside." An external approval request can trigger a state transition, and a component can choose a strategy based on data size. The distinction is whether the design groups behavior for the current mode or makes alternatives for the same role interchangeable.
Decisions needed in production
State does not grant permission
The rule that a document in review can be approved is separate from whether the current caller is authorized to approve it. This example does not implement authentication or authorization. A real service must pass requests through a trusted authorization boundary. Neither the name Approve() nor a state object proves the caller's permission.
An object transition is not a database transaction
This example is an in-memory object used by one execution flow. It provides no synchronization for concurrent changes from multiple threads or servers. A real repository must handle conflicts with mechanisms such as updates that check the expected version and current state, affected-row checks, and transactions. Replacing a state field with objects does not remove race conditions.
Changing state and sending a notification can also succeed independently of one another. Define how to distinguish and recover from failures in storage updates and external effects. Placing a network call inside a state object does not make those operations atomic.
State-specific data and lifetime
Each draft state in this implementation can carry a rejection reason. Sharing such a state object between documents could mix their data, so these objects are not shared. Sharing an implementation with no request-specific data may be worth considering, but the name "state" is not a reason to make every implementation a singleton.
If entering and leaving states requires opening and closing resources, define the failure order too. For example, preparation of the next state might fail after the previous state has already been released. The reference assignments in this example do not implement resource transitions.
Choosing an approach
What the current code needs | Start with |
|---|---|
Display the current state | A state value and a display-label mapping |
Check only allowed transitions | A transition function or table |
Keep state-specific behavior together across several operations | The State pattern |
Replace one algorithm with an alternative | Strategy |
Run a sequence of steps in order | A pipeline or explicit workflow |
- What the current code needs
Display the current state
- Start with
A state value and a display-label mapping
- What the current code needs
Check only allowed transitions
- Start with
A transition function or table
- What the current code needs
Keep state-specific behavior together across several operations
- Start with
The State pattern
- What the current code needs
Replace one algorithm with an alternative
- Start with
Strategy
- What the current code needs
Run a sequence of steps in order
- Start with
A pipeline or explicit workflow
Rather than turning a small state machine into an object hierarchy by default, ask whether changing one state's rules lets you inspect the relevant behavior and data in one place. The State pattern groups that behavior and data by state.
History and references
The GoF book Design Patterns classifies State as a behavioral pattern. The publisher lists October 31, 1994 as its publication date. That date is not the origin of the concept of state or of state machines.2
Related reading: Programming Patterns
For .NET 8 SDK (C# 12), combine the three C# blocks in order in one file.
Footnotes
- Robert Nystrom. Game Programming Patterns, State, author's text. The distinction between state machines,
enum + switch, and the State pattern that delegates to state objects. β© - Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides. Design Patterns: Elements of Reusable Object-Oriented Software, State. Addison-Wesley, 1994. Publisher's listing. β©