By the end of this lesson you can replace a class full of state checks with one small class per state. We build a document workflow that runs Draft to Published to Archived. Then we add a fourth state without editing the three that already work.
The Problem: Behavior Changes Based on Internal State
Let’s start with a document whose state lives in a string. Every operation branches on that string before it does anything.
Read this section; don’t type it. What follows is diagnosis. We are deliberately not going to write it, and none of it belongs in your project. Our building starts under Refactoring the Example.
Here is the shape that tempts us first:
// Diagnosis only - we are NOT adding this to the project.
public class Document
{
public string State { get; private set; } = "Draft";
public void Publish()
{
if (State == "Draft")
{
Console.WriteLine("Publishing document...");
State = "Published";
}
else if (State == "Published")
{
Console.WriteLine("Document is already published.");
}
else if (State == "Archived")
{
Console.WriteLine("Cannot publish an archived document.");
}
}
public void Archive()
{
if (State == "Published")
{
Console.WriteLine("Archiving document...");
State = "Archived";
}
else
{
Console.WriteLine("Only published documents can be archived.");
}
}
}
Why it rots: every method has to answer for every state. A fourth state means we edit Publish, we edit Archive, and we edit whatever we add next. Our transitions hide inside conditionals, so no single place tells us which move is legal. We will call that shape a state-dependent conditional for the rest of this lesson.
What we want instead is one class per state, each answering only for itself and naming its successor.
What the State Pattern Is
The State pattern lets an object change its behavior when its internal state changes, by moving each state’s behavior into a class of its own.
That sentence carries the whole pattern. The object holding the current state is the context. It keeps no branching. It forwards every call to the state object it holds, and a state may hand it a different state to hold next.
State is a behavioral pattern: we change what an object does over time, not what it is.
Structure Overview
We get three participants, one job each:
- Context – holds the current state object and forwards every operation to it.
- State interface – the contract every state implements.
- Concrete States – one class per state, carrying that state’s behavior and the transitions out of it.
In the diagram, look first at the arrow running from a concrete state back to the context. That arrow is our pattern. A state answers a call, and decides who answers the next one.
Because our context talks to the contract, our states name each other only when they hand over.
Refactoring the Example
C# gives us two ways to write that contract, and they give away different things. We build both, then the version most teams settle on.
The interface version. Here is the shape filled in with our own classes:
Create IDocumentState.cs and define the contract:
public interface IDocumentState
{
void Publish(DocumentContext context);
void Archive(DocumentContext context);
}
We declare one method per operation. Each takes the context, because a state needs a way to hand the document its successor. DocumentContext does not exist yet, so our build stays red until the next block.
Create DocumentContext.cs and add the object that holds the current state:
public class DocumentContext
{
private IDocumentState _state;
public DocumentContext()
{
_state = new DraftState();
}
public void SetState(IDocumentState state)
{
_state = state;
}
public void Publish()
{
_state.Publish(this);
}
public void Archive()
{
_state.Archive(this);
}
}
We start in DraftState, we expose SetState so a state can replace itself, and we forward both operations. Notice we wrote no conditional here.
Create DraftState.cs, PublishedState.cs and ArchivedState.cs, and add our three states:
public class DraftState : IDocumentState
{
public void Publish(DocumentContext context)
{
Console.WriteLine("Publishing document...");
context.SetState(new PublishedState());
}
public void Archive(DocumentContext context)
{
Console.WriteLine("Cannot archive a draft document.");
}
}
public class PublishedState : IDocumentState
{
public void Publish(DocumentContext context)
{
Console.WriteLine("Document is already published.");
}
public void Archive(DocumentContext context)
{
Console.WriteLine("Archiving document...");
context.SetState(new ArchivedState());
}
}
public class ArchivedState : IDocumentState
{
public void Publish(DocumentContext context)
{
Console.WriteLine("Cannot publish an archived document.");
}
public void Archive(DocumentContext context)
{
Console.WriteLine("Document is already archived.");
}
}
Let’s read the three classes against each other. In DraftState we publish and hand over. In PublishedState we refuse a second publish and allow the archive. In ArchivedState we refuse both, because our workflow ends there. Every rejection we used to bury in an else if now has an owner.
The abstract class version. Count how much of that block was refusal. Six methods, four of them saying no. A base class can say no once, for everybody:
Create DocumentState.cs and add the base every state inherits:
public abstract class DocumentState
{
protected DocumentContext Context { get; private set; }
public void SetContext(DocumentContext ctx)
=> Context = ctx;
// Default: operation not allowed in this state
public virtual void Publish()
=> Log("Cannot publish in this state.");
public virtual void Archive()
=> Log("Cannot archive in this state.");
protected void Log(string msg)
=> Console.WriteLine(
$"[{GetType().Name}] {msg}");
}
We put three things here that we then stop repeating. Our virtual defaults refuse an operation. Log prints the running state’s own type name. Context gives every state a way back to its document.
We reopen DocumentContext.cs. It holds a DocumentState now, and it announces each transition so we can watch the workflow move:
public class DocumentContext
{
public DocumentState State { get; private set; }
public DocumentContext()
{
TransitionTo(new DraftState());
}
public void TransitionTo(DocumentState next)
{
Console.WriteLine($"State → {next.GetType().Name}");
State = next;
State.SetContext(this);
}
public void Publish() => State.Publish();
public void Archive() => State.Archive();
}
TransitionTo is now the only place a state changes. We print the new state, we store it, and we hand it the context. One method, so one place to add a guard or an audit line later.
We rewrite our three states. Each one overrides only what it permits:
public class DraftState : DocumentState
{
public override void Publish()
{
Log("Publishing document...");
Context.TransitionTo(new PublishedState());
}
// Archive() not overridden
// → base class logs "Cannot archive in this state."
}
public class PublishedState : DocumentState
{
public override void Archive()
{
Log("Archiving document...");
Context.TransitionTo(new ArchivedState());
}
// Publish() not overridden
// → base class logs "Cannot publish in this state."
}
public class ArchivedState : DocumentState
{
// Neither method overridden —
// both fall through to base defaults.
// This state is a dead end.
}
DraftState overrides Publish and nothing else, so archiving a draft falls through to our base refusal. PublishedState mirrors it. ArchivedState overrides nothing, and an empty class is what a dead end should look like.
The combined version. The interface gave our context and our tests a contract with no baggage. The base class gave us defaults. We can keep both:
Create DocumentStateBase.cs and put the contract and the convenience side by side:
// Contract — consumers and tests depend on this
public interface IDocumentState
{
void Publish();
void Archive();
}
// Convenience base — concrete states inherit this
public abstract class DocumentStateBase : IDocumentState
{
protected DocumentContext Context { get; private set; }
public void SetContext(DocumentContext ctx) => Context = ctx;
public virtual void Publish()
=> Log("Not allowed in this state.");
public virtual void Archive()
=> Log("Not allowed in this state.");
protected void Log(string msg)
=> Console.WriteLine($"[{GetType().Name}] {msg}");
}
Our consumers depend on IDocumentState. States that want the defaults extend DocumentStateBase. A state that already inherits elsewhere implements the interface directly and loses only the defaults.
Our context then holds the interface, and wires the back-reference when it can:
public class DocumentContext
{
public IDocumentState State { get; private set; }
public void TransitionTo(IDocumentState next)
{
State = next;
if (next is DocumentStateBase baseState)
baseState.SetContext(this);
}
public void Publish() => State.Publish();
public void Archive() => State.Archive();
}
We test for DocumentStateBase before calling SetContext, because a state implementing the interface directly has no such method. That check is what our escape hatch costs.
So which do we reach for? The combined one, in most codebases. We run the abstract version below, because it shows the fall-through in the fewest lines.
Usage
Create Program.cs and drive one document through its whole life:
var document = new DocumentContext(); document.Publish(); document.Archive(); document.Publish();
Before you read on, predict two things. How many lines do these three calls print, and which class prints the last one? Our count is not three, and the class that answers the final call never declared a method.
Output:
State → DraftState [DraftState] Publishing document... State → PublishedState [PublishedState] Archiving document... State → ArchivedState [ArchivedState] Cannot publish in this state.
Let’s walk it in order. Our constructor transitions to DraftState, so a line appears before we call anything. Our first Publish reaches DraftState, which logs and transitions to PublishedState. Our Archive reaches PublishedState, which logs and transitions to ArchivedState. Our last Publish reaches ArchivedState, which overrides nothing, so the base answers.
Six lines from three calls, and our refusal came out of a class with an empty body. That line’s [ArchivedState] tag is GetType().Name reporting the runtime type, not the class we wrote.
What Changed in the Design
Before, our state lived in a string and our behavior lived in conditionals that all had to agree. A new state meant editing every method, and we reconstructed the legal moves by reading branches.
Now each state is a class. Behavior and transition sit together, so one file answers what happens next. A new state arrives as a new class, and the states we shipped stay closed to edits. That is the Open/Closed Principle: open to extension, closed to modification.
The deeper shift is who decides. In our diagnosis version the document decided, every time, by asking itself what it was. Here the current state decides, and the document holds no opinion.
Your Turn
Review has to happen before publishing. Add a ReviewState so a draft’s Publish sends the document to review, and a second Publish sends it on to PublishedState. Archiving from review stays refused, and you should not write a line to refuse it. Work in a scratch project, not in the lesson’s solution.
Here is the stub:
public class ReviewState : DocumentState
{
public override void Publish()
{
// Log that review passed, then transition to PublishedState.
}
// Archive() stays unwritten. Why is that already correct?
}
Solution: open after you have your own answer
Our new state follows
DraftState, since both log and hand over. We then redirect the draft at review:public class ReviewState : DocumentState { public override void Publish() { Log("Review passed. Publishing document..."); Context.TransitionTo(new PublishedState()); } } public class DraftState : DocumentState { public override void Publish() { Log("Sending document for review..."); Context.TransitionTo(new ReviewState()); } }Two things are worth naming. Archiving from review needs no code, because
ReviewStateleavesArchivealone and the base refusal answers it. And we never openedPublishedStateorArchivedState, though their workflow grew a step. That is the property we were buying.
When to Use State
We reach for State when behavior turns on an internal state the object moves between itself. We reach for it when the same branching shows up in more than one method, and when we expect more states.
We leave it alone when two or three states differ only in the message they print, because an enum and a switch read shorter. We leave it alone when every state has one exit and no rules. And when our machine grows guards, timeouts and re-entrant transitions, a library such as Stateless says it better than anything we hand-roll.
State in the .NET Ecosystem
A database connection is a state machine we already use. It moves through Closed, Connecting, Open and Broken, and DbConnection exposes a State property:
if (connection.State == ConnectionState.Open)
{
// Execute queries
}
else if (connection.State == ConnectionState.Closed)
{
connection.Open();
}
Open() and Close() behave differently depending on that internal state. ADO.NET stores it as an enum, not a state object, so we get the model without the Gang of Four structure.
For bigger machines, the Stateless library gives us a declarative alternative:
var machine = new StateMachine<WorkflowState, Trigger>(WorkflowState.Draft);
machine.Configure(WorkflowState.Draft)
.Permit(Trigger.Publish, WorkflowState.Published);
machine.Configure(WorkflowState.Published)
.Permit(Trigger.Archive, WorkflowState.Archived);
Here WorkflowState is an enum, not our class. We declare our legal moves in one table instead of across classes, which pays off once the table grows.
Common Mistakes
Letting the context decide the transitions. If our DocumentContext asks which state it holds before choosing the next, we moved the conditional rather than removing it. The state decides, always.
Reaching for the pattern when only a message varies. Three states differing only in the text they print do not repay a class each.
Letting state classes grow until they repeat each other. That is our signal to introduce the base class, not to abandon the pattern.
Leaving a dead end unmarked. Nothing leaves our ArchivedState, and a reader has to see that we meant it.
If the direction of the dependency takes you a couple of passes to hold, that is normal. It tends to click the first time you add a state and touch nothing that already worked.
Trade-offs
What we gain: no state-dependent conditionals, one file per state, transitions stated out loud, and a new state that arrives as a new class.
What it costs us: a class per state, and a context that reads as indirection until you know the shape. Our legal moves are now spread across states rather than listed in one place.
State vs Similar Patterns
Before you read each answer, decide which pattern you would name.
State vs Strategy. The class diagrams are nearly identical, the intent is not. Strategy is chosen from outside and stays chosen: the client picks an algorithm, the object runs it. State is chosen from inside and keeps changing: our object transitions itself and the caller never names a state. If the caller picks, it is Strategy.
State vs Template Method. Template Method fixes an algorithm’s skeleton and lets subclasses fill in steps, settled at compile time. State swaps whole behaviors while our program runs.
State vs Command. Command turns a request into an object so we can queue, log or undo it. State turns a mode of behavior into an object so we can swap it.
Potential Interview Questions
What problem does the State pattern solve?
It removes state-dependent conditionals by giving each state a class that owns its behavior and its transitions.
How does State differ from Strategy?
The client selects a Strategy. A State transitions itself, and the caller never names one.
Who controls the transitions?
The concrete states, by handing the context its successor. A context that picks has kept the conditional we came to remove.
When is an enum with a switch the better answer?
When our states are few, unlikely to grow, and differ in details rather than in real behavior.
Interface, abstract class, or both?
The interface costs a refusal method per state. The abstract class spends the one base class a state has. Most codebases take both.
Summary
You can now move state-dependent behavior out of conditionals and into a class per state, and add a state without opening the ones you already wrote. We built one workflow three ways, and we ran the version where an empty class still answers correctly.
New terms we defined here: state-dependent conditional, context, concrete state, and the Open/Closed Principle as State satisfies it.
To practise spotting this pattern in code you have not seen, work through the recognition exercises in Let’s Practice!. The pattern readers confuse it with most is Strategy, which shares the diagram but is chosen by the caller rather than by the object itself. That lesson is the useful one to read next.



