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
- What problem does the Strategy pattern solve in C#?
- How does the textbook Strategy look in .NET 10?
- When is a Func delegate enough?
- When does an injected interface earn its keep?
- How does Strategy combine with Factory Method to pick at runtime?
- When does the Strategy pattern misfire?
- How does Strategy compare to State and Command?
- What does a real .NET 10 example look like?
- 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:
- Discount / pricing rules. Percent-off, BOGO, free shipping, tiered. The cart calculator runs whichever rule applies.
- Sorting / comparison.
IComparer<T>,IEqualityComparer<T>. The caller picks the comparison; the sort never knows the details. - 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:
- The strategy needs dependencies. A
TaxStrategythat callsIGeoServicecannot live in aFunc<>cleanly; theFunc<>closes over the dependency awkwardly. - The strategy has multiple methods. "Compute discount, plus
decide whether it is stackable, plus produce a label" is three
methods on one interface. A
Func<>is one. - The strategy must be discoverable. Tooling (DI scanner,
serialiser, admin UI) needs to find every implementation of
IDiscountStrategy. Anonymous lambdas hide. - The strategy carries state. A
RateLimiterstrategy remembering recent calls is a class, not a delegate.
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:
- One-strategy strategies. A single concrete class behind a trivial interface is a wrapper that adds no value. Drop it until you have a second variant.
- Strategies that mutate shared state. A
Strategyis expected to be stateless or self-contained. Mutating shared state breaks the substitution promise. - Strategies that are really states. If your
Strategypicks itself based on the object's lifecycle stage, you have State instead. Drop the manual selection and use a state machine.
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.
Where should you read next in this series?
- Previous: State — same shape but swaps based on lifecycle.
- Next: Template Method — when the skeleton is shared and only one or two steps vary.
- Cross-reference: Factory Method — the natural way to pick a Strategy at runtime.
- Cross-reference: Command — when you want the action to be data, not just an algorithm.
- Decision tree: How to choose the right design pattern.
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?
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?
Should I implement Strategy or use a switch expression?
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?
IDiscountStrategy keyed by region, then runs .Apply(cart) on the returned instance. In .NET 8+ this is exactly what IKeyedServiceProvider.GetKeyedService<IDiscountStrategy>(region) is.