The Strategy Pattern in C#: Where the Switch Goes
By Sergey Nosov
14 October 2026
The usual case for the Strategy pattern is that it replaces conditional logic with polymorphism. That holds for the code that runs the algorithm, but the decision does not go away: something still has to pick the algorithm. Strategy lets you move that choice to one place, and that place is one of the two parts of the design worth getting right. Here is the kind of code the pattern replaces, a shipping calculator with one formula per method:
public static class ShippingCost
{
public static decimal Calculate(string method, decimal weightKg) =>
method switch
{
"ground" => weightKg * 1.50m,
"air" => weightKg * 3.00m + 10.00m,
"express" => weightKg * 5.00m + 25.00m,
_ => throw new ArgumentException(
$"Unknown shipping method: {method}", nameof(method)),
};
}
Three one-line formulas in a switch expression are fine. Now suppose the rules keep coming. Same-day delivery needs a cutoff time, international shipping needs a destination and a customs fee, and a free-shipping promotion needs the order total, which the method does not even receive. Every new rule reopens the one method that holds all the others, so a change to one rate is a change to the code that computes every rate.
Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, the Gang of Four, cataloged the alternative in Design Patterns: Elements of Reusable Object-Oriented Software (1994). The intent of Strategy reads: “Define a family of algorithms, encapsulate each one, and make them interchangeable. Strategy lets the algorithm vary independently from clients that use it.” This article builds the pattern in C#, then follows the switch to where it went. It covers when a delegate is enough, how to shape the strategy interface, how Strategy differs from its neighbors, and when the switch was the better design all along. The samples run on .NET 10.
The Pattern in Three Parts
The book names three participants. The strategy “declares an interface common to all supported algorithms.” Each concrete strategy implements one algorithm, and the context holds a strategy and calls it through that interface. Here, the strategy is a C# interface, and each shipping method becomes a small class:
public interface IShippingStrategy
{
decimal Calculate(decimal weightKg);
}
public sealed class GroundShipping : IShippingStrategy
{
public decimal Calculate(decimal weightKg) => weightKg * 1.50m;
}
public sealed class AirShipping : IShippingStrategy
{
public decimal Calculate(decimal weightKg) =>
weightKg * 3.00m + 10.00m;
}
public sealed class ExpressShipping : IShippingStrategy
{
public decimal Calculate(decimal weightKg) =>
weightKg * 5.00m + 25.00m;
}
The context is the calculator. It delegates the formula and adds two rules that hold for every method, rules the switch never had. It rejects a weight that is not positive, and it rounds the result to cents:
// What stays the same lives here
public sealed class ShippingCostCalculator(IShippingStrategy strategy)
{
public decimal Calculate(decimal weightKg)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(weightKg);
decimal cost = strategy.Calculate(weightKg);
return decimal.Round(cost, 2, MidpointRounding.AwayFromZero);
}
}
var calculator = new ShippingCostCalculator(new AirShipping());
// Prints: 47.50
Console.WriteLine(calculator.Calculate(12.5m));
Each part now has one reason to change. A new air rate touches AirShipping and nothing else, and its test needs no other method. A test of the calculator can pass in a fake strategy that returns a fixed amount, so one test covers the rounding rule instead of one per method. Joshua Kerievsky’s catalog of refactorings from Refactoring to Patterns names the step from the opening switch to this shape Replace Conditional Logic with Strategy.
The Gang of Four also give the pattern a second name, Policy, and .NET uses it. System.Text.Json accepts a JsonNamingPolicy, a class that “determines the naming policy used to convert a string-based name to another format.” Switching property names from camel case to snake case means setting a different policy object, such as JsonNamingPolicy.SnakeCaseLower, in the serializer options. The serializer itself does not change.
Where Did the Switch Go?
Something still has to choose AirShipping. The calculator cannot do it without bringing the switch back, so the choice moves up a level, to whoever creates the calculator. The design question becomes where that level is. My answer is one place, chosen once. A dictionary turns that place into a table, with no branch per method:
// Every method, named once
var strategies = new Dictionary<string, IShippingStrategy>(
StringComparer.OrdinalIgnoreCase)
{
{ "ground", new GroundShipping() },
{ "air", new AirShipping() },
{ "express", new ExpressShipping() },
};
IShippingStrategy StrategyFor(string method) =>
strategies.TryGetValue(method, out var strategy)
? strategy
: throw new ArgumentException(
$"Unknown shipping method: {method}", nameof(method));
var calculator = new ShippingCostCalculator(StrategyFor("Express"));
// Prints: 87.50
Console.WriteLine(calculator.Calculate(12.5m));
The switch has become data. The method name can come from a request, a configuration file, or a user’s choice. Another weight-based rate, such as an economy tier, now costs one new class and one line in the table, while the calculator and the other strategies stay untouched. An unknown name fails with an ArgumentException that names it, and so does a duplicate, because the initializer calls Add. The table is the registry from the factory article, holding ready-made objects instead of classes. Its key comparison is a strategy too: StringComparer.OrdinalIgnoreCase, an IEqualityComparer<string>, ignores case, so "Express" finds the express entry.
With dependency injection, the table can live in the container. Keyed services, added in .NET 8, let you register each strategy under a key. A console app gets them from the Microsoft.Extensions.DependencyInjection package:
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddKeyedSingleton<IShippingStrategy, GroundShipping>("ground");
services.AddKeyedSingleton<IShippingStrategy, AirShipping>("air");
services.AddKeyedSingleton<IShippingStrategy, ExpressShipping>("express");
using var provider = services.BuildServiceProvider();
var strategy = provider.GetRequiredKeyedService<IShippingStrategy>("air");
// Prints: 47.50
Console.WriteLine(new ShippingCostCalculator(strategy).Calculate(12.5m));
The registrations are singletons. The Gang of Four suggest sharing strategies that hold no state, but Microsoft’s DI guidelines advise against singletons for services that have no state. For classes this small, either lifetime works. What a singleton strategy must never hold is a scoped service, such as a database context: the guidelines, using Mark Seemann’s term, call that a captive dependency.
Two details matter when the key comes from user input. String keys are case-sensitive, so "Air" finds nothing, and for a missing key, GetRequiredKeyedService throws an InvalidOperationException whose message omits the key. So keep the lookup in one method, like StrategyFor above: normalize the case, call GetKeyedService, which returns null for a missing key, and reject an unknown method with a message that names it.
Unlike the table, the container accepts a second registration under the same key, and the lookup returns the last one. A startup check catches that: group the keyed IShippingStrategy descriptors in the service collection by ServiceKey, and fail if any group has more than one. The container also accepts a fallback under KeyedService.AnyKey that answers every key without a registration of its own. The Gang of Four describe a related idea: a context with no strategy that “carries out default behavior.” For shipping, I would rather fail. A default would quietly price a mistyped method at the ground rate.
Frameworks use the same shape. In ASP.NET Core’s response compression middleware, each provider implements ICompressionProvider, whose EncodingName names the encoding it produces. The middleware “uses this information to choose the provider based on the list specified in the Accept-Encoding header of the request.” The provider list is the table, Brotli and Gzip are the built-in strategies, and the selection rule runs once per request. A custom encoding is one more provider class, but then the list is yours: “When a compression provider is added, other providers aren’t added,” so add Brotli and Gzip explicitly if you want to keep them.
Choosing once has a corollary: do not swap the strategy of a shared context. Suppose a web service keeps one calculator for all requests, and each request calls a SetStrategy method before calculating. When two requests overlap, one of them can be priced with the other’s method. Pass the strategy to the constructor instead, and create a context per choice, which costs one small object.
A setter suits a context that only one thread uses, such as the calculator behind a desktop form. System.Text.Json draws the same line for the options that hold its naming policy. Microsoft calls a JsonSerializerOptions instance safe to share across threads, and it “is immutable after the first serialization or deserialization.”
A Strategy Can Be a Function
If you have sorted a list with a custom comparison, you have used the pattern. The Gang of Four list three ways to parameterize a sort by how it compares elements. The second makes the comparison “the responsibility of an object that’s passed to the sorting routine.” The standard libraries of .NET, Java, and JavaScript chose that option. In .NET, List<T>.Sort accepts an IComparer<T> or a Comparison<T> delegate. Java’s sort methods accept a Comparator, and JavaScript’s Array.prototype.sort accepts a compareFn.
Joshua Bloch states the connection in Effective Java (third edition, 2017): “The Comparator interface represents an abstract strategy for sorting.” Each comparator passed to a sort is a concrete strategy. The sorting algorithm itself stays inside the library. Microsoft documents that List<T>.Sort applies “the introspective sort,” which chooses among insertion sort, heapsort, and quicksort by itself. Callers choose the order, so the order is the strategy. The documentation also states a property of the hidden algorithm that callers must plan for: “This implementation performs an unstable sort.”
In C#, a strategy with a single method can be a delegate, and the calculator barely changes:
public sealed class ShippingCostCalculator(Func<decimal, decimal> rate)
{
public decimal Calculate(decimal weightKg)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(weightKg);
decimal cost = rate(weightKg);
return decimal.Round(cost, 2, MidpointRounding.AwayFromZero);
}
}
var air = new ShippingCostCalculator(weightKg => weightKg * 3.00m + 10.00m);
// Prints: 47.50
Console.WriteLine(air.Calculate(12.5m));
Peter Norvig made the general point in 1996, in a talk on design patterns in dynamic languages: “The strategy is a variable whose value is a function.” In his study of Design Patterns, sixteen of the twenty-three patterns had a “qualitatively simpler implementation in Lisp or Dylan than in C++ for at least some uses of each pattern.” Strategy was one of the four he credited to first-class functions, which C# has too.
When should a strategy stay an interface? When it needs more than one member, or dependencies of its own that a container must supply. Length alone calls for a named method, not an interface. Bloch offers a readability guideline for lambdas: “One line is ideal for a lambda, and three lines is a reasonable maximum.” When a computation outgrows that, or needs a name of its own, pass a named method instead: new ShippingCostCalculator(Rates.Ground) works when Rates.Ground is a static method that takes and returns a decimal.
A shipping method that calls a carrier’s rate service, caches the answer, and reports whether it delivers to a postal code is a class. A formula is a function. The SOLID article on this site shows the JavaScript form, a Map of functions.
Design the Interface for the Whole Family
The other part of the design worth getting right is the strategy’s signature. The Gang of Four put it plainly in their case study: “The key to applying the Strategy pattern is designing interfaces for the strategy and its context that are general enough to support a range of algorithms. You shouldn’t have to change the strategy or context interface to support a new algorithm.”
Calculate(decimal weightKg) fails that test as soon as the free-shipping promotion arrives, because the promotion depends on the order total. One fix is to pass the thing being priced instead of one of its numbers. The interface and the calculator change, and each strategy reads only the fields it needs:
public sealed record Shipment(decimal WeightKg, decimal OrderTotal);
public interface IShippingStrategy
{
decimal Calculate(Shipment shipment);
}
public sealed class GroundShipping : IShippingStrategy
{
public decimal Calculate(Shipment shipment) =>
shipment.WeightKg * 1.50m;
}
// Free over a threshold; else ask
// the strategy it wraps
public sealed class FreeShippingOver(
decimal threshold, IShippingStrategy otherwise) : IShippingStrategy
{
public decimal Calculate(Shipment shipment) =>
shipment.OrderTotal > threshold
? 0.00m
: otherwise.Calculate(shipment);
}
public sealed class ShippingCostCalculator(IShippingStrategy strategy)
{
public decimal Calculate(Shipment shipment)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(shipment.WeightKg);
decimal cost = strategy.Calculate(shipment);
return decimal.Round(cost, 2, MidpointRounding.AwayFromZero);
}
}
var promotion = new FreeShippingOver(100.00m, new GroundShipping());
var calculator = new ShippingCostCalculator(promotion);
// Prints: 0.00
Console.WriteLine(calculator.Calculate(new Shipment(12.5m, 120.00m)));
// Prints: 18.75
Console.WriteLine(calculator.Calculate(new Shipment(12.5m, 80.00m)));
The Gang of Four’s implementation notes call this approach taking “the data to the strategy,” which “keeps Strategy and Context decoupled.” They name the cost, too: “Context might pass data the Strategy doesn’t need.” GroundShipping ignores the order total, and that is fine. Adding a destination later would change the record and the code that creates shipments, not the strategy interface or the existing strategies. A dependency such as a clock belongs in the constructor of the strategy that needs it.
The notes also describe the other option: pass the context itself, as an argument or a stored reference, so that “the strategy can request exactly what it needs.” The price is a context that “must define a more elaborate interface to its data, which couples Strategy and Context more closely.”
FreeShippingOver also shows that strategies compose. It is a decorator: it adds the promotion and hands every other case to the strategy it wraps. Its free branch returns 0.00m, not 0m: decimal.Round removes decimal places but never adds them, so 0m would print as 0.
Two signs say the family is wrong. If an implementation cannot honor the shared operation without dummy values or unsupported cases, an adapter will not help: it can reconcile representations, not contracts. And if every strategy has to reach into the context’s private state, the encapsulation that justified the pattern is gone.
Template Method, Generics, and State
The sorting passage quoted earlier offers three ways to vary the comparison. A subclass can implement it, which the Gang of Four call an application of Template Method. An object can be passed in, which is Strategy. Or the comparison can be “an argument of a C++ template or Ada generic.” They then weigh them: object composition “lets you change the behavior being composed at run-time, but it also requires indirection and can be less efficient,” and “neither inheritance nor parameterized types can change at run-time.”
Template Method is the inheritance option. A base class fixes the steps of an algorithm, and subclasses fill in some of them. It fits when the variants share a real skeleton. The entry on it in my Software Development Principles series draws the line from the other side. A template with only one primitive operation and little invariant structure is probably better as a strategy. The shipping calculator is that case: its fixed part is a weight check and a rounding step, and only the formula varies.
The parameterized-type option is the Gang of Four’s compile-time variant, “Strategies as template parameters.” They limit it to cases where “(1) the Strategy can be selected at compile-time, and (2) it does not have to be changed at run-time.” In C#, a static abstract interface member does the job:
public interface IRate
{
static abstract decimal For(decimal weightKg);
}
public readonly struct GroundRate : IRate
{
public static decimal For(decimal weightKg) => weightKg * 1.50m;
}
public static class Batch
{
// The rate is a type argument
public static decimal Total<TRate>(ReadOnlySpan<decimal> weights)
where TRate : struct, IRate
{
decimal total = 0m;
foreach (decimal weightKg in weights)
{
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(weightKg);
total += decimal.Round(
TRate.For(weightKg), 2, MidpointRounding.AwayFromZero);
}
return total;
}
}
decimal[] weights = [12.5m, 0.8m, 4.0m];
// Prints: 25.95
Console.WriteLine(Batch.Total<GroundRate>(weights));
The type argument fixes the rate for each instantiation. Microsoft’s documentation notes that static abstract members “don’t have a runtime dispatch mechanism analogous to virtual or abstract methods declared in classes.” For value types, the runtime specializes generic code, creating a version “one time for each unique value type that is used as a parameter.” In the optimized .NET 10 code for this sample, the JIT compiles Total<GroundRate> as a method of its own, with the rate inlined into the loop. A second rate struct gets a second copy.
The price is the one the Gang of Four name: a call site cannot change its rate at run time, and picking the rate from input brings the switch back, this time over the instantiations. Reach for the type argument only when a profiler points at the call. Since .NET 8, the JIT’s dynamic profile-guided optimization is on by default. Stephen Toub’s .NET 8 performance post describes how it can “devirtualize virtual and interface calls per call site,” and delegate calls as well. A call site that mostly sees one strategy is often cheap already.
State is the neighbor with the same structure; the difference is intent. “An object will only have one state,” the Gang of Four write, “but it can have many strategies for different requests.” States are the stages an object moves through, and the State pattern leaves open who moves it. Fixed criteria “can be implemented entirely in the Context,” though the authors find it “generally more flexible and appropriate” to let the states choose their successors. Strategies are alternative ways to do one job, and a strategy never changes which strategy its context holds. If your objects move the context from stage to stage, State is the pattern to read.
When the Switch Is the Better Design
In its class form, Strategy costs an interface, a class per variant, and a selection point to maintain. For two or three short formulas that change together, the switch expression from the opening is easier to read, because every rate fits on one screen. I would introduce the pattern when a branch begins to need logic or inputs of its own, as the free-shipping rule did. Introduced earlier, it is speculative design: a guess about the interface, made before the variants exist to test it.
Philip Wadler described the deeper trade-off in a 1998 note that named the Expression Problem. “One can think of cases as rows and functions as columns in a table,” he wrote. In an object-oriented language, “the columns are fixed (methods in a class declaration) but it is easy to add new rows (subclasses).”
Strategy has that shape. A new shipping method is a new row: one class. A new question for every method, such as an estimated delivery date, is a new column: it changes the interface and every strategy. A switch over a closed set has the other shape. A new question is one more switch, and a new method edits every switch.
So ask which change you expect more often. If new methods keep arriving, use Strategy. If the methods are stable and the questions grow, keep the switch, and let the C# compiler check it. Model the methods as an enum, say ShippingMethod, and leave out the discard arm. When someone adds a member, the compiler flags every switch expression that misses it with CS8509. Set the WarningsAsErrors property to CS8509 in the project file, and the warning becomes a failed build.
Leaving out the discard arm has a cost. The compiler also warns, with CS8524, that an unnamed value such as (ShippingMethod)3 is not covered. Because that case has its own warning number, you can suppress CS8524 alone, but an unnamed value then throws a SwitchExpressionException at run time. Keep such values out at the boundary: Enum.TryParse accepts "3", so check Enum.IsDefined as well.
C# 15 goes further. Its union types and closed class hierarchies, in preview as of October 2026, give the compiler the complete set of cases. For a non-null value, a switch that covers every case needs no discard arm. Null still needs handling where it can occur, including the default value of a union struct. Closed enums, still only planned, would prevent “creation of values other than the declared members.” Bill Wagner of Microsoft wrote on 24 August 2026 that C# 15 “will ship with .NET 11 in November.”
A Checklist for Choosing
- Is the variation real? Two or three short formulas that change together read better as a switch. Wait for the variant that needs its own logic or inputs.
- Where is the choice made? Once, where the input arrives: a table, the container, or a framework’s selection rule. Pass the strategy to the context’s constructor, not through a setter on a shared object, and make an unknown or duplicate name fail with a message that names it.
- Is the strategy a single function? Use a delegate, and a named method once it outgrows a lambda. Use an interface when it needs more than one member or dependencies of its own.
- Does the interface fit the whole family? Shape it around the data the known variants need. Passing the thing being worked on, not one of its numbers, leaves room to grow, and some strategies will ignore part of it.
- Is it really Strategy? A fixed skeleton whose steps subclasses fill in may be simpler as Template Method, and objects that move their context from stage to stage are State. One varying step behind a small fixed part, as in the calculator, is a strategy.
- Is the choice fixed when the code compiles? If a profiler also shows the call in a hot loop, pass the strategy as a type argument instead.
- Which change comes more often? New variants favor Strategy. New operations over a fixed set favor a switch the compiler can check.
Further Reading
- Design Patterns: Elements of Reusable Object-Oriented Software (Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, Addison-Wesley, 1994)
- Effective Java, Third Edition (Joshua Bloch, Addison-Wesley, 2017)
- Design Patterns in Dynamic Languages (Peter Norvig, 5 May 1996)
- The Expression Problem (Philip Wadler, 12 November 1998)