Behavioral Beginner 7 min read

Strategy Pattern in C#: Func, DI, or Interface?

Strategy pattern in C# / .NET 10: when a Func delegate is enough, when an injected interface earns its keep, and how Strategy differs from State and Command.

Table of contents
  1. What problem does the Strategy pattern solve in C#?
  2. How does the textbook Strategy look in .NET 10?
  3. When is a Func delegate enough?
  4. When does an injected interface earn its keep?
  5. How does Strategy combine with Factory Method to pick at runtime?
  6. When does the Strategy pattern misfire?
  7. How does Strategy compare to State and Command?
  8. What does a real .NET 10 example look like?
  9. Where should you read next in this series?

The shop has three discount algorithms today: PercentOff, BuyOneGetOne, FreeShipping. Tomorrow there will be a fourth. The first version is a switch over a string in OrderTotalsCalculator. Six months later the calculator depends on date helpers, customer segments, and four other services — all imported only because some discount variant uses them.

The Strategy pattern is the answer. Each algorithm becomes its own object behind a small interface. The caller picks which one to use; the calculator stays clean. Modern C# gives you two ergonomic shapes: a one-line Func<> for trivial strategies, and an injected interface for richer ones. This article is about choosing between the two — and noticing that you used Strategy the moment you injected an IComparer<T> into a sort.

This is the article we cross-referenced from Singleton, Factory Method, Bridge, Command, State, and several others.

What problem does the Strategy pattern solve in C#?

The pattern earns its place when the caller's logic varies in exactly one well-defined step, and that step has multiple interchangeable implementations. Three concrete shapes:

  1. Discount / pricing rules. Percent-off, BOGO, free shipping, tiered. The cart calculator runs whichever rule applies.
  2. Sorting / comparison. IComparer<T>, IEqualityComparer<T>. The caller picks the comparison; the sort never knows the details.
  3. Serialisation formats. Same data, JSON or Protobuf or CSV. The serialiser is a Strategy.

What is not a Strategy problem: "behaviour changes when state transitions" — that is State. "I want to record an action for replay" — that is Command. Strategy is specifically about one algorithm, swappable by the caller.

How does the textbook Strategy look in .NET 10?

A small interface and one class per algorithm:

public sealed record CartContext(string[] Skus, decimal Subtotal, string Region);

public interface IDiscountStrategy
{
    decimal Apply(CartContext ctx);
}

public sealed class PercentOff(decimal Percent) : IDiscountStrategy
{
    public decimal Apply(CartContext ctx) => ctx.Subtotal * (1m - Percent);
}

public sealed class FreeShipping : IDiscountStrategy
{
    public decimal Apply(CartContext ctx) => ctx.Subtotal;   // shipping is added later
}

public sealed class BuyOneGetOne(string Sku, decimal UnitPrice) : IDiscountStrategy
{
    public decimal Apply(CartContext ctx)
    {
        var count = ctx.Skus.Count(s => s == Sku);
        return ctx.Subtotal - (count / 2) * UnitPrice;
    }
}

The caller takes the strategy as a dependency and runs it:

public sealed class CartCalculator
{
    private readonly IDiscountStrategy _discount;
    public CartCalculator(IDiscountStrategy discount) => _discount = discount;
    public decimal GrandTotal(CartContext ctx) => _discount.Apply(ctx);
}

The structural picture:

classDiagram
    class IDiscountStrategy {
        <<interface>>
        +Apply(ctx) decimal
    }
    class PercentOff
    class FreeShipping
    class BuyOneGetOne
    IDiscountStrategy <|.. PercentOff
    IDiscountStrategy <|.. FreeShipping
    IDiscountStrategy <|.. BuyOneGetOne

    class CartCalculator
    CartCalculator o-- IDiscountStrategy : uses

Adding a new strategy is one new class plus one DI line.

When is a Func delegate enough?

For trivial single-method strategies, the interface is overhead. A Func<TInput, TOutput> is the same pattern with one less file:

public sealed class CartCalculator
{
    private readonly Func<CartContext, decimal> _discount;
    public CartCalculator(Func<CartContext, decimal> discount) => _discount = discount;
    public decimal GrandTotal(CartContext ctx) => _discount(ctx);
}

// Composition root
builder.Services.AddSingleton<Func<CartContext, decimal>>(ctx => ctx.Subtotal * 0.9m);

The trade-off is real: a Func<> cannot have a name, cannot be discovered by reflection, cannot inject services into itself, and cannot be unit-tested without instantiating the closure. Promote to an interface the moment you want any of those.

When does an injected interface earn its keep?

Reach for the interface when at least one of these is true:

How does Strategy combine with Factory Method to pick at runtime?

The strategies above all live as DI registrations; the caller picks one by key:

public enum DiscountKind { PercentOff, BuyOneGetOne, FreeShipping }

builder.Services.AddKeyedSingleton<IDiscountStrategy, PercentOff>(DiscountKind.PercentOff,
    (_, _) => new PercentOff(0.10m));
builder.Services.AddKeyedSingleton<IDiscountStrategy, FreeShipping>(DiscountKind.FreeShipping,
    (_, _) => new FreeShipping());
builder.Services.AddKeyedSingleton<IDiscountStrategy, BuyOneGetOne>(DiscountKind.BuyOneGetOne,
    (_, _) => new BuyOneGetOne("BOOK", 9.99m));

