DEV Community

Manohari Jayachandran
Manohari Jayachandran

Posted on

Design Patterns in C# Part 3: Behavioral Patterns - Strategy, Observer, Command, State, and Template Method

This is Part 3, the final part of a three-part design patterns series built for interview prep. Part 1 covered Creational patterns - how objects get created. Part 2 covered Structural patterns - how objects and classes are composed into larger structures. This closing part covers Behavioral patterns - how objects communicate and share responsibility with each other. Same format throughout the series: the problem each pattern solves, an analogy, a before-and-after code example, and honest guidance on when it's actually worth the added complexity.

Behavioral Pattern
Design Patterns in C#, Part 3 of 3 — Behavioral patterns close out the series. Thirteen patterns total, all with real C# and honest tradeoffs.

Behavioral patterns include Strategy, Observer, Command, State, Template Method, Iterator, and Chain of Responsibility. This post covers the five that come up most often in real interviews and real code: Strategy, Observer, Command, State, and Template Method.

Pattern 1: Strategy

The problem Strategy solves is defining a family of interchangeable algorithms, encapsulating each one separately, and letting the algorithm be swapped at runtime without the calling code changing at all.

Think of different routes to the same destination - driving, walking, or public transit. The destination stays the same, and "get me there" stays the same request, but the actual method of travel can be swapped independently of that request. Strategy applies the same idea to algorithms: the calling code asks for a result, and which specific algorithm produces that result can change freely.

This is genuinely the same underlying idea covered twice already in this series under different names - an IDiscount interface in an earlier Open/Closed Principle post, and a custom IComparer<T> in an earlier Advanced Generics post. Strategy is simply the formal design pattern name for that same pattern.

// The strategy interface
public interface IShippingStrategy
{
    decimal CalculateCost(decimal weight, decimal distance);
}

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

public class ExpressShipping : IShippingStrategy
{
    public decimal CalculateCost(decimal weight, decimal distance)
        => (weight * 0.5m + distance * 0.1m) * 2.5m;
}

public class FreightShipping : IShippingStrategy
{
    public decimal CalculateCost(decimal weight, decimal distance)
        => weight > 100 ? weight * 0.3m : weight * 0.5m;
}

// The calling code depends only on the interface -
// it never changes regardless of which strategy is used
public class ShippingCalculator
{
    private readonly IShippingStrategy _strategy;

    public ShippingCalculator(IShippingStrategy strategy)
        => _strategy = strategy;

    public decimal Calculate(decimal weight, decimal distance)
        => _strategy.CalculateCost(weight, distance);
}

// Swapping the algorithm is just swapping which
// strategy gets injected - ShippingCalculator itself
// never needs to change
var standard = new ShippingCalculator(new StandardShipping());
var express = new ShippingCalculator(new ExpressShipping());

Console.WriteLine(standard.Calculate(10, 50));
Console.WriteLine(express.Calculate(10, 50));
Enter fullscreen mode Exit fullscreen mode

Strategy earns its place once there are genuinely multiple interchangeable algorithms for the same task, and the choice between them needs to stay flexible - configurable at runtime, or extensible without touching existing code. For exactly one algorithm with no realistic alternative on the horizon, introducing an interface and a strategy class is unnecessary ceremony around code that could just be a plain method.

Pattern 2: Observer

The problem Observer solves is defining a one-to-many relationship between objects, so that when one object's state changes, all of its dependents are notified automatically, without the object doing the notifying needing to know anything about who is listening.

Think of a YouTube channel and its subscribers. The channel publishes a new video, and every subscriber gets notified - the channel has no idea how many subscribers exist, who they are, or what each one does in response to the notification. Someone can subscribe or unsubscribe at any time without the channel's own behavior changing at all. Observer is exactly this relationship in code.

// The observer interface - anything that wants to
// react to changes implements this
public interface IOrderObserver
{
    void OnOrderPlaced(string orderId, decimal amount);
}

