Implementing Behavioral Patterns: Observer, Strategy, State in .NET

Patterns in Action

Picture a stock trading platform where price updates need to flow to multiple displays, charts, and alert systems simultaneously. When a stock price changes, dozens of components need to react without the price feed knowing anything about charts or alerts. This is where behavioral patterns shine.

Behavioral patterns focus on how objects communicate and distribute responsibility. The Observer pattern handles the stock price broadcasting. The Strategy pattern swaps between different pricing algorithms. The State pattern manages order lifecycles as they move from pending to filled to settled.

You'll learn when each pattern solves real problems, how to implement them in modern C#, and the trade-offs between using built-in language features versus full pattern implementations.

Observer Pattern for Event Broadcasting

The Observer pattern creates a one-to-many relationship where multiple observers automatically receive notifications when the subject's state changes. This decouples the source of events from the listeners. The subject maintains a list of observers and notifies them when something interesting happens.

C# events are built-in Observer implementations, but explicit Observer gives you more control over subscription management, filtering, and cross-assembly scenarios. You can add priorities, throttling, or async notifications that standard events don't provide out of the box.

Here's how to implement Observer when you need more than basic events can provide. The pattern uses interfaces to keep subject and observers loosely coupled.

Observer.cs
public interface IObserver<T>
{
    void Update(T data);
}

public interface ISubject<T>
{
    void Attach(IObserver<T> observer);
    void Detach(IObserver<T> observer);
    void Notify();
}

public class StockPrice : ISubject<decimal>
{
    private readonly List<IObserver<decimal>> _observers = new();
    private decimal _price;

    public decimal Price
    {
        get => _price;
        set
        {
            if (_price != value)
            {
                _price = value;
                Notify();
            }
        }
    }

    public void Attach(IObserver<decimal> observer) => _observers.Add(observer);
    public void Detach(IObserver<decimal> observer) => _observers.Remove(observer);
    public void Notify()
    {
        foreach (var observer in _observers)
        {
            observer.Update(_price);
        }
    }
}

public class PriceDisplay : IObserver<decimal>
{
    private readonly string _name;
    public PriceDisplay(string name) => _name = name;

    public void Update(decimal price)
    {
        Console.WriteLine($"{_name} updated: ${price:F2}");
    }
}

When the Price property changes, all attached observers get notified automatically. PriceDisplay implements IObserver to receive updates. This pattern works great for scenarios where you need runtime subscription management or want to avoid tight coupling through direct event references.

Strategy Pattern for Algorithm Selection

Strategy pattern encapsulates interchangeable algorithms behind a common interface. The client chooses which algorithm to use at runtime without knowing implementation details. This makes code more flexible and testable because you can swap strategies without changing the context that uses them.

You'll find Strategy useful for payment processing where you support multiple gateways, compression algorithms where different strategies suit different data types, or sorting where the optimal algorithm depends on data characteristics. The pattern promotes open-closed principle by letting you add new strategies without modifying existing code.

Strategy.cs
public interface IShippingStrategy
{
    decimal CalculateCost(decimal weight, decimal distance);
    string GetEstimatedDays();
}

public class StandardShipping : IShippingStrategy
{
    public decimal CalculateCost(decimal weight, decimal distance)
    {
        return weight * 0.5m + distance * 0.1m;
    }

    public string GetEstimatedDays() => "5-7 business days";
}

public class ExpressShipping : IShippingStrategy
{
    public decimal CalculateCost(decimal weight, decimal distance)
    {
        return weight * 1.5m + distance * 0.3m + 15m;
    }

    public string GetEstimatedDays() => "1-2 business days";
}

public class ShippingCalculator
{
    private IShippingStrategy _strategy;

    public ShippingCalculator(IShippingStrategy strategy)
    {
        _strategy = strategy;
    }

    public void SetStrategy(IShippingStrategy strategy)
    {
        _strategy = strategy;
    }

    public decimal Calculate(decimal weight, decimal distance)
    {
        return _strategy.CalculateCost(weight, distance);
    }

    public string GetDeliveryEstimate()
    {
        return _strategy.GetEstimatedDays();
    }
}

The calculator doesn't know whether it's using standard or express shipping. You can change strategies at runtime or inject different ones through dependency injection. This makes unit testing simple because you can mock strategies independently of the context.

State Pattern for State-Dependent Behavior

State pattern lets an object change its behavior when its internal state changes. Instead of massive switch statements checking state variables, each state becomes a class that handles its own transitions and behavior. This pattern simplifies complex state machines and makes states easier to understand and modify.

Use State pattern for order processing workflows, document approval chains, or connection management where behavior differs dramatically based on current state. Each state class knows which transitions are valid and what actions make sense in that state.

State.cs
public interface IOrderState
{
    void Process(OrderContext context);
    void Cancel(OrderContext context);
    string GetStatus();
}

public class OrderContext
{
    private IOrderState _currentState;

    public OrderContext()
    {
        _currentState = new PendingState();
    }

    public void SetState(IOrderState state) => _currentState = state;
    public void Process() => _currentState.Process(this);
    public void Cancel() => _currentState.Cancel(this);
    public string Status => _currentState.GetStatus();
}

public class PendingState : IOrderState
{
    public void Process(OrderContext context)
    {
        Console.WriteLine("Processing order...");
        context.SetState(new ProcessingState());
    }

    public void Cancel(OrderContext context)
    {
        Console.WriteLine("Order cancelled");
        context.SetState(new CancelledState());
    }

