BrandGhost
State Pattern Real-World Example in C#: Complete Implementation

State Pattern Real-World Example in C#: Complete Implementation

State Pattern Real-World Example in C#: Complete Implementation

Most state pattern tutorials show a vending machine with three states and a couple of transitions. That's enough to explain the concept, but it won't survive contact with a real system that has guard conditions, audit logging, and a dozen transitions that need to stay consistent. This article builds a complete state pattern real-world example in C# -- a document workflow system with draft, review, approved, rejected, and published states, each enforcing its own rules about what transitions are legal.

By the end, you'll have compilable classes covering the full evolution: the problem without states, the state interface, concrete state implementations, a context class, guard conditions, transition logging, unit tests, and DI registration. If you want to see how this pattern contrasts with swapping behavior via the strategy pattern, this article gives you the hands-on foundation to compare them.

The Problem: Conditional Logic Without State Objects

You're building a document management system. Documents move through a workflow: draft, under review, approved, rejected, and published. Without dedicated state objects, the document class accumulates switch statements and nested conditionals:

public class DocumentWorkflow
{
    private string _status = "Draft";

    public void Submit(string submittedBy)
    {
        if (_status == "Draft")
        {
            _status = "UnderReview";
        }
        else if (_status == "Rejected")
        {
            _status = "UnderReview";
        }
        else
        {
            throw new InvalidOperationException(
                $"Cannot submit from {_status}.");
        }
    }

    public void Approve(string approvedBy, string submittedBy)
    {
        if (_status != "UnderReview")
        {
            throw new InvalidOperationException(
                $"Cannot approve from {_status}.");
        }

        if (approvedBy == submittedBy)
        {
            throw new InvalidOperationException(
                "Cannot approve your own document.");
        }

        _status = "Approved";
    }

    public void Reject(string reason)
    {
        if (_status != "UnderReview")
        {
            throw new InvalidOperationException(
                $"Cannot reject from {_status}.");
        }

        _status = "Rejected";
    }

    public void Publish()
    {
        if (_status != "Approved")
        {
            throw new InvalidOperationException(
                $"Cannot publish from {_status}.");
        }

        _status = "Published";
    }
}

This approach has compounding problems. Every new state means updating every method. Guard conditions get scattered across methods. Testing requires setting up string-based states. Adding audit logging means modifying every branch. The conditional complexity grows quadratically as you add states and transitions.

The state pattern eliminates this by giving each state its own class. Each state object knows which transitions are legal and what conditions must be met. The document simply delegates to whatever state it at that point holds.

Defining the State Interface

The state interface declares every action that a document can attempt. Each concrete state decides whether to allow or reject that action:

public interface IDocumentState
{
    string Name { get; }

    IDocumentState Submit(DocumentContext context);

    IDocumentState Approve(
        DocumentContext context, string approvedBy);

    IDocumentState Reject(
        DocumentContext context, string reason);

    IDocumentState Publish(DocumentContext context);
}

Every method returns an IDocumentState -- the next state after the transition. This keeps transitions explicit. A state that doesn't support an action throws an exception. A state that does support it returns the appropriate next state. The DocumentContext parameter gives each state access to the document's metadata for guard conditions.

Building the DocumentContext

Before we implement concrete states, we need the context class that holds document metadata and delegates to the current state. The context is the object that clients interact with. It carries the data that states need for decision-making:

public sealed class DocumentContext
{
    private IDocumentState _currentState;
    private readonly List<TransitionRecord> _transitionLog = [];

    public DocumentContext(
        string title,
        string author)
    {
        Title = title;
        Author = author;
        _currentState = new DraftState();
    }

    public string Title { get; }

    public string Author { get; }

    public string CurrentStateName => _currentState.Name;

    public string? RejectionReason { get; private set; }

    public IReadOnlyList<TransitionRecord> TransitionLog
        => _transitionLog;