// Concrete observers - each reacts independently
public class EmailNotifier : IOrderObserver
{
    public void OnOrderPlaced(string orderId, decimal amount)
        => Console.WriteLine($"Email sent for order {orderId}");
}

public class InventoryUpdater : IOrderObserver
{
    public void OnOrderPlaced(string orderId, decimal amount)
        => Console.WriteLine($"Inventory updated for order {orderId}");
}

public class AnalyticsTracker : IOrderObserver
{
    public void OnOrderPlaced(string orderId, decimal amount)
        => Console.WriteLine($"Analytics event logged: {amount:C}");
}

// The subject - maintains a list of observers and
// notifies all of them, without knowing what any
// individual observer actually does
public class OrderService
{
    private readonly List<IOrderObserver> _observers = new();

    public void Subscribe(IOrderObserver observer)
        => _observers.Add(observer);

    public void Unsubscribe(IOrderObserver observer)
        => _observers.Remove(observer);

    public void PlaceOrder(string orderId, decimal amount)
    {
        Console.WriteLine($"Order {orderId} placed");
        // Notify every subscriber - OrderService has
        // no idea what any of them actually do
        foreach (var observer in _observers)
            observer.OnOrderPlaced(orderId, amount);
    }
}

var orderService = new OrderService();
orderService.Subscribe(new EmailNotifier());
orderService.Subscribe(new InventoryUpdater());
orderService.Subscribe(new AnalyticsTracker());

// One call triggers all three observers independently
orderService.PlaceOrder("ORD-001", 49.99m);

// Adding a fourth observer requires zero changes
// to OrderService itself
orderService.Subscribe(new AnalyticsTracker());
Enter fullscreen mode Exit fullscreen mode

This is the same underlying idea as an Azure Service Bus Topic with multiple subscriptions, covered in an earlier post in this blog's Azure series - one publisher, many independent subscribers, each reacting without the publisher knowing or caring who they are. Observer is that same pattern applied in-process rather than across distributed systems.

Observer earns its place when multiple, genuinely independent parts of a system need to react to the same event, and the set of things reacting to it may grow or change over time. C#'s built-in event keyword and delegates already implement a lightweight version of this pattern natively - reach for a full custom Observer implementation mainly when the built-in event model doesn't fit, such as needing dynamic subscribe and unsubscribe management beyond what a simple event handles cleanly.

Behavioral Pattern
Five behavioral patterns, five different answers to the same question — how should these objects actually talk to each other?

Pattern 3: Command

The problem Command solves is turning a request or an action into a standalone object, so that request can be queued, logged, delayed, passed around, or undone - things that are difficult or impossible to do with a plain, immediate method call.

Think of a restaurant order slip. A waiter doesn't walk into the kitchen and cook the dish personally on the spot - they write the order onto a slip, which becomes a physical object that can be handed off, queued behind other orders, or handled by whichever cook is free. The order slip is the request, turned into an object, decoupled from who executes it and when.

// The command interface
public interface ICommand
{
    void Execute();
    void Undo();
}

// A simple receiver - the thing commands act upon
public class TextDocument
{
    private readonly StringBuilder _content = new();

    public void Append(string text) => _content.Append(text);
    public void RemoveLast(int length)
        => _content.Length = Math.Max(0, _content.Length - length);

    public string Content => _content.ToString();
}

// A concrete command - captures everything needed
// to execute AND undo one specific action
public class AppendTextCommand : ICommand
{
    private readonly TextDocument _document;
    private readonly string _text;

    public AppendTextCommand(TextDocument document, string text)
    {
        _document = document;
        _text = text;
    }

    public void Execute() => _document.Append(_text);
    public void Undo() => _document.RemoveLast(_text.Length);
}

// An invoker - maintains history, enabling undo
// without needing to know what any command actually does
public class CommandHistory
{
    private readonly Stack<ICommand> _history = new();

