Design Patterns #7: From Chaos to Clarity with the State Pattern.
Serhii Korol

Serhii Korol @serhii_korol_ab7776c50dba

About: Software Engineer, .NET developer

Location:
Kyiv, Ukraine
Joined:
May 28, 2022

Design Patterns #7: From Chaos to Clarity with the State Pattern.

Publish Date: May 14
1 1

Hi! Today, we’re going to talk about managing state. In fact, even this article has a state — it started as an idea and is now evolving as I write it.

To explore the concept of state management, we’ll use a simple example and gradually implement the logic in code. Let’s begin with a naive approach, assuming you’ve never heard of the State pattern.

We’ll use a coffee vending machine as our example because it's intuitive and easy to follow.

Here’s a typical flow when you buy coffee:

  • The machine starts in a waiting-for-money state.
  • Once you insert money, it transitions to a has-money state.
  • You then select a beverage, moving it to a beverage-selected state.
  • Finally, the machine prepares and dispenses the drink, entering a dispensing state.

So, there are four basic states:

  1. No money
  2. Has money
  3. Beverage selected
  4. Dispensing beverage

However, things can get a bit more complex in real life.

For example:

  • The machine is waiting for money.
  • You insert some coins, and it moves to the has-money state.
  • You select a beverage, but it costs more than what you inserted.
  • The machine transitions back to the waiting-for-money state.
  • You insert more coins, and it returns to the has-money state.
  • You choose the same beverage again, but now it’s out of stock.
  • The machine switches to the out-of-stock state.
  • You select a different, available drink, and it enters the beverage-selected state.
  • You press the start button, the drink is prepared, and the machine transitions to the dispensing state.

Let’s now implement this logic using a naive approach to better understand how state management works without relying on the State pattern.

Naive approach

To implement this functionality, we only need an enum to represent the possible states and a class to manage the transitions between them. Since there are just four main states, the logic remains relatively simple.

At this point, I’m not aiming to build a highly realistic simulation—just a straightforward example to demonstrate how state management can be implemented.

public enum CoffeeMachineState
{
    NoMoneyState,
    HasMoneyState,
    BeverageSelectedState,
    OutOfStockState
}
Enter fullscreen mode Exit fullscreen mode

The class initializes with a starting state and handles transitions between other states. However, as you can see, the implementation quickly becomes messy. It relies heavily on conditional logic, which negatively impacts performance, readability, and flexibility.

public class CoffeeMachine(int beverageCount)
{
    private CoffeeMachineState _currentState =
        beverageCount <= 0 ? CoffeeMachineState.OutOfStockState : CoffeeMachineState.NoMoneyState;

    private int BeverageCount { get; set; } = beverageCount;
    private decimal CurrentBalance { get; set; }
    private const decimal BeveragePrice = 1.50M;