    public void Submit()
    {
        var previousState = _currentState.Name;
        _currentState = _currentState.Submit(this);
        LogTransition(previousState, _currentState.Name,
            "Submitted");
    }

    public void Approve(string approvedBy)
    {
        var previousState = _currentState.Name;
        _currentState = _currentState.Approve(
            this, approvedBy);
        LogTransition(previousState, _currentState.Name,
            $"Approved by {approvedBy}");
    }

    public void Reject(string reason)
    {
        var previousState = _currentState.Name;
        RejectionReason = reason;
        _currentState = _currentState.Reject(this, reason);
        LogTransition(previousState, _currentState.Name,
            $"Rejected: {reason}");
    }

    public void Publish()
    {
        var previousState = _currentState.Name;
        _currentState = _currentState.Publish(this);
        LogTransition(previousState, _currentState.Name,
            "Published");
    }

    private void LogTransition(
        string from,
        string to,
        string description)
    {
        _transitionLog.Add(new TransitionRecord(
            from, to, description, DateTimeOffset.UtcNow));
    }
}

The TransitionRecord captures every state change for audit purposes:

public sealed record TransitionRecord(
    string FromState,
    string ToState,
    string Description,
    DateTimeOffset Timestamp);

Notice that the context captures the previous state name before delegating to the current state's method. This ensures the transition log records the correct "from" state even if the state object throws an exception. The context stores metadata like RejectionReason so that states and calling code can inspect why a document was rejected. This separation connects to inversion of control principles -- the context doesn't decide what transitions are valid, it delegates that responsibility to the state objects.

Implementing the DraftState

The draft state is where every document begins. From draft, a document can only be submitted for review:

public sealed class DraftState : IDocumentState
{
    public string Name => "Draft";

    public IDocumentState Submit(DocumentContext context)
    {
        return new ReviewState();
    }

    public IDocumentState Approve(
        DocumentContext context, string approvedBy)
    {
        throw new InvalidOperationException(
            "Cannot approve a document in Draft state.");
    }

    public IDocumentState Reject(
        DocumentContext context, string reason)
    {
        throw new InvalidOperationException(
            "Cannot reject a document in Draft state.");
    }

    public IDocumentState Publish(DocumentContext context)
    {
        throw new InvalidOperationException(
            "Cannot publish a document in Draft state.");
    }
}

The state pattern makes invalid transitions impossible at the behavioral level. There's no string comparison, no switch statement. If you try to approve a draft, DraftState itself throws. Every state class is responsible only for its own rules.

Implementing the ReviewState with Guard Conditions

The review state is where this pattern earns its keep. This state needs guard conditions -- an approver can't be the same person who authored the document:

public sealed class ReviewState : IDocumentState
{
    public string Name => "UnderReview";

    public IDocumentState Submit(DocumentContext context)
    {
        throw new InvalidOperationException(
            "Document is already under review.");
    }

    public IDocumentState Approve(
        DocumentContext context, string approvedBy)
    {
        if (string.Equals(
            approvedBy,
            context.Author,
            StringComparison.OrdinalIgnoreCase))
        {
            throw new InvalidOperationException(
                "Cannot approve your own document.");
        }

        return new ApprovedState();
    }

    public IDocumentState Reject(
        DocumentContext context, string reason)
    {
        if (string.IsNullOrWhiteSpace(reason))
        {
            throw new ArgumentException(
                "A rejection reason is required.",
                nameof(reason));
        }

        return new RejectedState();
    }

    public IDocumentState Publish(DocumentContext context)
    {
        throw new InvalidOperationException(
            "Cannot publish a document under review.");
    }
}

Guard conditions live inside the state that enforces them. The self-approval check belongs in ReviewState because it's only relevant when someone tries to approve a document that's under review. The rejection reason validation also belongs here -- rejections only happen from this state, so the validation is co-located with the transition logic.

Implementing the ApprovedState

The approved state allows exactly one transition -- publishing:

public sealed class ApprovedState : IDocumentState
{
    public string Name => "Approved";