    public void ExecuteCommand(ICommand command)
    {
        command.Execute();
        _history.Push(command);
    }

    public void UndoLast()
    {
        if (_history.Count > 0)
            _history.Pop().Undo();
    }
}

var document = new TextDocument();
var history = new CommandHistory();

history.ExecuteCommand(new AppendTextCommand(document, "Hello"));
history.ExecuteCommand(new AppendTextCommand(document, " World"));

Console.WriteLine(document.Content); // "Hello World"

history.UndoLast();
Console.WriteLine(document.Content); // "Hello"
Enter fullscreen mode Exit fullscreen mode

Command earns its place when a request genuinely needs to be treated as a first-class object - queued for later execution, logged for auditing, transmitted somewhere else, or undone. For a simple, immediate action with no need for any of those capabilities, a direct method call is simpler and Command adds ceremony without solving a real problem.

Pattern 4: State

The problem State solves is letting an object change its own behavior when its internal state changes, so the object appears to change its class at runtime, without a large if/else or switch statement checking "what state am I currently in" scattered throughout the codebase.

Think of a traffic light. The same physical object behaves completely differently depending on which state it's currently in - red means stop, green means go, yellow means prepare to stop - and it transitions through a defined sequence of those states over time. The light doesn't have one giant method with an if/else for every color; each state's behavior is genuinely distinct.

// The state interface
public interface IOrderState
{
    void Next(OrderContext context);
    string StatusName { get; }
}

// Each concrete state knows what state comes next,
// and what its own status name is - the logic for
// each state lives in exactly one place
public class PendingState : IOrderState
{
    public string StatusName => "Pending";
    public void Next(OrderContext context)
        => context.SetState(new ShippedState());
}

public class ShippedState : IOrderState
{
    public string StatusName => "Shipped";
    public void Next(OrderContext context)
        => context.SetState(new DeliveredState());
}

public class DeliveredState : IOrderState
{
    public string StatusName => "Delivered";
    public void Next(OrderContext context)
        => Console.WriteLine("Order already delivered - no next state");
}

// The context - delegates behavior to its
// current state object entirely
public class OrderContext
{
    private IOrderState _state = new PendingState();

    public void SetState(IOrderState state) => _state = state;
    public string CurrentStatus => _state.StatusName;

    public void Advance() => _state.Next(this);
}

var order = new OrderContext();
Console.WriteLine(order.CurrentStatus); // Pending

order.Advance();
Console.WriteLine(order.CurrentStatus); // Shipped

order.Advance();
Console.WriteLine(order.CurrentStatus); // Delivered

// WITHOUT State, this would typically be one method
// with a switch on a status enum, repeated everywhere
// the order's behavior needs to vary by status -
// State moves each state's specific logic into its
// own class instead
Enter fullscreen mode Exit fullscreen mode

State earns its place when an object has several genuinely distinct states, each with meaningfully different behavior, and the transitions between them follow real rules. For two states with a trivial difference in behavior, a simple boolean flag and an if statement is often more direct - State's real value shows up once the number of states and the complexity of behavior per state both grow past what a flag or a simple enum switch can comfortably express.

Pattern 5: Template Method

The problem Template Method solves is defining the skeleton of an algorithm in a base class - the fixed sequence of steps - while letting subclasses override specific individual steps without changing the overall structure or order.

Think of a recipe template for making a hot beverage. The overall sequence is always the same - boil water, brew, pour into a cup, add condiments - but the specific brewing step and the specific condiments differ between tea and coffee. The recipe's structure stays fixed; only certain steps within it vary by beverage.

// The abstract base class defines the fixed
// algorithm skeleton
public abstract class BeverageTemplate
{
    // The template method - defines the FIXED sequence
    public void Prepare()
    {
        BoilWater();
        Brew();
        PourInCup();
        AddCondiments();
    }

