State Pattern Best Practices in C#: Code Organization and Maintainability
You can wire up a basic state pattern implementation in an afternoon. Keeping it clean after the fifth state? After the tenth transition? That's where most state machines fall apart. State pattern best practices in C# go beyond defining an interface and a few concrete state classes -- they address how you encapsulate transitions, enforce guard conditions, handle entry and exit actions, structure state hierarchies, avoid state explosion, test state machines, log state changes, and manage thread safety.
This guide covers the practical decisions that separate a clean state machine from one that turns into an unreadable mess of conditionals. We'll walk through encapsulating transitions, guard conditions, entry and exit actions, state hierarchies, avoiding state explosion, testing, structured logging, and thread-safe state changes. If you want to understand how other behavioral patterns compare to the state pattern, the strategy pattern is a good companion read.
Encapsulate Transitions Inside States
The most critical state pattern best practice is keeping transition logic inside the state classes -- not in the context. When the context decides which state to move to, you've rebuilt a switch statement with extra steps. The whole point of the state pattern is that each state knows its own behavior, including what comes next.
Here's what happens when transition logic leaks into the context:
using System;
public interface IOrderState
{
void Process(Order order);
}
// Bad: Context decides transitions
public class Order
{
public IOrderState CurrentState { get; set; }
public void Process()
{
CurrentState.Process(this);
if (CurrentState is PendingState)
CurrentState = new ProcessingState();
else if (CurrentState is ProcessingState)
CurrentState = new ShippedState();
}
}
Every new state means editing the Order class. The corrected version lets each state handle its own transitions:
using System;
public interface IOrderState
{
void Handle(OrderContext context);
}
public class OrderContext
{
public IOrderState CurrentState { get; private set; }
public OrderContext(IOrderState initialState)
{
CurrentState = initialState
?? throw new ArgumentNullException(
nameof(initialState));
}
public void SetState(IOrderState state)
{
CurrentState = state
?? throw new ArgumentNullException(
nameof(state));
}
public void Request()
{
CurrentState.Handle(this);
}
}
// Good: Each state decides the next state
public sealed class PendingState : IOrderState
{
public void Handle(OrderContext context)
{
Console.WriteLine("Processing pending order.");
context.SetState(new ProcessingState());
}
}
public sealed class ProcessingState : IOrderState
{
public void Handle(OrderContext context)
{
Console.WriteLine("Order is being processed.");
context.SetState(new ShippedState());
}
}
public sealed class ShippedState : IOrderState
{
public void Handle(OrderContext context)
{
Console.WriteLine("Order already shipped.");
}
}
Now adding a new state means adding a new class. The context never changes. This is the same principle behind inversion of control -- the context delegates decisions to the objects that have the information needed to make them.
Use Guard Conditions to Protect Transitions
Not every state pattern transition should fire unconditionally. A document shouldn't move from "Draft" to "Published" without passing validation. A payment shouldn't transition to "Completed" on a declined response. Guard conditions enforce these rules, and the state pattern best practice is to keep them inside the state.
Here's the anti-pattern -- guard logic scattered across the caller:
using System;
// Bad: Caller responsible for checking guards
public class DocumentWorkflow
{
private IDocumentState _state;
private int _wordCount;
private bool _hasBeenReviewed;
public void Publish()
{
if (_wordCount < 100)
throw new InvalidOperationException(
"Document too short to publish.");
if (!_hasBeenReviewed)
throw new InvalidOperationException(
"Must be reviewed first.");
_state.Publish(this);
}
}
Callers can forget these checks or duplicate them inconsistently. The state should own its guards:
using System;
public interface IDocumentState
{
void Publish(DocumentContext context);
void Reject(DocumentContext context);
}
public class DocumentContext
{
public IDocumentState CurrentState { get; private set; }
public int WordCount { get; set; }
public bool HasBeenReviewed { get; set; }
public DocumentContext(IDocumentState initialState)
{
CurrentState = initialState;
}
public void SetState(IDocumentState state)
{
CurrentState = state;
}
}
// Good: Guards live inside the state
public sealed class DraftState : IDocumentState
{
public void Publish(DocumentContext context)
{
if (context.WordCount < 100)
throw new InvalidOperationException(
"Document too short to publish.");
if (!context.HasBeenReviewed)
throw new InvalidOperationException(
"Document must be reviewed first.");
context.SetState(new PublishedState());
}
public void Reject(DocumentContext context)
{
throw new InvalidOperationException(
"Cannot reject a draft.");
}
}
public sealed class PublishedState : IDocumentState
{
public void Publish(DocumentContext context)
{
throw new InvalidOperationException(
"Already published.");
}
public void Reject(DocumentContext context)
{
context.SetState(new DraftState());
}
}
Now the guards are enforced regardless of who calls Publish. The state is the single source of truth for whether a transition is valid. This keeps your state pattern implementation predictable and testable.
Implement Entry and Exit Actions
Many state pattern implementations need to perform work when entering or leaving a state -- sending notifications, starting timers, releasing resources, updating audit logs. If you scatter this logic across the transition sites, you'll end up duplicating it every time multiple paths lead to the same state.
The best state pattern practice is to define explicit entry and exit methods on your state interface:
using System;
public interface IConnectionState
{
void OnEnter(ConnectionContext context);
void OnExit(ConnectionContext context);
void Connect(ConnectionContext context);
void Disconnect(ConnectionContext context);
}
public class ConnectionContext
{
public IConnectionState CurrentState { get; private set; }
public ConnectionContext(IConnectionState initialState)
{
CurrentState = initialState;
CurrentState.OnEnter(this);
}
public void TransitionTo(IConnectionState newState)
{
CurrentState.OnExit(this);
CurrentState = newState;
CurrentState.OnEnter(this);
}
}
public sealed class DisconnectedState : IConnectionState
{
public void OnEnter(ConnectionContext context)
=> Console.WriteLine(
"Releasing resources.");
public void OnExit(ConnectionContext context)
=> Console.WriteLine(
"Preparing connection.");
public void Connect(ConnectionContext context)
=> context.TransitionTo(
new ConnectingState());
public void Disconnect(ConnectionContext context)
=> Console.WriteLine(
"Already disconnected.");
}
public sealed class ConnectingState : IConnectionState
{
public void OnEnter(ConnectionContext context)
=> Console.WriteLine(
"Starting handshake.");
public void OnExit(ConnectionContext context)
=> Console.WriteLine(
"Leaving connecting state.");
public void Connect(ConnectionContext context)
=> Console.WriteLine(
"Already in progress.");
public void Disconnect(ConnectionContext context)
=> context.TransitionTo(
new DisconnectedState());
}
The TransitionTo method on the context guarantees that exit runs on the old state and entry runs on the new state -- every time, no matter which code path triggers the transition. This eliminates the risk of forgetting to clean up resources when entering a new state.
Build State Hierarchies to Reduce Duplication
When multiple states share behavior, you end up copying methods across classes. A "Gold Member" state and a "Platinum Member" state might both allow purchases but differ on discount rates. Duplicating the purchase logic in both violates DRY.
Use an abstract base state to capture shared behavior:
using System;
public interface IMembershipState
{
decimal GetDiscount();
void Purchase(
MembershipContext context,
decimal amount);
}
public class MembershipContext
{
public IMembershipState CurrentState { get; private set; }
public decimal TotalSpent { get; set; }
public MembershipContext(IMembershipState initialState)
{
CurrentState = initialState;
}
public void SetState(IMembershipState state)
{
CurrentState = state;
}
}
// Shared behavior in base class
public abstract class ActiveMemberState
: IMembershipState
{
public abstract decimal GetDiscount();
public void Purchase(
MembershipContext context,
decimal amount)
{
decimal discounted =
amount * (1 - GetDiscount());
context.TotalSpent += discounted;
Console.WriteLine(
$"Purchased for {discounted:C}");
EvaluateUpgrade(context);
}
protected virtual void EvaluateUpgrade(
MembershipContext context)
{
}
}
public sealed class GoldMemberState
: ActiveMemberState
{
public override decimal GetDiscount() => 0.10m;
protected override void EvaluateUpgrade(
MembershipContext context)
{
if (context.TotalSpent > 5000m)
context.SetState(
new PlatinumMemberState());
}
}
public sealed class PlatinumMemberState
: ActiveMemberState
{
public override decimal GetDiscount() => 0.20m;
}
The hierarchy keeps purchase logic in one place while letting each tier define its own discount and upgrade rules. This is the same kind of structural thinking you'd apply when using the decorator pattern to layer behavior -- except here you're layering through inheritance within your state classes.
Avoid State Explosion
State explosion happens when you create a new class for every combination of conditions instead of isolating independent concerns. This is one of the most common state pattern pitfalls. If you have three connection modes and four authentication levels, creating twelve state classes is a maintenance nightmare.
The fix is separating orthogonal concerns. Let the state pattern handle one axis of variation. Handle the other axis differently -- through properties, configuration, or another pattern entirely.
Here's the explosion in action:
// Bad: Combinatorial explosion of states
public class WifiAuthenticatedActive : IConnectionState
{ /* ... */ }
public class WifiAuthenticatedIdle : IConnectionState
{ /* ... */ }
public class WifiUnauthenticatedActive : IConnectionState
{ /* ... */ }
public class WifiUnauthenticatedIdle : IConnectionState
{ /* ... */ }
public class CellularAuthenticatedActive : IConnectionState
{ /* ... */ }
public class CellularAuthenticatedIdle : IConnectionState
{ /* ... */ }
// 6 classes and counting...
Instead, separate the concerns:
using System;
// Good: State pattern handles connection
// lifecycle only
public interface IConnectionLifecycleState
{
void Activate(NetworkContext context);
void Deactivate(NetworkContext context);
}
public class NetworkContext
{
public IConnectionLifecycleState CurrentState
{ get; private set; }
// Orthogonal concerns as properties
public ConnectionType ConnectionType { get; set; }
public bool IsAuthenticated { get; set; }
public NetworkContext(
IConnectionLifecycleState initialState)
{
CurrentState = initialState;
}
public void SetState(
IConnectionLifecycleState state)
{
CurrentState = state;
}
}
public enum ConnectionType
{
Wifi,
Cellular,
Ethernet
}
public sealed class ActiveState
: IConnectionLifecycleState
{
public void Activate(NetworkContext context)
=> Console.WriteLine("Already active.");
public void Deactivate(NetworkContext context)
=> context.SetState(new IdleState());
}
public sealed class IdleState
: IConnectionLifecycleState
{
public void Activate(NetworkContext context)
{
if (!context.IsAuthenticated)
throw new InvalidOperationException(
"Must authenticate first.");
context.SetState(new ActiveState());
}
public void Deactivate(NetworkContext context)
=> Console.WriteLine("Already idle.");
}
Two state classes instead of twelve. The connection type and authentication status are tracked as data on the context rather than being baked into state class identity. This keeps your state pattern focused on the transitions that actually vary by state.
Log State Transitions for Debugging and Auditing
In production, you need visibility into what your state pattern implementation is doing. When a bug report says "the order got stuck in processing," you need logs that show exactly which transitions happened and when. The best practice is centralizing transition logging in the context rather than sprinkling Console.WriteLine calls across every state.
using System;
using Microsoft.Extensions.Logging;
public interface ITaskState
{
string Name { get; }
void Start(TaskContext context);
void Complete(TaskContext context);
void Cancel(TaskContext context);
}
public class TaskContext
{
private readonly ILogger<TaskContext> _logger;
public ITaskState CurrentState { get; private set; }
public TaskContext(
ITaskState initialState,
ILogger<TaskContext> logger)
{
_logger = logger;
CurrentState = initialState;
_logger.LogInformation(
"State machine initialized in " +
"state {State}",
CurrentState.Name);
}
public void TransitionTo(ITaskState newState)
{
string previousName = CurrentState.Name;
CurrentState = newState;
_logger.LogInformation(
"Transitioned from {FromState} " +
"to {ToState}",
previousName,
newState.Name);
}
}
public sealed class TodoState : ITaskState
{
public string Name => "Todo";
public void Start(TaskContext context)
{
context.TransitionTo(new InProgressState());
}
public void Complete(TaskContext context)
{
throw new InvalidOperationException(
"Cannot complete an unstarted task.");
}
public void Cancel(TaskContext context)
{
context.TransitionTo(new CancelledState());
}
}
// InProgressState, DoneState, CancelledState follow
// the same pattern with Name and transition logic
Using structured logging with IServiceCollection registration for your ILogger<T> instances means your state transitions show up in whatever logging infrastructure your application already uses. The Name property on each state makes log output human-readable without resorting to GetType().Name calls scattered everywhere.
Handle Thread Safety in Concurrent State Machines
If multiple threads can trigger state pattern transitions on the same context, you have a race condition waiting to happen. Two threads could both read the current state, both pass a guard condition, and both attempt a transition -- leaving the context in an inconsistent state.
The simplest state pattern best practice for thread safety is locking around the transition:
using System;
using System.Threading;
public interface IJobState
{
string Name { get; }
void Execute(ThreadSafeJobContext context);
}
public class ThreadSafeJobContext
{
private readonly Lock _lock = new();
private IJobState _currentState;
public IJobState CurrentState
{
get { lock (_lock) { return _currentState; } }
}
public ThreadSafeJobContext(IJobState initialState)
{
_currentState = initialState
?? throw new ArgumentNullException(
nameof(initialState));
}
public void SetState(IJobState newState)
{
lock (_lock)
{
_currentState = newState
?? throw new ArgumentNullException(
nameof(newState));
}
}
public void Execute()
{
IJobState snapshot;
lock (_lock)
{
snapshot = _currentState;
}
// Handler runs outside the lock so it
// can safely call SetState
snapshot.Execute(this);
}
}
The lock ensures that reading and writing the current state are atomic operations. The pattern above avoids deadlock by taking a snapshot of the current state under the lock, releasing the lock, and then calling the handler. The handler can safely call SetState, which acquires its own lock.
For high-throughput scenarios where lock contention is a concern, consider using Interlocked.CompareExchange with state references for lock-free state pattern transitions. But start with a simple lock -- premature optimization of state machine concurrency is rarely worth the complexity.
Test State Machines Thoroughly
Testing a state pattern implementation means verifying three things: valid transitions produce the correct next state, invalid transitions throw or are rejected, and guard conditions are enforced. The state pattern best practice for testing is to test each state class in isolation and then write a smaller set of integration tests for end-to-end flows.
Here's what focused state pattern tests look like:
using System;
using Xunit;
public class DraftStateTests
{
[Fact]
public void Publish_WithValidDocument_TransitionsToPublished()
{
// Arrange
var context = new DocumentContext(
new DraftState())
{
WordCount = 500,
HasBeenReviewed = true
};
// Act
context.CurrentState.Publish(context);
// Assert
Assert.IsType<PublishedState>(
context.CurrentState);
}
[Fact]
public void Publish_WithShortDocument_ThrowsException()
{
// Arrange
var context = new DocumentContext(
new DraftState())
{
WordCount = 50,
HasBeenReviewed = true
};
// Act & Assert
Assert.Throws<InvalidOperationException>(
() => context.CurrentState.Publish(context));
}
[Fact]
public void Reject_InDraftState_ThrowsException()
{
// Arrange
var context = new DocumentContext(
new DraftState());
// Act & Assert
Assert.Throws<InvalidOperationException>(
() => context.CurrentState.Reject(context));
}
}
Each test targets one state and one action. When a state pattern test fails, you know exactly which state and which transition broke. If a state needs external services, use interfaces and provide test doubles. Following adapter pattern principles for wrapping external dependencies behind interfaces makes your states easy to test in isolation.
Register States with Dependency Injection
When state pattern states need dependencies -- repositories, loggers, external services -- you should avoid newing them up inside other states. Instead, use factories or dependency injection containers to create state instances with their dependencies resolved automatically.
using System;
using Microsoft.Extensions.DependencyInjection;
public interface IStateFactory
{
T Create<T>() where T : IOrderState;
}
public class ServiceProviderStateFactory
: IStateFactory
{
private readonly IServiceProvider _provider;
public ServiceProviderStateFactory(
IServiceProvider provider)
{
_provider = provider;
}
public T Create<T>() where T : IOrderState
{
return _provider
.GetRequiredService<T>();
}
}
// Registration
public static class StateRegistration
{
public static IServiceCollection AddOrderStates(
this IServiceCollection services)
{
services.AddTransient<PendingState>();
services.AddTransient<ProcessingState>();
services.AddTransient<ShippedState>();
services.AddSingleton<IStateFactory,
ServiceProviderStateFactory>();
return services;
}
}
Now state pattern states can accept constructor dependencies without any state needing to know how to construct the next one. The factory handles that. This keeps your state classes focused on behavior and transition logic rather than construction concerns.
FAQ: Common State Pattern Questions
How many states is too many for a single state machine?
There's no fixed number, but past ten or twelve concrete state classes, evaluate whether you have orthogonal concerns combined into one machine. A state machine for connection lifecycle and a separate one for authentication will be far easier to maintain than one machine handling both.
Should state objects be stateless or can they hold data?
State pattern objects should generally be stateless -- they define behavior, not store data. Data belongs on the context. If a state needs temporary data during a transition, pass it as method parameters. Stateless state objects can be shared, which simplifies memory management.
When should I use the state pattern instead of if-else or switch statements?
When you have more than two or three states and the behavior per state is non-trivial. If your switch fits in twenty lines and rarely changes, leave it. But once you're adding states quarterly or writing tests per state, the state pattern pays for itself. The command pattern faces a similar threshold.
How do I handle async operations within state transitions?
Define an async version of your state pattern interface with methods returning Task. Keep TransitionTo synchronous since it only swaps a reference. Let each state's handler be async, awaiting external calls before triggering the transition.
Can I combine the state pattern with other design patterns?
Absolutely. Use the strategy pattern when behavior varies within a single state based on configuration. Use the command pattern to encapsulate actions triggered during transitions so they can be logged or undone. The decorator pattern can wrap states with cross-cutting concerns like logging without modifying the state classes.
How do I persist state machine state across application restarts?
Store the state identifier -- a string or enum -- in your database alongside the entity. On load, resolve the concrete state class from the identifier using a factory. Avoid serializing state objects directly since they carry behavior, not data.
What's the difference between the state pattern and a finite state machine library?
The state pattern uses polymorphism where each state is a class implementing a shared interface. A finite state machine library typically uses declarative configuration -- tables of states and transitions. The state pattern gives more flexibility for complex per-state behavior. Libraries give less boilerplate for simple transition-heavy machines.
Wrapping Up State Pattern Best Practices
The state pattern is at its best when each state class is a self-contained unit of behavior that knows its transitions, enforces its guard conditions, and handles entry and exit logic. When you centralize logging in the context, separate orthogonal concerns, and inject dependencies through factories and dependency injection, the result is a state machine that's easy to extend and straightforward to test.
The practices in this guide aren't theoretical. They're the state pattern approaches that surface after maintaining state machines through multiple releases. Start with encapsulated transitions and guard conditions. Add entry and exit actions when side effects enter the picture. Build hierarchies when duplication creeps in.