    public IDocumentState Submit(DocumentContext context)
    {
        throw new InvalidOperationException(
            "Cannot submit an approved document.");
    }

    public IDocumentState Approve(
        DocumentContext context, string approvedBy)
    {
        throw new InvalidOperationException(
            "Document is already approved.");
    }

    public IDocumentState Reject(
        DocumentContext context, string reason)
    {
        throw new InvalidOperationException(
            "Cannot reject an already approved document.");
    }

    public IDocumentState Publish(DocumentContext context)
    {
        return new PublishedState();
    }
}

Implementing the RejectedState

The rejected state allows resubmission. This is a common real-world requirement -- rejected documents go back for revision and re-enter the review process:

public sealed class RejectedState : IDocumentState
{
    public string Name => "Rejected";

    public IDocumentState Submit(DocumentContext context)
    {
        return new ReviewState();
    }

    public IDocumentState Approve(
        DocumentContext context, string approvedBy)
    {
        throw new InvalidOperationException(
            "Cannot approve a rejected document " +
            "without resubmission.");
    }

    public IDocumentState Reject(
        DocumentContext context, string reason)
    {
        throw new InvalidOperationException(
            "Document is already rejected.");
    }

    public IDocumentState Publish(DocumentContext context)
    {
        throw new InvalidOperationException(
            "Cannot publish a rejected document.");
    }
}

Notice the workflow loop this creates. A document goes Draft to UnderReview to Rejected, then back to UnderReview when resubmitted. Each state transition is explicit and self-documenting. You can trace the entire workflow by reading each state class.

Implementing the PublishedState

The published state is a terminal state. No further transitions are allowed:

public sealed class PublishedState : IDocumentState
{
    public string Name => "Published";

    public IDocumentState Submit(DocumentContext context)
    {
        throw new InvalidOperationException(
            "Cannot submit a published document.");
    }

    public IDocumentState Approve(
        DocumentContext context, string approvedBy)
    {
        throw new InvalidOperationException(
            "Cannot approve a published document.");
    }

    public IDocumentState Reject(
        DocumentContext context, string reason)
    {
        throw new InvalidOperationException(
            "Cannot reject a published document.");
    }

    public IDocumentState Publish(DocumentContext context)
    {
        throw new InvalidOperationException(
            "Document is already published.");
    }
}

Terminal states in this implementation should reject every action. This is cleaner than checking for a terminal flag in the context -- the state itself enforces finality.

Testing the Implementation

Unit tests for this system verify two things: valid transitions produce the correct next state, and invalid transitions throw. Here are tests covering the core behaviors:

public sealed class DraftStateTests
{
    [Fact]
    public void Submit_DraftDocument_TransitionsToUnderReview()
    {
        var context = new DocumentContext(
            "Test Doc", "alice");

        context.Submit();

        Assert.Equal("UnderReview", context.CurrentStateName);
    }

    [Fact]
    public void Approve_DraftDocument_ThrowsInvalidOperation()
    {
        var context = new DocumentContext(
            "Test Doc", "alice");

        Assert.Throws<InvalidOperationException>(
            () => context.Approve("bob"));
    }
}

public sealed class ReviewStateTests
{
    [Fact]
    public void Approve_DifferentReviewer_TransitionsToApproved()
    {
        var context = new DocumentContext(
            "Test Doc", "alice");
        context.Submit();

        context.Approve("bob");

        Assert.Equal("Approved", context.CurrentStateName);
    }

    [Fact]
    public void Approve_SameAuthor_ThrowsInvalidOperation()
    {
        var context = new DocumentContext(
            "Test Doc", "alice");
        context.Submit();

        Assert.Throws<InvalidOperationException>(
            () => context.Approve("alice"));
    }

    [Fact]
    public void Reject_WithReason_TransitionsToRejected()
    {
        var context = new DocumentContext(
            "Test Doc", "alice");
        context.Submit();

        context.Reject("Needs more detail");

        Assert.Equal("Rejected", context.CurrentStateName);
        Assert.Equal(
            "Needs more detail", context.RejectionReason);
    }