    // Steps common to every beverage - implemented once
    private void BoilWater() => Console.WriteLine("Boiling water");
    private void PourInCup() => Console.WriteLine("Pouring into cup");

    // Steps that vary by beverage - left abstract,
    // subclasses MUST provide these
    protected abstract void Brew();
    protected abstract void AddCondiments();
}

public class Tea : BeverageTemplate
{
    protected override void Brew()
        => Console.WriteLine("Steeping tea bag");

    protected override void AddCondiments()
        => Console.WriteLine("Adding lemon");
}

public class Coffee : BeverageTemplate
{
    protected override void Brew()
        => Console.WriteLine("Brewing coffee grounds");

    protected override void AddCondiments()
        => Console.WriteLine("Adding milk and sugar");
}

// The overall SEQUENCE is guaranteed identical for
// both beverages - only the specific steps differ
BeverageTemplate tea = new Tea();
tea.Prepare();

BeverageTemplate coffee = new Coffee();
coffee.Prepare();
Enter fullscreen mode Exit fullscreen mode

This same idea already appeared earlier in this series without being named yet - the NotificationCreator class in Part 1's Factory Method section used exactly this pattern, with a base class method calling an abstract method that subclasses fill in. Template Method and Factory Method frequently show up together in the same class hierarchy in real code.

Template Method earns its place when several related classes share the same overall sequence of steps, but differ in the specific implementation of one or more of those steps - and it's genuinely important that the sequence itself can't be reordered or skipped by a subclass. For classes with only superficially similar behavior and no real shared sequence, forcing them into a common template can create an awkward, overly rigid hierarchy instead of solving a real problem.

Key Lessons

Strategy makes an algorithm swappable at runtime through a shared interface - the same underlying idea already seen twice earlier in this series as IDiscount and a custom IComparer<T>, just under its formal design pattern name.

Observer lets multiple independent parts of a system react to the same event without the publisher knowing who's listening - conceptually the same relationship as an Azure Service Bus Topic, applied in-process instead of across distributed systems.

Command turns a request into an object, which is what actually makes queuing, logging, delaying, and undo possible - a direct method call can't do any of those things on its own.

State moves state-specific behavior into its own class per state, replacing a scattered if/else or switch on "what state am I in" with each state owning its own logic.

Template Method fixes the overall sequence of an algorithm in a base class while leaving specific steps for subclasses to fill in - the parent controls order, subclasses control detail.

The Series, In Full

This closes out the three-part design patterns series. Part 1 covered Creational patterns - Singleton, Factory Method, Builder, Abstract Factory. Part 2 covered Structural patterns - Adapter, Decorator, Facade, Composite. Part 3 covered Behavioral patterns - Strategy, Observer, Command, State, Template Method. Thirteen patterns total, each with the problem it solves, an analogy, real C# code, and honest guidance on when it's actually worth using, not just when it exists as an option.

Summary

Behavioral patterns solve one consistent underlying question - how should these objects actually communicate and share responsibility - in five different ways depending on the specific shape of the problem. Strategy swaps an algorithm without touching the caller. Observer lets many independent parts react to one event. Command turns a request into something that can be queued, logged, or undone. State moves state-specific behavior into dedicated classes. Template Method fixes a sequence while leaving specific steps open. As with every pattern across this series, each one adds real indirection that only earns its place once the specific problem it solves is genuinely present in the code, not applied by default just because the pattern exists.


Originally published at TechStack Blog: https://www.techstackblog.com/post.html?slug=design-patterns-behavioral-csharp

Part 1 of this series (Creational patterns): https://www.techstackblog.com/post.html?slug=design-patterns-creational-csharp
Part 2 of this series (Structural patterns): https://www.techstackblog.com/post.html?slug=design-patterns-structural-csharp

More from TechStack Blog: CS Fundamentals: https://www.techstackblog.com/category.html?cat=cs-fundamentals
C# / .NET: https://www.techstackblog.com/category.html?cat=csharp

Top comments (0)