// Caller
public sealed class Checkout
{
    private readonly IKeyedServiceProvider _sp;
    public Checkout(IKeyedServiceProvider sp) => _sp = sp;

    public decimal ComputeTotal(DiscountKind kind, CartContext ctx)
    {
        var strategy = _sp.GetRequiredKeyedService<IDiscountStrategy>(kind);
        return strategy.Apply(ctx);
    }
}

This is exactly the Factory Method pattern — keyed services pick the right Strategy at runtime. The two patterns combine naturally; recognising both lets you read AddKeyedSingleton<> calls correctly.

When does the Strategy pattern misfire?

Three traps:

How does Strategy compare to State and Command?

Pattern What it represents Who picks
Strategy One algorithm among peers Caller / DI
State Whole behaviour at a lifecycle stage The object's own state machine
Command One action with inputs Caller, sent to a handler

The cleanest split: Strategy is how (algorithm); State is what (lifecycle); Command is what to do once (action with inputs). Strategy is the lightest of the three; promote to the others when the shape stops fitting.

What does a real .NET 10 example look like?

A complete shape — keyed strategies for discount, used by the checkout calculator, plus a Func<> for a trivial shipping discount that does not need its own class:

public sealed record CartContext(string[] Skus, decimal Subtotal, string Region);

public interface IDiscountStrategy
{
    decimal Apply(CartContext ctx);
}

public sealed class PercentOff : IDiscountStrategy
{
    private readonly decimal _percent;
    public PercentOff(decimal percent) => _percent = percent;
    public decimal Apply(CartContext ctx) => ctx.Subtotal * (1m - _percent);
}

public sealed class TieredDiscount : IDiscountStrategy
{
    private readonly IClock _clock;            // dependency: a delegate could not inject this cleanly
    public TieredDiscount(IClock clock) => _clock = clock;
    public decimal Apply(CartContext ctx)
    {
        var dayBoost = _clock.UtcNow.DayOfWeek == DayOfWeek.Monday ? 0.05m : 0m;
        var baseRate = ctx.Subtotal switch
        {
            < 50m  => 0.00m,
            < 200m => 0.05m,
            _      => 0.10m,
        };
        return ctx.Subtotal * (1m - baseRate - dayBoost);
    }
}

// Composition root
builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddKeyedSingleton<IDiscountStrategy, PercentOff>("flat10",
    (_, _) => new PercentOff(0.10m));
builder.Services.AddKeyedSingleton<IDiscountStrategy, TieredDiscount>("tiered");

// Trivial shipping calculator — Func is enough
builder.Services.AddSingleton<Func<decimal, decimal>>(subtotal =>
    subtotal > 100m ? 0m : 7.99m);

// Caller
public sealed class Checkout
{
    private readonly IKeyedServiceProvider _sp;
    private readonly Func<decimal, decimal> _shipping;
    public Checkout(IKeyedServiceProvider sp, Func<decimal, decimal> shipping)
        => (_sp, _shipping) = (sp, shipping);

    public decimal Total(string discountKey, CartContext ctx)
    {
        var discounted = _sp.GetRequiredKeyedService<IDiscountStrategy>(discountKey).Apply(ctx);
        return discounted + _shipping(discounted);
    }
}

// Test
[Fact]
public void Tiered_discount_applies_5pct_in_50_to_200_range()
{
    var clock = new Mock<IClock>();
    clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 5, 5, 0, 0, 0, DateTimeKind.Utc)); // Tuesday
    var sut = new TieredDiscount(clock.Object);
    var result = sut.Apply(new CartContext(Array.Empty<string>(), 150m, "US"));
    Assert.Equal(142.5m, result);   // 150 * (1 - 0.05)
}

What you read: each strategy is small, has its own dependencies or none, and is testable in isolation. The caller never imports PercentOff. Adding a new strategy is one new class plus one DI line.

A practical observation: Strategy is probably the most-used behavioural pattern in modern .NET, and almost nobody calls it by name. Every IComparer<T> you pass to OrderBy, every Func<> you inject, every keyed service is the pattern. Once you see it everywhere, the question becomes "should this be a Func<> or an interface?" — and that is the only design decision left.

Frequently asked questions

When is a Func delegate enough versus a full IStrategy interface?
A Func<> is enough when the strategy is a single method, has no dependencies, and is not worth a unit test of its own. An interface earns its keep when the strategy has multiple methods, holds state, depends on injected services, or needs to be discoverable across the codebase. Reach for Func<> first; promote to an interface when you find yourself wanting to inject ILogger into a delegate.
How is Strategy different from State?
Same shape, different intent. State swaps the whole behaviour of an object based on its lifecycle stage; transitions are part of the abstraction. Strategy swaps one algorithm among interchangeable peers; the caller stays the same. State is a state machine; Strategy is a plug-in slot.
Should I implement Strategy or use a switch expression?
Use a switch expression when the algorithms are small, closed (you control all variants), and never need their own dependencies. Promote to Strategy when any of those breaks: a third-party algorithm joins, a variant needs IDateTimeProvider injected, or you want each variant testable in isolation. The switch is fine; Strategy buys testability.
How does Strategy combine with Factory Method?
Factory Method picks which Strategy to instantiate at runtime; Strategy then runs the chosen algorithm. The two compose cleanly: caller asks the factory for IDiscountStrategy keyed by region, then runs .Apply(cart) on the returned instance. In .NET 8+ this is exactly what IKeyedServiceProvider.GetKeyedService<IDiscountStrategy>(region) is.