Strategy Pattern
By the end of this lesson
Swap an algorithm at run time instead of branching on type.
A strategy is an algorithm behind an interface, chosen while the program is running. The caller knows it needs a discount calculated; it does not know which of the four ways is about to be used.
The pain it answers is specific. A calculation that branches on a type or a category value has to be edited every time a new case appears, and the same set of cases usually gets copied into two or three other methods that also need to know. The pattern turns "edit the method" into "add a class", and turns a value scattered across several switches into one thing the container resolves.
public decimal DiscountFor(CustomerCategory category, OrderTotals totals)
{
switch (category)
{
case CustomerCategory.Standard:
return 0m;
case CustomerCategory.Trade:
return totals.Net * 0.05m;
case CustomerCategory.Wholesale:
return totals.Net >= 5_000m
? totals.Net * 0.12m
: totals.Net * 0.08m;
case CustomerCategory.Staff:
return Math.Min(totals.Net * 0.25m, 200m);
default:
throw new NotSupportedException($"No discount rule for {category}.");
}
}- Four unrelated commercial rules share one method. A change to the staff cap is a change to the file that also decides wholesale pricing, so the review has to cover both and the merge conflicts land here.
- The rules do not change together. Trade terms come from sales, the staff discount from HR, the wholesale threshold from finance. Three reasons to change, one location — the problem the first lesson of this course named.
- This switch is rarely alone. The same four categories usually appear again in the method that writes invoice wording and again in the one that sets payment terms. Add a fifth category and the compiler tells you nothing; you find the other two switches when a customer complains.
- The default case is doing real work. Throwing on an unrecognised value is better than returning zero, because a silently missing discount reaches a customer rather than a developer.
public interface IDiscountRule
{
CustomerCategory AppliesTo { get; }
decimal DiscountFor(OrderTotals totals);
}
internal sealed class WholesaleDiscount : IDiscountRule
{
public CustomerCategory AppliesTo => CustomerCategory.Wholesale;
public decimal DiscountFor(OrderTotals totals) =>
totals.Net >= 5_000m ? totals.Net * 0.12m : totals.Net * 0.08m;
}
public sealed class DiscountCalculator
{
private readonly Dictionary<CustomerCategory, IDiscountRule> _rules;
public DiscountCalculator(IEnumerable<IDiscountRule> rules) =>
_rules = rules.ToDictionary(rule => rule.AppliesTo);
public decimal For(CustomerCategory category, OrderTotals totals) =>
_rules.TryGetValue(category, out IDiscountRule? rule)
? rule.DiscountFor(totals)
: throw new NotSupportedException($"No discount rule registered for {category}.");
}
// Program.cs — one line per rule, and this is where the cost shows
builder.Services.AddScoped<IDiscountRule, StandardDiscount>();
builder.Services.AddScoped<IDiscountRule, TradeDiscount>();
builder.Services.AddScoped<IDiscountRule, WholesaleDiscount>();
builder.Services.AddScoped<IDiscountRule, StaffDiscount>();- Each rule declares the category it handles, so the mapping lives with the rule rather than in a separate lookup somebody has to remember to update.
- Injecting IEnumerable of an interface gives you every registered implementation. The container builds the list; the calculator turns it into a dictionary once, at construction.
- Testing the wholesale threshold is now a constructor call and two assertions. No other rule is loaded, so a mistake in one cannot make another test fail.
- The throw survives the refactor. Removing the switch removed the branching, not the need to decide what happens when a value has no rule — and that decision is worth keeping explicit.
- Four registration lines are the honest price. Forget one and the failure appears at run time for one category of customer, not at compile time. Some teams prefer assembly scanning to avoid that; scanning trades an explicit list for magic, which is a real choice rather than an obvious improvement.
Three parts, and the one people get wrong is the third:
- The contract
- One interface describing what varies, in terms of the inputs every implementation genuinely needs. If two implementations need different inputs, the contract is wrong — that is the signal discussed below.
- The implementations
- One class per algorithm, each with no knowledge of the others and no branching of its own. A strategy containing an if on the same value you selected it with is a switch you moved.
- The selection point
- One place that turns run-time data into an implementation: a dictionary, keyed container registrations, or a property on each strategy as above. Let the selection spread and callers start constructing strategies themselves, which reintroduces the coupling the pattern removed.
The trade, stated in both directions:
| A switch statement | Strategy classes | |
|---|---|---|
| Adding a case | Edit the method that holds every other case | Add a class and a registration line |
| Seeing all the rules at once | Read one method | Open several files and trust the naming |
| Testing one rule | Reachable, but the method loads with its neighbours | Construct one class and call it |
| Choosing by configuration or tenant | Needs another branch | Register a different implementation |
| Rules needing different inputs | Awkward and visible | Pushes the interface wider than any one rule needs |
| Files and names | One method | Interface, one class per rule, a selector, registrations |
Summary
- A strategy is an algorithm behind an interface, selected from run-time data
- It converts editing a shared method into adding an independent class
- Keep selection in one place, and keep branching out of the implementations
- An interface whose parameters most implementations ignore is two abstractions in one
- Two stable cases are better served by an if, and a category-to-value mapping is data rather than an algorithm
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Three switches, one interface?
An application switches on CustomerCategory in three methods: the discount calculation, the wording printed on the invoice, and the default payment terms.
A developer proposes a single ICustomerPolicy interface with three members so that adding a category means writing one class. What would you ask before agreeing, and what would you propose instead?
Show solution
Ask whether the three vary for the same reason. If a new category always needs all three decided at once, one interface per category is a good fit, and it has a real advantage: the compiler tells you what is missing, which none of the three switches do today.
If they change independently — discounts on a commercial schedule, invoice wording when marketing rewrites it — then one interface welds three reasons to change into one file, and every wording tweak touches the class that also prices the order.
The more useful observation is that only one of the three is an algorithm. Invoice wording is text, and payment terms are a number of days. Both are data, so they belong in configuration or a column on the category, read by code that does not branch at all. A strategy over data is a class wrapped around a value.
A proportionate answer: strategies for the discount calculation, data for the other two. That removes all three switches, and only one of them needed a pattern.
Try it yourself
Tax by destination
An invoicing application calculates tax differently by destination: domestic sales charge the standard rate on everything, and sales to registered businesses in another country charge nothing but must record the customer's tax registration number.
Write the interface, two implementations and the selector. Then write one test for each rule.
Show solution
The rule returns a value rather than modifying the invoice. That keeps each implementation a function of its inputs, which is what makes the tests a single call with no setup.
The result type carries both the amount and the note, because the zero-rated case has to record why it was zero. A method returning only a decimal would force the caller to work out the reason, which puts the rule back in the caller.
The selector throws on an unknown destination. Returning zero tax for a country nobody configured would be a defect discovered by an auditor rather than by a test, and the cost of that is higher than the cost of a failed request.
Worth noting what this does not solve: real tax rules involve product categories, thresholds and dates. Two implementations is the shape, not the finished job — and if the rules become complex enough to need a rate table with effective dates, that table is data and no number of strategy classes will replace it.
public sealed record TaxOutcome(decimal Amount, string Basis);
public interface ITaxRule
{
TaxTreatment AppliesTo { get; }
TaxOutcome Calculate(decimal net, Customer customer);
}
internal sealed class DomesticTax : ITaxRule
{
public TaxTreatment AppliesTo => TaxTreatment.Domestic;
public TaxOutcome Calculate(decimal net, Customer customer) =>
new(Math.Round(net * 0.20m, 2, MidpointRounding.AwayFromZero), "Standard rate");
}
internal sealed class RegisteredBusinessAbroadTax : ITaxRule
{
public TaxTreatment AppliesTo => TaxTreatment.RegisteredBusinessAbroad;
public TaxOutcome Calculate(decimal net, Customer customer)
{
if (string.IsNullOrWhiteSpace(customer.TaxRegistrationNumber))
throw new TaxRuleException("Zero rating needs the customer tax registration number.");
return new TaxOutcome(0m, $"Zero rated, registration {customer.TaxRegistrationNumber}");
}
}
[Fact]
public void DomesticTax_RoundsToTwoDecimals()
{
TaxOutcome outcome = new DomesticTax().Calculate(19.99m, AnyCustomer());
Assert.Equal(4.00m, outcome.Amount);
}Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.