    [Fact]
    public void Reject_EmptyReason_ThrowsArgumentException()
    {
        var context = new DocumentContext(
            "Test Doc", "alice");
        context.Submit();

        Assert.Throws<ArgumentException>(
            () => context.Reject(""));
    }
}

Now let's test the full workflow path and the transition log:

public sealed class DocumentWorkflowTests
{
    [Fact]
    public void FullWorkflow_DraftToPublished_Succeeds()
    {
        var context = new DocumentContext(
            "Release Notes", "alice");

        context.Submit();
        context.Approve("bob");
        context.Publish();

        Assert.Equal("Published", context.CurrentStateName);
    }

    [Fact]
    public void RejectionLoop_ResubmitAndApprove_Succeeds()
    {
        var context = new DocumentContext(
            "Proposal", "alice");

        context.Submit();
        context.Reject("Too vague");
        context.Submit();
        context.Approve("charlie");
        context.Publish();

        Assert.Equal("Published", context.CurrentStateName);
    }

    [Fact]
    public void TransitionLog_RecordsAllTransitions()
    {
        var context = new DocumentContext(
            "Report", "alice");

        context.Submit();
        context.Approve("bob");

        Assert.Equal(2, context.TransitionLog.Count);
        Assert.Equal(
            "Draft", context.TransitionLog[0].FromState);
        Assert.Equal(
            "UnderReview", context.TransitionLog[0].ToState);
        Assert.Equal(
            "UnderReview", context.TransitionLog[1].FromState);
        Assert.Equal(
            "Approved", context.TransitionLog[1].ToState);
    }

    [Fact]
    public void Publish_FromRejectedState_ThrowsInvalidOperation()
    {
        var context = new DocumentContext(
            "Draft Doc", "alice");
        context.Submit();
        context.Reject("Incomplete");

        Assert.Throws<InvalidOperationException>(
            () => context.Publish());
    }
}

Each test exercises the workflow through the DocumentContext public API. Invalid transitions verify that state objects enforce their own rules. The transition log tests confirm that audit records capture every state change. Testing is clean because each state class is a focused, single-responsibility object.

Wiring Everything Up with Dependency Injection

Registering the workflow components in the DI container is straightforward. State objects aren't registered because they're created by other states during transitions -- only the context needs registration:

using Microsoft.Extensions.DependencyInjection;

public static class DocumentWorkflowRegistration
{
    public static IServiceCollection AddDocumentWorkflow(
        this IServiceCollection services)
    {
        services.AddTransient<DocumentContext>(sp =>
            new DocumentContext("Untitled", "system"));

        return services;
    }
}

In practice, you'd likely create DocumentContext instances through a factory that accepts runtime parameters like the title and author. State objects are ephemeral -- they're created and discarded as transitions occur. This is a key characteristic of this pattern: the context owns the lifecycle, and states are lightweight objects that carry behavior, not data.

In your Program.cs or startup configuration:

builder.Services.AddDocumentWorkflow();

If you wanted to add a new state -- say an "Archived" state reachable from Published -- you'd create an ArchivedState class implementing IDocumentState, add an Archive method to the interface, update PublishedState to return ArchivedState on archive, and have every other state throw for that action. No switch statements to update, no conditional blocks to modify.

State Pattern vs. Strategy Pattern

These two patterns and the strategy pattern look structurally identical -- both wrap behavior behind an interface and let the context delegate to the current implementation. The difference is who controls the switching and why.

With the strategy pattern, the client or configuration chooses which strategy to use. The strategy itself doesn't trigger a switch. With the state pattern, the state objects themselves drive transitions. ReviewState.Approve returns a new ApprovedState -- the current state decides the next state. This self-transition behavior is what makes it distinct.

