If you only learn one pattern from this series, make it this one. Strategy takes a calculation that comes in several flavours, gives each flavour its own class, and lets the caller pick one. It’s simple, it’s everywhere, and it’s probably the pattern you’ll reach for most often.
| Category | Behavioral |
| Smell fixed | S21: Shipping cost is picked with a switch |
| Code | patterns/behavioral/strategy |
The Scene
CornerStore offers three shipping methods. Customers switch between them during the day, and one tries something a bit ambitious:
[031] cart.shipping customer=cu-1 method=express
shipping set to express
[038] cart.shipping customer=cu-2 method=drone
rejected: unknown shipping method drone
[041] cart.shipping customer=cu-2 method=pickup
shipping set to pickup
No drones yet. But if the business ever adds them, where would the new pricing go?
The Smell
In the baseline, all three shipping calculations live in one switch inside the pricing service:
public decimal? ShippingCost(string method, decimal weightKg)
{
// SMELL S21: the shipping cost is picked with a switch on the method string
switch (method)
{
case "standard":
return Math.Round(5.00m + 2.00m * weightKg, 2, MidpointRounding.AwayFromZero);
case "express":
return Math.Round(12.00m + 4.00m * weightKg, 2, MidpointRounding.AwayFromZero);
case "pickup":
return 0m;
default:
return null;
}
}
It’s tidy, and with three cases you might say it’s fine. Here’s why it’s a smell anyway. Each case is a separate business rule that could change for its own reasons (a new courier rate, a weight bracket, a free-shipping threshold), and all of them are wedged into the pricing service. Adding a method means editing PricingService. Testing the express formula means going through PricingService. And realistic shipping rules rarely stay one line long.
The Idea
Strategy says: when you have a family of interchangeable algorithms, give each one its own class behind a shared interface. The code that uses the algorithm just holds an interface and calls it. Which one it gets is decided separately.
Think of getting to the airport. You can drive, take the train or catch a taxi. They’re different ways to do the same job: get you from home to the terminal. You pick one based on the situation, and the rest of your trip doesn’t change.
The Refactor
One small interface for “calculate a shipping cost”:
internal interface IShippingCostStrategy
{
decimal Calculate(decimal weightKg);
}
One class per method. Here’s express:
internal sealed class ExpressShippingCostStrategy : IShippingCostStrategy
{
public decimal Calculate(decimal weightKg)
{
return Math.Round(12.00m + 4.00m * weightKg, 2, MidpointRounding.AwayFromZero);
}
}
The pricing service holds a dictionary of strategies, keyed by method name:
this.shippingStrategies = shippingStrategies ?? new Dictionary<string, IShippingCostStrategy>(StringComparer.Ordinal)
{
["standard"] = new StandardShippingCostStrategy(),
["express"] = new ExpressShippingCostStrategy(),
["pickup"] = new PickupShippingCostStrategy(),
};
And looking up the cost becomes a lookup plus a call. An unknown method still returns null, which is how the drone gets rejected:
return shippingStrategies.TryGetValue(method, out var strategy)
? strategy.Calculate(weightKg)
: null;
classDiagram
PricingService --> IShippingCostStrategy : selects and calculates
IShippingCostStrategy <|.. StandardShippingCostStrategy
IShippingCostStrategy <|.. ExpressShippingCostStrategy
IShippingCostStrategy <|.. PickupShippingCostStrategy
PricingService --> ShippingCalculator : adds regional surcharge
Notice the constructor parameter shippingStrategies. The default dictionary is used in the app, but a test can pass in its own. The solution’s tests do exactly that: they add a brand-new strategy, defined only in the test project, and price a cart with it, without changing a single line of PricingService. That’s the clearest proof the pattern is working.
Also notice the ShippingCalculator arrow in the diagram. The regional surcharge from Abstract Factory’s smell is still applied separately. One pattern, one seam.
What It Cost
7 files: about 138 lines added and only 13 removed. Three tiny strategy classes, an interface and tests, many of them checking that each strategy produces exactly the baseline’s numbers for AU and NZ orders.
This is Strategy at its best: a small amount of structure for a very flexible result.
When Not to Use It
- There are two cases that never change. A strategy hierarchy for “free” and “not free” is overkill. An
ifis fine. - The algorithms are one-liners. In modern C#, a strategy can simply be a delegate. A
Dictionary<string, Func<decimal, decimal>>gives you the same flexibility without a class per rule. Reach for classes when the algorithms have their own dependencies, state or tests. - The caller doesn’t actually choose. If the “strategy” is always the same one, you’ve built an interface with one implementation.
Easily Confused With
- State. Same class diagram, different intent. With Strategy, the caller chooses an algorithm, and the strategies don’t know about each other. With State, the object itself moves between states as its lifecycle progresses. See Strategy vs State.
- Template Method. Both let you vary part of an algorithm. Strategy swaps the whole algorithm by composition (holding a different object). Template Method keeps the algorithm’s skeleton fixed and varies steps by inheritance. See Template Method vs Strategy.
Try It Yourself
dotnet build patterns/behavioral/strategy/Strategy.slnx
dotnet test patterns/behavioral/strategy/Strategy.slnx
dotnet run --project patterns/behavioral/strategy/src/CornerStore/CornerStore.csproj -- scenarios/trading-day.json
Exercise: CornerStore is finally getting drones. Add a DroneShippingCostStrategy and register it as "drone". Then rerun the scenario: step 38 now succeeds, so the golden master fails. That’s the safety net doing its job: you changed behaviour, so it makes you notice.
The Strategy README has the participant table and a sequence diagram. The full change is in DIFF.md.
Previous: State · Next: Template Method