    public void InsertMoney(decimal amount)
    {
        switch (_currentState)
        {
            case CoffeeMachineState.NoMoneyState:
                if (amount > 0)
                {
                    AddMoney(amount);
                    _currentState = CoffeeMachineState.HasMoneyState;
                }
                else
                {
                    Console.WriteLine("Please insert a valid amount of money");
                }

                break;

            case CoffeeMachineState.HasMoneyState:
                AddMoney(amount);
                break;

            case CoffeeMachineState.BeverageSelectedState:
                Console.WriteLine("Beverage already selected. Please wait for beverage to be dispensed.");
                break;

            case CoffeeMachineState.OutOfStockState:
                Console.WriteLine("Sorry, the machine is out of beverages. Returning your money.");
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    public void SelectBeverage()
    {
        switch (_currentState)
        {
            case CoffeeMachineState.NoMoneyState:
                Console.WriteLine("Please insert money first");
                break;

            case CoffeeMachineState.HasMoneyState:
                if (BeverageCount <= 0)
                {
                    Console.WriteLine("Sorry, out of beverages");
                    _currentState = CoffeeMachineState.OutOfStockState;
                    return;
                }

                if (CurrentBalance >= BeveragePrice)
                {
                    Console.WriteLine("Beverage selected!");
                    _currentState = CoffeeMachineState.BeverageSelectedState;
                }
                else
                {
                    Console.WriteLine(
                        $"Insufficient funds. Please insert more money. Current balance: ${CurrentBalance}, Beverage price: ${BeveragePrice}");
                }

                break;

            case CoffeeMachineState.BeverageSelectedState:
                Console.WriteLine("Beverage already selected. Please wait for beverage to be dispensed.");
                break;

            case CoffeeMachineState.OutOfStockState:
                Console.WriteLine("Sorry, the machine is out of beverages");
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    public void DispenseBeverage()
    {
        switch (_currentState)
        {
            case CoffeeMachineState.NoMoneyState:
                Console.WriteLine("Please insert money first");
                break;

            case CoffeeMachineState.HasMoneyState:
                Console.WriteLine("Please select a beverage first");
                break;

            case CoffeeMachineState.BeverageSelectedState:
                if (BeverageCount > 0)
                {
                    BeverageCount--;
                    Console.WriteLine("Beverage dispensed!");

                    decimal changeAmount = CurrentBalance - BeveragePrice;
                    if (changeAmount > 0)
                    {
                        Console.WriteLine($"Returning change: ${changeAmount}");
                    }

                    CurrentBalance = 0;

                    _currentState = BeverageCount <= 0
                        ? CoffeeMachineState.OutOfStockState
                        : CoffeeMachineState.NoMoneyState;
                }
                else
                {
                    Console.WriteLine("Sorry, out of beverages");
                    ReturnMoney();
                    _currentState = CoffeeMachineState.OutOfStockState;
                }

                break;

            case CoffeeMachineState.OutOfStockState:
                Console.WriteLine("Sorry, the machine is out of beverages");
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    public void CancelTransaction()
    {
        switch (_currentState)
        {
            case CoffeeMachineState.NoMoneyState:
                Console.WriteLine("No transaction to cancel");
                break;

            case CoffeeMachineState.HasMoneyState:
            case CoffeeMachineState.BeverageSelectedState:
                ReturnMoney();
                _currentState = CoffeeMachineState.NoMoneyState;
                break;

            case CoffeeMachineState.OutOfStockState:
                ReturnMoney();
                break;
            default:
                throw new ArgumentOutOfRangeException();
        }
    }

    private void AddMoney(decimal amount)
    {
        CurrentBalance += amount;
        Console.WriteLine($"Money added: ${amount}. Current balance: ${CurrentBalance}");
    }

    private void ReturnMoney()
    {
        if (CurrentBalance <= 0) return;
        Console.WriteLine($"Returning ${CurrentBalance} to customer");
        CurrentBalance = 0;
    }

    public string GetCurrentState()
    {
        return _currentState.ToString();
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, let's implement a usage scenario. The main idea is to simulate the following sequence of actions:

  1. Set the available beverages.
  2. Attempt to select a beverage with a zero balance.
  3. Insert money and try selecting the beverage again.
  4. Encounter insufficient funds.
  5. Insert more money and select the beverage again.
  6. Repeat the selection process.
  7. Dispense the beverage.
  8. Try selecting a beverage without any money.
  9. Cancel a transaction with no money inserted.
  10. Cancel a transaction after inserting money.
  11. Set the stock of a beverage to zero.
  12. Attempt to insert money when the selected beverage is out of stock.
public static void RunNaive()
    {
        Naive.CoffeeMachine coffeeMachine = new Naive.CoffeeMachine(5);

        // Try operations in NoMoney state
        Console.WriteLine("\n[NoMoneyState]");
        Console.WriteLine($"\nCurrent State: {coffeeMachine.GetCurrentState()}");
        coffeeMachine.SelectBeverage();

        // Insert money and move to HasMoney state
        Console.WriteLine("\n[Moving to HasMoneyState]");
        coffeeMachine.InsertMoney(1.00M);
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");

        // Insufficient funds
        coffeeMachine.SelectBeverage();

        // Add more money
        coffeeMachine.InsertMoney(1.00M);

        // Now select beverage to move to BeverageSelected state
        Console.WriteLine("\n[Moving to BeverageSelectedState]");
        coffeeMachine.SelectBeverage();
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");

        // Try to select again or insert money
        coffeeMachine.SelectBeverage();
        coffeeMachine.InsertMoney(0.50M);

        // Dispense beverage and go back to NoMoney state
        Console.WriteLine("\n[Dispensing Beverage]");
        coffeeMachine.DispenseBeverage();
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");

        // We're back in NoMoney state, try operations
        Console.WriteLine("\n[Back to NoMoneyState]");
        coffeeMachine.SelectBeverage();

        // Try cancel transaction
        Console.WriteLine("\n[Cancel transaction from different states]");
        coffeeMachine.CancelTransaction();
        coffeeMachine.InsertMoney(2.00M);
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");
        coffeeMachine.CancelTransaction();
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");

        // Create a vending machine with 0 beverages to demonstrate OutOfStock state
        Console.WriteLine("\n[Out Of Stock]");
        Naive.CoffeeMachine emptyMachine = new Naive.CoffeeMachine(0);
        Console.WriteLine($"Current State: {emptyMachine.GetCurrentState()}");
        emptyMachine.InsertMoney(1.00M);
    }
Enter fullscreen mode Exit fullscreen mode

Let's check this out.

[NoMoneyState]

Current State: NoMoneyState
Please insert money first

[Moving to HasMoneyState]
Money added: $1,00. Current balance: $1,00
Current State: HasMoneyState
Insufficient funds. Please insert more money. Current balance: $1,00, Beverage price: $1,50
Money added: $1,00. Current balance: $2,00

[Moving to BeverageSelectedState]
Beverage selected!
Current State: BeverageSelectedState
Beverage already selected. Please wait for beverage to be dispensed.
Beverage already selected. Please wait for beverage to be dispensed.

[Dispensing Beverage]
Beverage dispensed!
Returning change: $0,50
Current State: NoMoneyState

[Back to NoMoneyState]
Please insert money first

[Cancel transaction from different states]
No transaction to cancel
Money added: $2,00. Current balance: $2,00
Current State: HasMoneyState
Returning $2,00 to customer
Current State: NoMoneyState

[Out Of Stock]
Current State: OutOfStockState
Sorry, the machine is out of beverages. Returning your money.

Process finished with exit code 0.
Enter fullscreen mode Exit fullscreen mode

The main drawback of this approach is that adding a new state, such as an "in-progress" state where the machine is preparing the beverage, requires modifying the code in multiple places. As the complexity of the scenario increases beyond a simple coffee machine, managing these changes becomes increasingly difficult and error-prone.

Fortunately, there’s a better solution. Let’s take a look at it.

State Pattern Approach

In this approach, each state is implemented as a class that inherits from a common abstraction. State transitions and behaviors are encapsulated within dedicated methods, making it easier to manage and extend the system. This design allows each state to handle its own logic and define how and when transitions to other states occur.

public interface ICoffeeMachineState
{
    void InsertMoney(decimal amount);
    void SelectBeverage();
    void DispenseBeverage();
    void CancelTransaction();
}
Enter fullscreen mode Exit fullscreen mode

Each state is represented by a class that implements a common interface. These classes define the specific behavior for each action relevant to their state.

public class BeverageSelectedState(CoffeeMachine coffeeMachine) : ICoffeeMachineState
{
    private const decimal BeveragePrice = 1.50M;

    public void InsertMoney(decimal amount)
    {
        Console.WriteLine("Beverage already selected. Please wait for beverage to be dispensed.");
    }

    public void SelectBeverage()
    {
        Console.WriteLine("Beverage already selected. Please wait for beverage to be dispensed.");
    }

    public void DispenseBeverage()
    {
        if (coffeeMachine.BeverageCount > 0)
        {
            coffeeMachine.DecrementBeverageCount();
            Console.WriteLine("Beverage dispensed!");

            decimal changeAmount = coffeeMachine.CurrentBalance - BeveragePrice;
            if (changeAmount > 0)
            {
                Console.WriteLine($"Returning change: ${changeAmount}");
            }

            coffeeMachine.SetState(new NoMoneyState(coffeeMachine));
            coffeeMachine.CurrentBalance = 0;
        }
        else
        {
            Console.WriteLine("Sorry, out of beverages");
            coffeeMachine.ReturnMoney();
            coffeeMachine.SetState(new NoMoneyState(coffeeMachine));
        }
    }

    public void CancelTransaction()
    {
        coffeeMachine.ReturnMoney();
        coffeeMachine.SetState(new NoMoneyState(coffeeMachine));
    }
}

public class HasMoneyState(CoffeeMachine coffeeMachine) : ICoffeeMachineState
{
    private const decimal BeveragePrice = 1.50M;

    public void InsertMoney(decimal amount)
    {
        coffeeMachine.AddMoney(amount);
    }

    public void SelectBeverage()
    {
        if (coffeeMachine.BeverageCount <= 0)
        {
            Console.WriteLine("Sorry, out of beverages");
            return;
        }

        if (coffeeMachine.CurrentBalance >= BeveragePrice)
        {
            Console.WriteLine("Beverage selected!");
            coffeeMachine.SetState(new BeverageSelectedState(coffeeMachine));
        }
        else
        {
            Console.WriteLine(
                $"Insufficient funds. Please insert more money. Current balance: ${coffeeMachine.CurrentBalance}, Beverage price: ${BeveragePrice}");
        }
    }

    public void DispenseBeverage()
    {
        Console.WriteLine("Please select a beverage first");
    }

    public void CancelTransaction()
    {
        coffeeMachine.ReturnMoney();
        coffeeMachine.SetState(new NoMoneyState(coffeeMachine));
    }
}

public class NoMoneyState(CoffeeMachine coffeeMachine) : ICoffeeMachineState
{
    public void InsertMoney(decimal amount)
    {
        if (amount > 0)
        {
            coffeeMachine.AddMoney(amount);
            coffeeMachine.SetState(new HasMoneyState(coffeeMachine));
        }
        else
        {
            Console.WriteLine("Please insert a valid amount of money");
        }
    }

    public void SelectBeverage()
    {
        Console.WriteLine("Please insert money first");
    }

    public void DispenseBeverage()
    {
        Console.WriteLine("Please insert money first");
    }

    public void CancelTransaction()
    {
        Console.WriteLine("No transaction to cancel");
    }
}

public class OutOfStockState(CoffeeMachine coffeeMachine) : ICoffeeMachineState
{
    public void InsertMoney(decimal amount)
    {
        Console.WriteLine("Sorry, the machine is out of beverages. Returning your money.");
    }

    public void SelectBeverage()
    {
        Console.WriteLine("Sorry, the machine is out of beverages");
    }

    public void DispenseBeverage()
    {
        Console.WriteLine("Sorry, the machine is out of beverages");
    }

    public void CancelTransaction()
    {
        coffeeMachine.ReturnMoney();
    }
}
Enter fullscreen mode Exit fullscreen mode

Let’s take a look at the NoMoneyState class. This state checks the current balance, and if it's greater than zero, it transitions to a different state. It also defines how the machine should respond to actions that aren't valid in this state, such as trying to select a beverage without inserting money.

The same principle applies to the other state classes, each of which defines its own valid transitions and behaviors. Since we have four states, we should create four corresponding classes.

The main logic for managing the balance, handling beverage stock, and switching between states remains in a central context class, just like in the previous approach. However, this version is much cleaner, more organized, and easier to maintain.

public class CoffeeMachine
{
    private ICoffeeMachineState _state;

    public int BeverageCount { get; set; }
    public decimal CurrentBalance { get; internal set; }

    public CoffeeMachine(int beverageCount)
    {
        if (beverageCount <= 0)
        {
            _state = new OutOfStockState(this);
        }
        else
        {
            _state = new NoMoneyState(this);
        }

        BeverageCount = beverageCount;
        CurrentBalance = 0;
    }

    public void SetState(ICoffeeMachineState state)
    {
        _state = state;
    }

    public void InsertMoney(decimal amount)
    {
        _state.InsertMoney(amount);
    }

    public void SelectBeverage()
    {
        _state.SelectBeverage();
    }

    public void DispenseBeverage()
    {
        _state.DispenseBeverage();
    }

    public void CancelTransaction()
    {
        _state.CancelTransaction();
    }

    public void AddMoney(decimal amount)
    {
        CurrentBalance += amount;
        Console.WriteLine($"Money added: ${amount}. Current balance: ${CurrentBalance}");
    }

    public void ReturnMoney()
    {
        Console.WriteLine($"Returning ${CurrentBalance} to customer");
        CurrentBalance = 0;
    }

    public void DecrementBeverageCount()
    {
        if (BeverageCount > 0)
        {
            BeverageCount--;
        }
    }

    public string GetCurrentState()
    {
        return _state.GetType().Name;
    }
}
Enter fullscreen mode Exit fullscreen mode

The usage scenario remains the same, with the only difference being that each state-specific behavior is now handled by the appropriate class.

public static void RunState()
    {
        State.CoffeeMachine coffeeMachine = new State.CoffeeMachine(5);

        // Try operations in NoMoneyState
        Console.WriteLine("\n[NoMoneyState]");
        Console.WriteLine($"\nCurrent State: {coffeeMachine.GetCurrentState()}");
        coffeeMachine.SelectBeverage(); // Should fail

        // Insert money and move to HasMoneyState
        Console.WriteLine("\n[Moving to HasMoneyState]");
        coffeeMachine.InsertMoney(1.00M);
        Console.WriteLine($"\nCurrent State: {coffeeMachine.GetCurrentState()}");

        // Insufficient funds
        coffeeMachine.SelectBeverage();

        // Add more money
        coffeeMachine.InsertMoney(1.00M);

        // Now select beverage to move to BeverageSelectedState
        Console.WriteLine("\n[Moving to BeverageSelectedState]");
        coffeeMachine.SelectBeverage();
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");

        // Try to select again or insert money
        coffeeMachine.SelectBeverage();
        coffeeMachine.InsertMoney(0.50M);

        // Dispense beverage and go back to NoMoneyState
        Console.WriteLine("\n[Dispensing Beverage]");
        coffeeMachine.DispenseBeverage();
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");

        // We're back in NoMoneyState, try operations
        Console.WriteLine("\n[Back to NoMoneyState]");
        coffeeMachine.SelectBeverage();

        // Try cancel transaction
        Console.WriteLine("\n[Cancel transaction from different states]");
        coffeeMachine.CancelTransaction();
        coffeeMachine.InsertMoney(2.00M);
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");
        coffeeMachine.CancelTransaction();
        Console.WriteLine($"Current State: {coffeeMachine.GetCurrentState()}");

        Console.WriteLine("\n[Out Of Stock]");
        State.CoffeeMachine emptyMachine = new State.CoffeeMachine(0);
        Console.WriteLine($"Current State: {emptyMachine.GetCurrentState()}");
        emptyMachine.InsertMoney(1.00M);
    }
Enter fullscreen mode Exit fullscreen mode

Check this out also.

[NoMoneyState]

Current State: NoMoneyState
Please insert money first

[Moving to HasMoneyState]
Money added: $1,00. Current balance: $1,00

Current State: HasMoneyState
Insufficient funds. Please insert more money. Current balance: $1,00, Beverage price: $1,50
Money added: $1,00. Current balance: $2,00

[Moving to BeverageSelectedState]
Beverage selected!
Current State: BeverageSelectedState
Beverage already selected. Please wait for beverage to be dispensed.
Beverage already selected. Please wait for beverage to be dispensed.

[Dispensing Beverage]
Beverage dispensed!
Returning change: $0,50
Current State: NoMoneyState

[Back to NoMoneyState]
Please insert money first

[Cancel transaction from different states]
No transaction to cancel
Money added: $2,00. Current balance: $2,00
Current State: HasMoneyState
Returning $2,00 to customer
Current State: NoMoneyState

[Out Of Stock]
Current State: OutOfStockState
Sorry, the machine is out of beverages. Returning your money.

Process finished with exit code 0.
Enter fullscreen mode Exit fullscreen mode

As you can see, the end result is the same. However, this approach requires creating more classes. So, how does that impact performance? Let’s find out.

Benchmark

Let’s test this using DotNetBenchmark.

As you can see, the naive approach performs significantly slower. And this is with a simple scenario. In more complex scenarios, it will likely perform even worse due to the numerous conditions and logic checks.

benchmark

Conclusion

The naive approach may be acceptable for small projects. However, for larger and more complex applications, I highly recommend adopting the State pattern. It offers significant improvements in flexibility, extensibility, readability, and performance.

Thank you for reading! I hope you found this article helpful. See you next time, and happy coding!

You can find the source code for this example here.

Buy Me A Beer

Comments 1 total

  • Spyros Ponaris
    Spyros PonarisMay 15, 2025

    Thanks for sharing. Here is another version of the state.
    Simplifies maintenance by encapsulating behavior per state, reducing switch-case clutter, and making it easy to add new states . I hope y like it.

    public interface ICoffeeMachineState
    {
        void InsertCoin();
        void SelectBeverage(string beverage);
        void Dispense();
    }
    
    Enter fullscreen mode Exit fullscreen mode
    public class IdleState : ICoffeeMachineState
    {
        private readonly CoffeeMachine _machine;
    
        public IdleState(CoffeeMachine machine) => _machine = machine;
    
        public void InsertCoin()
        {
            Console.WriteLine("Coin inserted. Ready to select beverage.");
            _machine.SetState(_machine.SelectionState);
        }
    
        public void SelectBeverage(string beverage)
        {
            Console.WriteLine("Please insert a coin first.");
        }
    
        public void Dispense()
        {
            Console.WriteLine("Insert coin and select beverage first.");
        }
    }
    
    Enter fullscreen mode Exit fullscreen mode
    public class SelectionState : ICoffeeMachineState
    {
        private readonly CoffeeMachine _machine;
    
        public SelectionState(CoffeeMachine machine) => _machine = machine;
    
        public void InsertCoin()
        {
            Console.WriteLine("Coin already inserted.");
        }
    
        public void SelectBeverage(string beverage)
        {
            Console.WriteLine($"{beverage} selected. Dispensing now...");
            _machine.SetState(_machine.DispensingState);
            _machine.Dispense();
        }
        public void Dispense()
        {
            Console.WriteLine("Please select a beverage first.");
        }
    }
    
    Enter fullscreen mode Exit fullscreen mode
    public class DispensingState : ICoffeeMachineState
    {
        private readonly CoffeeMachine _machine;
    
        public DispensingState(CoffeeMachine machine) => _machine = machine;
    
        public void InsertCoin()
        {
            Console.WriteLine("Please wait, dispensing in progress.");
        }
    
        public void SelectBeverage(string beverage)
        {
            Console.WriteLine("Already dispensing. Please wait.");
        }
    
        public void Dispense()
        {
            Console.WriteLine("Here is your coffee. Thank you!");
            _machine.SetState(_machine.IdleState);
        }
    }
    
    Enter fullscreen mode Exit fullscreen mode
    public class CoffeeMachine
    {
        public ICoffeeMachineState IdleState { get; }
        public ICoffeeMachineState SelectionState { get; }
        public ICoffeeMachineState DispensingState { get; }
    
        private ICoffeeMachineState _currentState;
    
        public CoffeeMachine()
        {
            IdleState = new IdleState(this);
            SelectionState = new SelectionState(this);
            DispensingState = new DispensingState(this);
            _currentState = IdleState;
        }
    
        public void SetState(ICoffeeMachineState state) => _currentState = state;
    
        public void InsertCoin() => _currentState.InsertCoin();
        public void SelectBeverage(string beverage) => _currentState.SelectBeverage(beverage);
        public void Dispense() => _currentState.Dispense();
    }
    
    Enter fullscreen mode Exit fullscreen mode
    var machine = new CoffeeMachine();
    machine.SelectBeverage("Espresso"); // Error: insert coin first
    machine.InsertCoin();                          // Moves to selection
    machine.SelectBeverage("Espresso"); // Moves to dispensing
    machine.InsertCoin();                          // Wait until dispensing finishes
    
    Enter fullscreen mode Exit fullscreen mode
Add comment