Use the strategy pattern when you want to select an algorithm. Use the state pattern when an object's behavior depends on its internal condition and that condition changes as methods are called. If you find yourself changing behavior based on the same field in every method of a class, that's a signal to reach for state-based design.

Consider also how the command pattern captures operations as objects -- you could combine it with state-driven logic by having commands validate against the current state before executing. Similarly, the adapter pattern can bridge legacy workflow systems into a state-based architecture without rewriting the existing integrations.

Frequently Asked Questions

When should I use the state pattern instead of if-else chains?

Use it when you have more than two or three states, when each state has different rules for multiple operations, or when you expect to add new states in the future. If you only have two states with simple transitions, if-else is probably fine. The breakeven point is usually around three to four states where conditional logic starts repeating across methods.

How does the state pattern handle concurrent access in multi-threaded applications?

The implementation shown here is not thread-safe. For concurrent scenarios, you'd synchronize transitions in the context class using a lock or SemaphoreSlim. The key insight is that synchronization belongs in the context, not in individual states. Each transition is an atomic operation -- read the current state, call its method, assign the new state -- and that sequence needs protection.

Can I persist state pattern objects to a database?

Yes, but you persist the state name, not the object. Store CurrentStateName as a string or enum column. On reload, map the persisted value back to the corresponding state class. A factory method or dictionary lookup handles the mapping. The objects themselves are stateless -- they carry behavior, not data -- so reconstruction is trivial.

What's the difference between the state pattern and a finite state machine library?

A finite state machine (FSM) library uses a declarative table of states and transitions. The state pattern uses polymorphism -- each state is a class with its own methods. FSM libraries are better when you have dozens of states or need runtime-configurable transitions. The pattern approach is better when state behavior is complex, involves guard conditions, or benefits from object-oriented encapsulation.

How do I add guard conditions to state transitions?

Guard conditions belong inside the concrete state methods, as demonstrated in ReviewState.Approve with the self-approval check. The state has access to the context's metadata, so it can inspect any property before deciding to allow or reject the transition. This keeps guards co-located with the transitions they protect.

Should each state be a singleton or a new instance per transition?

Either approach works. New instances per transition (as shown in this article) are simpler and avoid shared mutable state. Singletons save allocations if states are stateless and transitions happen frequently. For most applications, the allocation cost of new state objects is negligible. Use singletons only if profiling reveals state creation as a bottleneck.

How do I test that invalid transitions throw the right exceptions?

Use Assert.Throws<InvalidOperationException> with the action that should fail. Set up the context in the desired state by calling valid transitions, then assert that the invalid transition throws. Each state class can also be tested in isolation by creating the state directly and calling its methods with a mock or real context object.

Wrapping Up This State Pattern Real-World Example

This implementation shows the pattern managing a real workflow -- document approval with guard conditions, transition logging, and multiple valid paths through the system. We started with a class full of string comparisons and nested conditionals. We ended with five focused state classes, each responsible for its own transition rules.

The state pattern is the right choice when an object's behavior varies based on its internal condition and that condition changes during the object's lifetime. Document workflows, order processing systems, game character states, connection management -- anywhere you see behavior branching on a status field across multiple methods, this pattern turns that branching into clean polymorphism. Take this document workflow example, swap the state names and guard conditions for your own domain, and you've got an extensible workflow engine that keeps your transitions explicit, your guards testable, and your architecture open to new states without touching existing code.

State Design Pattern in C#: Complete Guide with Examples

Master the state design pattern in C# with practical examples showing state transitions, behavior changes, and finite state machine implementations.

State Pattern Best Practices in C#: Code Organization and Maintainability

Master state pattern best practices in C# including transition management, guard conditions, state hierarchies, and organized state machine architectures.

How to Implement State Pattern in C#: Step-by-Step Guide

Learn how to implement state pattern in C# with a step-by-step guide covering state interfaces, concrete states, context classes, and transition logic.

An error has occurred. This application may no longer respond until reloaded. Reload