You've been doing this since Foundations 028. Today it gets a name.
Foundations lesson 028 taught you to favor composition over inheritance by injecting an ILogger instead of inheriting from a logger base class. Intermediate 081 taught you Dependency Inversion by injecting an IPaymentGateway instead of hard-coding Stripe. Intermediate's higher-order function lessons showed you passing a Func<T, TResult> as a parameter so a method's behavior could vary without rewriting the method. All three of those were, in a very real sense, the same idea wearing different clothes.
That idea has a name: the Strategy Pattern. It's the Behavioral pattern — one of the three GoF categories from the design patterns lesson earlier in this Part — for making an algorithm swappable behind a common interface, so the code that uses the algorithm never has to know or care which specific version it's running.
In this lesson, you'll learn the formal GoF shape of Strategy, see it side by side with the lightweight Func<T>-parameter version you already know, and get honest guidance on which one to reach for.
The Strategy Pattern encapsulates a family of interchangeable algorithms behind a common interface, and lets the algorithm actually used be chosen — and swapped — independently of the code that uses it. The class doing the work (the "context") holds a reference to a strategy and calls it, without knowing which concrete strategy it's holding.
Define a family of algorithms, encapsulate each one behind a shared interface, and make them interchangeable. Strategy lets the algorithm vary independently from the clients that use it — a direct application of depending on an abstraction rather than a concrete implementation, applied specifically to behavior rather than to infrastructure like a database or payment provider.
Composition over inheritance (028): inject the behavior instead of subclassing to get it. Dependency Inversion (081): the context depends on an abstraction, not a concrete class. Passing a Func<Order, decimal> as a parameter (099, 104): a single-method strategy, expressed as a delegate instead of an interface. All three ARE Strategy — this lesson just gives the shape a name and shows its classic textbook form.
Without Strategy, varying behavior tends to end up as a growing conditional buried inside one method:
// WITHOUT Strategy — every new discount rule means editing this method
public decimal CalculateDiscount(Order order, string customerTier)
{
if (customerTier == "gold") return order.Total * 0.20m;
if (customerTier == "silver") return order.Total * 0.10m;
if (customerTier == "new-customer" && order.Total > 100) return 15m;
// ...growing forever, mixing unrelated discount rules into one method,
// violating Open/Closed — you can't add a rule without editing existing code
return 0m;
}
This couples every discount rule's logic into one method, makes each rule impossible to test in isolation, and means adding a new discount type requires editing code that already works — risking regressions in rules that had nothing to do with the change.
Strategy has a classic, heavyweight GoF form, and a lightweight, idiomatic modern-C# form. Both solve exactly the same problem — they differ only in ceremony.
public interface IDiscountStrategy
{
decimal Calculate(Order order);
}
public sealed class GoldTierDiscount : IDiscountStrategy
{
public decimal Calculate(Order order) =>
order.Total * 0.20m;
}
public sealed class OrderProcessor(IDiscountStrategy discount)
{
public decimal GetFinalTotal(Order order) =>
order.Total - discount.Calculate(order);
}
public sealed class OrderProcessor(
Func<Order, decimal> calculateDiscount)
{
public decimal GetFinalTotal(Order order) =>
order.Total - calculateDiscount(order);
}
// No interface, no class per strategy — just a function:
var processor = new OrderProcessor(
order => order.Total * 0.20m);
Notice: both versions let OrderProcessor vary its discount logic without changing OrderProcessor itself. The interface version needs a whole class per strategy; the delegate version needs only a lambda. Mechanically, they're the same pattern — IDiscountStrategy.Calculate and Func<Order, decimal> are both, structurally, "a single swappable operation with one input and one output."
Order, returns a decimal — that shared shape becomes either an interface method or a delegate typeOrderProcessor receives whichever strategy applies — via constructor, exactly like injecting IPaymentGateway in lesson 081public interface IDiscountStrategy
{
decimal Calculate(Order order);
}
public sealed class GoldTierDiscount : IDiscountStrategy
{
public decimal Calculate(Order order) => order.Total * 0.20m;
}
public sealed class SilverTierDiscount : IDiscountStrategy
{
public decimal Calculate(Order order) => order.Total * 0.10m;
}
public sealed class NoDiscount : IDiscountStrategy
{
public decimal Calculate(Order order) => 0m;
}
public sealed class OrderProcessor(IDiscountStrategy discount)
{
public decimal GetFinalTotal(Order order) => order.Total - discount.Calculate(order);
}
// ── Usage ──
var goldProcessor = new OrderProcessor(new GoldTierDiscount());
var total = goldProcessor.GetFinalTotal(order); // order.Total minus 20%
Code → Meaning → Result: OrderProcessor never mentions gold, silver, or "no discount" — it only knows IDiscountStrategy. Swap the strategy instance passed to the constructor, and the discount calculation changes completely, with zero changes to OrderProcessor itself.
A shipping cost calculator needs different pricing algorithms per carrier. Here's the same real scenario, once as a full interface hierarchy, once as a delegate — so you can compare the tradeoffs directly:
// ── Full interface form — earns its cost because carriers have REAL configuration/state ──
public interface IShippingStrategy
{
decimal CalculateCost(Shipment shipment);
string CarrierName { get; }
}
public sealed class FedExStrategy(FedExRateTable rates) : IShippingStrategy
{
public string CarrierName => "FedEx";
public decimal CalculateCost(Shipment shipment) =>
rates.LookupRate(shipment.Weight, shipment.Destination);
}
public sealed class UpsStrategy(UpsAccountSettings account) : IShippingStrategy
{
public string CarrierName => "UPS";
public decimal CalculateCost(Shipment shipment) =>
account.NegotiatedRate * shipment.Weight;
}
public sealed class ShippingCalculator(IShippingStrategy strategy)
{
public ShippingQuote Quote(Shipment shipment) =>
new(strategy.CarrierName, strategy.CalculateCost(shipment));
}
// Registered per-carrier in DI, resolved by name — a natural fit for a
// container-friendly, stateful, multi-method strategy.
services.AddKeyedScoped<IShippingStrategy, FedExStrategy>("fedex");
services.AddKeyedScoped<IShippingStrategy, UpsStrategy>("ups");
// ── Lightweight delegate form — fine for a simple, stateless, one-off rule ──
public sealed class FlatRateCalculator(Func<Shipment, decimal> costFormula)
{
public decimal Quote(Shipment shipment) => costFormula(shipment);
}
var domesticFlatRate = new FlatRateCalculator(s => s.Weight <= 5 ? 4.99m : 9.99m);
The interface version earns its extra ceremony here because each carrier strategy has real constructor dependencies (a rate table, account settings), a second member (CarrierName), and needs to be discoverable and swappable through the DI container by a key. The delegate version is genuinely simpler for the flat-rate case — no state, one operation, no reason to force it into a class.
A GPS app doesn't hard-code "always take the highway." It lets you pick a strategy — "fastest route," "avoid tolls," "shortest distance" — and plugs whichever one you chose into the same trip-planning logic. The trip-planning code (the context) doesn't know or care how the route is calculated; it just calls "calculate route" and trusts whichever strategy is currently selected. Swap strategies mid-trip, and the planning logic never changes — only which algorithm answers the question does.
Order and returns a decimal" — a delegate type is, under the hood, a compiler-generated class with an Invoke method matching that signature (lesson 096)order => order.Total * discountRate — if discountRate is a captured local, the compiler generates a small closure class holding it, no different in spirit from a strategy class holding a constructor-injected fieldFunc<T> is anonymous, ad hoc, and limited to exactly one operationYou are. The GoF book was written before C# had first-class delegates and lambdas as convenient as they are today — in 1994 Smalltalk/C++ terms, "encapsulate an algorithm behind a common interface" necessarily meant a class hierarchy, because that was the only tool available. The intent — swappable algorithm, decoupled from the code using it — is what makes something Strategy, not the specific mechanism (interface vs. delegate) used to express it. A Func<Order, decimal> parameter is a legitimate, idiomatic Strategy implementation in modern C#.
Factory (lesson 240) decides which object to construct. Strategy decides which algorithm to run, once you already have the object. They often appear together — a factory might construct the right strategy — but they answer different questions: "what do I build?" vs. "what do I do?"
Writing IDiscountStrategy, three implementing classes, and a factory to select between them, for a rule that's genuinely just order.Total * 0.1m with no state and no plausible reuse elsewhere. A Func<Order, decimal> parameter says the same thing in one line, with no extra files.
Passing three separate Func<T> parameters into a constructor because the "strategy" really needs to expose CalculateCost, CarrierName, and EstimateDeliveryDate together, consistently. When a strategy genuinely has more than one related operation, or holds real state (a rate table, an API client), an interface keeps those pieces cohesive — as the shipping example showed.
IDiscountStrategy strategy = tier switch { "gold" => new GoldTierDiscount(), ... }; scattered at every call site — this is the exact same coupling problem Strategy was meant to solve, just one level removed. Centralize that selection logic in one place — a factory (lesson 240) or a DI registration — not repeated at every point a strategy is needed.
Func<T>. Promote it to a full interface the moment the strategy grows a second method, needs constructor dependencies, or needs to be resolved by the DI container rather than passed directly.
Func<T> parameters (099/104) are all real, lightweight — or literal — implementations of Strategy you've already used.You've seen Strategy in its classic and modern forms, and where you've already been using it. Let's check your understanding.
1. Which earlier lesson's core idea is most directly the same pattern as Strategy?
Correct: A
Why A is correct: Composition over inheritance — injecting a behavior object rather than baking it into a subclass — is structurally the same move Strategy makes: the behavior is supplied from outside as an interchangeable, swappable component.
Why B is incorrect: required is about enforcing that a value is set at construction time — unrelated to swapping algorithms.
Why C is incorrect: Connection pooling is a performance/infrastructure concern, not an interchangeable-algorithm concern.
Why D is incorrect: Profiling is a diagnostic technique, unrelated to Strategy's shape.
Reinforcement: Strategy is the formal name for "inject the behavior instead of hard-coding it" — exactly what composition over inheritance already taught.
2. A ShippingCalculator needs a carrier strategy that holds a rate table, an API client, and exposes both CalculateCost and EstimateDeliveryDate. Which form fits best?
Correct: B
Why B is correct: Real state (rate table, API client) and multiple related methods are exactly the conditions the lesson names for when the full interface form earns its complexity over a plain delegate.
Why A is incorrect: A single Func<T> can only represent one operation — it can't hold a rate table as state or expose a second method like EstimateDeliveryDate.
Why C is incorrect: A switch statement reintroduces the exact coupling and Open/Closed violation Strategy exists to remove.
Why D is incorrect: C# doesn't support multiple inheritance of classes, and an interface-based Strategy is the idiomatic, testable choice here, not an inheritance hierarchy.
Reinforcement: Promote to the full interface form once a strategy needs state or more than one method.
3. Why is a Func<Order, decimal> parameter considered a legitimate implementation of the Strategy pattern, not just "a shortcut that skips the real pattern"?
Correct: B
Why B is correct: The "Under the Hood" section showed that a delegate is structurally equivalent to a one-method interface. GoF's 1994 book predates convenient C# lambdas — the pattern's intent (swappable algorithm, decoupled from its caller) is satisfied either way.
Why A is incorrect: Performance isn't the basis for this distinction; both compile to similar underlying mechanics.
Why C is incorrect: The GoF catalog predates C# entirely and describes the pattern in Smalltalk/C++ terms, using interfaces — it doesn't mention Func<T> at all.
Why D is incorrect: This directly contradicts the lesson's point — the delegate form is a legitimate, idiomatic modern implementation, not a lesser substitute.
Reinforcement: A pattern is defined by its intent and shape, not by the specific C# syntax used to express it.
4. What's the key difference between the Factory pattern (lesson 240) and the Strategy pattern?
Correct: A
Why A is correct: This is exactly the distinction drawn in Common Confusion — Factory answers "what do I build?" while Strategy answers "what do I do?" — different questions, though they can be combined.
Why B is incorrect: They solve different problems and belong to different GoF categories entirely.
Why C is incorrect: This is backwards — Factory is Creational, Strategy is Behavioral.
Why D is incorrect: Neither pattern is restricted to static methods; both are commonly implemented with instance classes or delegates.
Reinforcement: Keep "what to build" (Factory) and "what to do" (Strategy) as separate questions, even though a factory can be used to construct a strategy.
You now have a name for something you've been doing since Foundations — and know exactly when to reach for a full interface versus a plain delegate.
dotnetmadeeasy.com — Learn C# and .NET, the right way.