    public string GetStatus() => "Pending";
}

public class ProcessingState : IOrderState
{
    public void Process(OrderContext context)
    {
        Console.WriteLine("Order shipped");
        context.SetState(new ShippedState());
    }

    public void Cancel(OrderContext context)
    {
        Console.WriteLine("Cannot cancel processing order");
    }

    public string GetStatus() => "Processing";
}

public class ShippedState : IOrderState
{
    public void Process(OrderContext context) =>
        Console.WriteLine("Already shipped");

    public void Cancel(OrderContext context) =>
        Console.WriteLine("Cannot cancel shipped order");

    public string GetStatus() => "Shipped";
}

public class CancelledState : IOrderState
{
    public void Process(OrderContext context) =>
        Console.WriteLine("Cannot process cancelled order");

    public void Cancel(OrderContext context) =>
        Console.WriteLine("Already cancelled");

    public string GetStatus() => "Cancelled";
}

Each state handles Process and Cancel differently. Pending can transition to Processing or Cancelled. Processing can only move to Shipped. The state classes encapsulate transition logic instead of scattering it across the context class.

Try It Yourself

This example combines all three patterns in a simple notification system that demonstrates their practical interaction.

Steps

  1. dotnet new console -n PatternsDemo
  2. cd PatternsDemo
  3. Open Program.cs and add the code below
  4. dotnet run
Program.cs
// Observer pattern
var stock = new Stock("AAPL", 150m);
stock.Attach(new StockAlert("Alert-1"));
stock.Attach(new StockAlert("Alert-2"));

stock.Price = 155m;
stock.Price = 160m;

// Strategy pattern
var calc = new ShippingCalc(new StandardRate());
Console.WriteLine($"\nStandard: ${calc.Calculate(10)}");

calc.SetStrategy(new ExpressRate());
Console.WriteLine($"Express: ${calc.Calculate(10)}");

// State pattern
var order = new OrderFlow();
Console.WriteLine($"\nOrder: {order.Status}");
order.Process();
Console.WriteLine($"Order: {order.Status}");
order.Process();
Console.WriteLine($"Order: {order.Status}");

class Stock
{
    private readonly List<IAlert> _observers = new();
    private decimal _price;
    public string Symbol { get; }

    public Stock(string symbol, decimal price)
    {
        Symbol = symbol;
        _price = price;
    }

    public decimal Price
    {
        get => _price;
        set { _price = value; Notify(); }
    }

    public void Attach(IAlert observer) => _observers.Add(observer);
    void Notify() => _observers.ForEach(o => o.Update(Symbol, _price));
}

interface IAlert { void Update(string symbol, decimal price); }
class StockAlert : IAlert
{
    private readonly string _id;
    public StockAlert(string id) => _id = id;
    public void Update(string symbol, decimal price) =>
        Console.WriteLine($"{_id}: {symbol} @ ${price}");
}

interface IRate { decimal Calculate(decimal weight); }
class StandardRate : IRate
{
    public decimal Calculate(decimal weight) => weight * 0.5m;
}
class ExpressRate : IRate
{
    public decimal Calculate(decimal weight) => weight * 1.5m + 10m;
}

class ShippingCalc
{
    private IRate _strategy;
    public ShippingCalc(IRate strategy) => _strategy = strategy;
    public void SetStrategy(IRate strategy) => _strategy = strategy;
    public decimal Calculate(decimal weight) => _strategy.Calculate(weight);
}

class OrderFlow
{
    private string _state = "New";
    public string Status => _state;
    public void Process() => _state = _state switch
    {
        "New" => "Processing",
        "Processing" => "Shipped",
        _ => _state
    };
}
PatternsDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output

You'll see multiple alerts firing for stock price changes, different shipping costs for the two strategies, and the order progressing through states.

Choosing the Right Pattern

Use Observer when one object's change should notify multiple dependent objects without tight coupling. Choose C# events for simple scenarios within your codebase. Pick explicit Observer when you need subscription filtering, priority handling, or work across assembly boundaries where events become awkward.

Apply Strategy when you have multiple algorithms for the same task and want to choose at runtime. It's ideal for dependency injection scenarios where different implementations satisfy the same interface. Strategy works great with ASP.NET Core's built-in DI container.

Implement State when behavior changes significantly based on internal state and you find yourself writing large switch statements on state variables. State pattern organizes complex state machines into manageable classes. Modern C# switch expressions can sometimes replace State for simpler scenarios.

Consider combinations when appropriate. An order processing system might use Observer to notify interested parties, Strategy to choose shipping methods, and State to manage the order lifecycle. Patterns complement each other when each addresses a distinct concern.

FAQ

When should I use Observer pattern vs C# events?

Use C# events for simple one-to-many notifications within your codebase. Choose Observer pattern when you need complex subscription management, filtering, or when working across assembly boundaries. Events are language-level Observer implementations with less boilerplate.

Is Strategy pattern just dependency injection?

Strategy defines interchangeable algorithms behind an interface. Dependency injection is the mechanism for providing that strategy. You can use DI to inject strategies, but Strategy focuses on runtime algorithm selection while DI handles object creation and lifecycle.

What's the gotcha with State pattern in concurrent code?

State transitions aren't thread-safe by default. Multiple threads can trigger simultaneous state changes, causing race conditions. Protect state transitions with locks, use immutable state objects, or leverage async state machines with proper synchronization context.

Back to Articles