The Strategy Pattern for C# Developers

The Strategy pattern is one of the more useful patterns in the Gang of Four catalog. It lets you swap out behavior at runtime without changing the code that uses it. If you’ve ever looked at a growing pile of if statements and thought “there has to be a better way”, this pattern might be what you’re looking for.

Let’s Define the Pattern

The Strategy pattern is about encapsulating interchangeable behavior behind a common interface. Instead of hard-coding decisions into your classes, you extract the varying behavior into separate strategy classes. The consumer picks which strategy to use, and the rest of the code doesn’t care which one it got.

Think of it as “variation without conditionals”. You still have multiple ways to do something, but the branching happens at composition time, not scattered throughout your business logic.

The key pieces:

  • A common interface that defines the behavior
  • Multiple implementations of that interface (the strategies)
  • A consumer that uses the interface without knowing which implementation it has

The Problem It Solves

Consider a pricing calculator that started simple but grew over time:

public decimal CalculatePrice(Order order, string customerType)
{
    decimal price = order.BasePrice;

    if (customerType == "Regular")
    {
        // no discount
    }
    else if (customerType == "Premium")
    {
        price *= 0.9m; // 10% off
    }
    else if (customerType == "VIP")
    {
        price *= 0.8m; // 20% off
    }
    else if (customerType == "Employee")
    {
        price *= 0.5m; // 50% off
    }
    // and it keeps growing...

    return price;
}

This pattern shows up everywhere: pricing, shipping calculations, validation rules, scoring algorithms. It starts with two or three cases and grows until someone adds a feature flag that never gets removed.

The problems compound:

  • Every new customer type means editing this method
  • Testing requires setting up all the conditional paths
  • The business rules are buried in procedural code
  • Changes ripple across multiple call sites if this logic is duplicated

Core Structure and Roles

The Strategy pattern has three parts:

Strategy interface: The contract that all strategies implement.

public interface IPricingStrategy
{
    decimal CalculatePrice(Order order);
}

Concrete strategies: The implementations that contain the actual behavior.

public class RegularPricingStrategy : IPricingStrategy
{
    public decimal CalculatePrice(Order order) => order.BasePrice;
}

public class PremiumPricingStrategy : IPricingStrategy
{
    public decimal CalculatePrice(Order order) => order.BasePrice * 0.9m;
}

public class VipPricingStrategy : IPricingStrategy
{
    public decimal CalculatePrice(Order order) => order.BasePrice * 0.8m;
}

public class EmployeePricingStrategy : IPricingStrategy
{
    public decimal CalculatePrice(Order order) => order.BasePrice * 0.5m;
}

Context: The class that uses the strategy. It holds a reference to the interface and delegates work to it.

public class PricingCalculator
{
    private readonly IPricingStrategy _strategy;

    public PricingCalculator(IPricingStrategy strategy)
    {
        _strategy = strategy;
    }

    public decimal Calculate(Order order)
    {
        return _strategy.CalculatePrice(order);
    }
}

The context should not care which strategy it uses. It just calls the interface. This is what makes the pattern work.

Now each pricing strategy is its own class. Adding a new customer type means adding a new class, not editing existing code. The “Employee” case can have whatever logic it needs without cluttering the others.

Strategy Selection Mechanisms

You still need to pick which strategy to use. There are a few common approaches.

Factory-based selection:

public class PricingStrategyFactory
{
    public IPricingStrategy Create(string customerType)
    {
        return customerType switch
        {
            "Regular" => new RegularPricingStrategy(),
            "Premium" => new PremiumPricingStrategy(),
            "VIP" => new VipPricingStrategy(),
            "Employee" => new EmployeePricingStrategy(),
            _ => throw new ArgumentException($"Unknown customer type: {customerType}")
        };
    }
}

Dictionary/lookup-based selection:

public class PricingStrategyResolver
{
    private readonly Dictionary<string, IPricingStrategy> _strategies;

    public PricingStrategyResolver(IEnumerable<IPricingStrategy> strategies)
    {
        _strategies = strategies.ToDictionary(s => s.GetType().Name.Replace("PricingStrategy", ""));
    }

    public IPricingStrategy Resolve(string customerType)
    {
        if (_strategies.TryGetValue(customerType, out var strategy))
            return strategy;
        
        throw new ArgumentException($"Unknown customer type: {customerType}");
    }
}

Policy or rules-driven selection: For more complex scenarios, you might have a rules engine that picks the strategy based on multiple factors (customer type, order size, time of day, etc.).

The key is to centralize the selection logic. Don’t scatter if (customerType == "VIP") checks throughout your codebase.

Strategy vs Similar Patterns

Strategy vs State: Both use polymorphism to vary behavior, but the intent differs. Strategy is about choosing an algorithm. State is about changing behavior as an object moves through its lifecycle. A state machine transitions between states; a strategy is selected once and used.

Strategy vs Template Method: Template Method uses inheritance. The base class defines the skeleton, subclasses fill in the blanks. Strategy uses composition. The context holds a reference to a strategy object. Composition is generally more flexible.

Strategy vs simple function delegation: In C#, you can often pass a Func<> instead of creating a strategy interface. For simple cases, that’s fine. Strategies become more valuable when the behavior needs dependencies, configuration, or when you want to name the behavior explicitly.

People blur these lines in practice, and that’s usually okay. The patterns are tools, not laws.

Strategy with Dependency Injection

Strategies work well with DI. You can register all your strategies and let the container manage their lifetimes.

services.AddScoped<RegularPricingStrategy>();
services.AddScoped<PremiumPricingStrategy>();
services.AddScoped<VipPricingStrategy>();
services.AddScoped<EmployeePricingStrategy>();

services.AddScoped<IEnumerable<IPricingStrategy>>(sp => new IPricingStrategy[]
{
    sp.GetRequiredService<RegularPricingStrategy>(),
    sp.GetRequiredService<PremiumPricingStrategy>(),
    sp.GetRequiredService<VipPricingStrategy>(),
    sp.GetRequiredService<EmployeePricingStrategy>()
});

Or register them all as the interface:

services.AddScoped<IPricingStrategy, RegularPricingStrategy>();
services.AddScoped<IPricingStrategy, PremiumPricingStrategy>();
services.AddScoped<IPricingStrategy, VipPricingStrategy>();
services.AddScoped<IPricingStrategy, EmployeePricingStrategy>();

Then inject IEnumerable<IPricingStrategy> where you need to select from them.

Be careful not to turn your selection logic into a service locator. The factory or resolver should be the only place that picks strategies. The rest of your code should receive the strategy it needs through constructor injection.

When Strategy Improves Testability

Strategies make testing easier in a few ways:

Test each strategy independently: Each strategy is a small, focused class. You can test it in isolation without setting up the entire system.

[Fact]
public void VipPricingStrategy_Applies20PercentDiscount()
{
    var strategy = new VipPricingStrategy();
    var order = new Order { BasePrice = 100m };

    var price = strategy.CalculatePrice(order);

    Assert.Equal(80m, price);
}

Eliminate complex test setup: Without strategies, you’d need to test every branch of that big if statement in one method. With strategies, each test class focuses on one behavior.

Make business rules explicit: A class named VipPricingStrategy is self-documenting. The rule is right there in the name. When a test fails, you know exactly which business rule broke.

Common Pitfalls and Code Smells

Too many tiny strategies: If you have 50 strategies and each one is a single line, you might be over-engineering. Sometimes a simple lookup table or switch expression is clearer.

Strategies that still contain conditionals: If your strategy has its own if statements checking the same things the context used to check, you haven’t really solved the problem. You’ve just moved it.

Contexts that leak knowledge of concrete strategies: If the context has special handling for specific strategies, the abstraction is broken. The context should treat all strategies the same.

Overengineering simple branching: Not every if statement needs to become a strategy. If you have two cases and they’re unlikely to change, a simple conditional is fine.

When Not to Use Strategy

Behavior that rarely changes: If the logic has been stable for years and there’s no reason to expect new variations, a strategy adds indirection without benefit.

One-off conditional logic: A single if in one place doesn’t need a pattern. Patterns are for recurring problems.

Performance-critical hot paths: Each strategy call is a virtual method dispatch. In tight loops processing millions of items, that overhead might matter. Profile before optimizing, but be aware of it.

Evolution Over Time

One of the nice things about strategies is how they evolve.

Adding new strategies: You create a new class, register it, and update the selection logic. Existing strategies don’t change.

Retiring strategies: When a strategy is no longer needed, you remove it from registration and delete the class. No need to hunt through conditionals.

Versioning strategies: If you need backward compatibility (say, for stored configuration that references strategy names), you can keep old strategies around or create adapters. The interface stays stable.

Practical Guidelines

A few things to keep in mind:

  • One reason to change per strategy. Each strategy should encapsulate one business rule or algorithm. If a strategy has multiple reasons to change, split it.
  • Name strategies after business intent, not mechanics. VipPricingStrategy is better than DiscountStrategy. The name should tell you what business rule it represents.
  • Centralize selection. Put the “which strategy do I use” logic in one place. Don’t scatter it.
  • Prefer clarity over extensibility fantasies. Don’t add a strategy pattern because you might need flexibility someday. Add it when you actually have multiple behaviors that vary.

The Strategy pattern is a solid tool for managing variation in your code. It keeps business rules explicit, makes testing easier, and lets you add new behaviors without editing existing code. Use it when you have real variation to manage, and skip it when a simple conditional will do.

Avatar
Alan P. Barber
Software Developer, Computer Scientist, Scrum Master, & Crohn’s Disease Fighter

I specialize in Software Development with a focus on Architecture and Design.

comments powered by Disqus
Previous

Related