Strategy¶
Intent¶
Define a family of interchangeable algorithms, give every member the same interface, and let the caller pick one at runtime. The code that uses the algorithm stops changing when you add a variant: a new pricing rule is a new class (or function), not another branch in an if/elif ladder that every gate in the building shares.
When to use and when not to¶
Use it when
- The follow-up is predictable ("now add a daily cap", "now prefer spots near the gate"): what the interviewer will ask you to change goes behind a Strategy.
- You want to test the algorithm in isolation or swap in a fake; a
RecordingStrategyin a test is the same move asFlatRatePricingin production. - Each variant carries configuration (rates, caps, grace periods) to validate once, at construction.
Leave it out when
- There is one algorithm and no credible second; add the seam when the second rule arrives.
- The variation is a parameter, not an algorithm: hourly at 3.00 and hourly at 4.00 are one strategy with one argument.
- The variant needs the context's private state; then the algorithm belongs in the context, or you want Template Method hooks.
- Behaviour changes with the object's lifecycle (active, paying, paid): that is State, where the object switches itself.
Structure¶
Three roles: the Strategy interface, one concrete class per rule, and a Context that holds a reference and delegates.
classDiagram
class PricingStrategy {
<<interface>>
+price(minutes: int) Money
}
class HourlyPricing {
+rate_per_hour: Money
+grace_minutes: int
+price(minutes: int) Money
}
class FlatRatePricing {
+rate: Money
+price(minutes: int) Money
}
class DailyCapPricing {
+hourly: HourlyPricing
+cap_per_day: Money
+price(minutes: int) Money
}
class ExitGate {
-_pricing: PricingStrategy
+pricing: PricingStrategy
+quote(minutes: int) Money
}
PricingStrategy <|.. HourlyPricing
PricingStrategy <|.. FlatRatePricing
PricingStrategy <|.. DailyCapPricing
DailyCapPricing *-- HourlyPricing
ExitGate o-- PricingStrategy : delegates to
ExitGate never asks which rule it holds; it calls price and trusts the answer. DailyCapPricing composes HourlyPricing rather than subclassing it: a cap is a rule wrapped around hourly pricing, not a kind of it. The dotted arrows are realisation, not inheritance; Python checks the shape, not the parent.
Canonical example in Python¶
The interface and the three rules come first (code/patterns/strategy.py, tested by code/patterns/tests/test_strategy.py):
@runtime_checkable
class PricingStrategy(Protocol):
"""The Strategy interface: one method, nothing about *which* rule is in force.
A ``Protocol`` rather than an ``ABC``: the concrete rules never inherit from
it, they qualify by having a matching ``price`` method (structural typing).
"""
def price(self, minutes: int) -> Money: ...
@dataclass(frozen=True, slots=True)
class HourlyPricing:
"""Per started hour after a free grace period (the weekday rule)."""
rate_per_hour: Money = DEFAULT_HOURLY_RATE
grace_minutes: int = DEFAULT_GRACE_MINUTES
def price(self, minutes: int) -> Money:
if minutes <= self.grace_minutes:
return Money(0, self.rate_per_hour.currency)
return self.rate_per_hour * math.ceil(minutes / MINUTES_PER_HOUR)
@dataclass(frozen=True, slots=True)
class FlatRatePricing:
"""One price per visit, however long (the event-night rule)."""
rate: Money = DEFAULT_FLAT_RATE
def price(self, minutes: int) -> Money:
return self.rate
@dataclass(frozen=True, slots=True)
class DailyCapPricing:
"""Hourly, but no single day costs more than the cap (the airport rule).
Composes ``HourlyPricing`` instead of subclassing it: the cap is a rule
wrapped *around* hourly pricing, so any hourly configuration can be capped.
"""
hourly: HourlyPricing = field(default_factory=HourlyPricing)
cap_per_day: Money = DEFAULT_DAILY_CAP
def price(self, minutes: int) -> Money:
full_days, remainder = divmod(minutes, MINUTES_PER_DAY)
return self.cap_per_day * full_days + min(self.hourly.price(remainder), self.cap_per_day)
Three decisions to say out loud:
Protocol, notABC. The rules never inherit fromPricingStrategy; they qualify by having a matchingpricemethod, and so does a test double defined inside a test.@runtime_checkablelets a test assertisinstance(HourlyPricing(), PricingStrategy).- Frozen dataclasses as strategies. A strategy is configuration plus one method.
frozen=Truemakes it a value: hashable, comparable, a readablerepr, and safe to share across gates and threads because nothing on it changes after construction. - Composition for the cap.
DailyCapPricingholds anHourlyPricingand calls it for the leftover hours of the last day; subclassing would weld the cap to one hourly implementation.
The Context is deliberately thin:
class ExitGate:
"""The Context: holds a reference to *a* rule and delegates the arithmetic to it.
The gate validates the input once, so no rule repeats the check, and exposes
the rule as a property so an operator can switch from the weekday rule to the
event rule while cars are queuing. Swapping is one attribute assignment; the
rules carry no mutable state, so no lock is involved.
"""
def __init__(self, pricing: PricingStrategy) -> None:
self._pricing = pricing
@property
def pricing(self) -> PricingStrategy:
return self._pricing
@pricing.setter
def pricing(self, pricing: PricingStrategy) -> None:
self._pricing = pricing
def quote(self, minutes: int) -> Money:
if minutes < 0:
raise ValidationError("a stay cannot be negative")
return self._pricing.price(minutes)
It validates once, so no rule repeats the check, and exposes the rule as a property so an operator can switch from the weekday rule to the event rule while cars are queuing. The swap is one attribute assignment and needs no lock, because the rules hold no mutable state; a quote in flight finishes with the rule it started with.
Running python -m patterns.strategy prints:
--- a 2h35m stay, priced by each rule ---
HourlyPricing: 9.00 USD
FlatRatePricing: 10.00 USD
DailyCapPricing: 9.00 USD
--- a 26h00m stay, priced by each rule ---
HourlyPricing: 78.00 USD
FlatRatePricing: 10.00 USD
DailyCapPricing: 26.00 USD
--- event night: the same gate switches rules at runtime ---
weekday rule: 2h35m -> 9.00 USD
event rule: 2h35m -> 10.00 USD
--- functional variant: the rules as callables, ranked with sorted(key=) ---
flat: 10.00 USD
daily_cap: 26.00 USD
hourly: 78.00 USD
rejected: a stay cannot be negative
Pythonic variant¶
A Strategy with one method is a function. Python supplies the interface, Callable[[int], Money], and closures carry the configuration that dataclass fields carried above:
# A Strategy with one method is a function: ``Callable`` is its whole interface.
type PricingRule = Callable[[int], Money]
def hourly(rate: Money = DEFAULT_HOURLY_RATE, grace_minutes: int = DEFAULT_GRACE_MINUTES) -> PricingRule:
"""A closure: the configuration is captured once, the returned function is the rule."""
def rule(minutes: int) -> Money:
if minutes <= grace_minutes:
return Money(0, rate.currency)
return rate * math.ceil(minutes / MINUTES_PER_HOUR)
return rule
def flat(rate: Money = DEFAULT_FLAT_RATE) -> PricingRule:
return lambda _minutes: rate
def daily_cap(inner: PricingRule, cap: Money = DEFAULT_DAILY_CAP) -> PricingRule:
"""Wraps any rule. A bound method such as ``HourlyPricing().price`` qualifies too."""
def rule(minutes: int) -> Money:
full_days, remainder = divmod(minutes, MINUTES_PER_DAY)
return cap * full_days + min(inner(remainder), cap)
return rule
def rules_by_name() -> dict[str, PricingRule]:
"""Dict dispatch: the mapping replaces both the if/elif ladder and a factory class."""
return {
"hourly": hourly(),
"flat": flat(),
"daily_cap": daily_cap(hourly()),
}
def ranked(rules: Mapping[str, PricingRule], minutes: int) -> list[tuple[str, Money]]:
"""``sorted(key=)`` is the Strategy you already use: the key function is the ordering rule."""
quotes = [(name, rule(minutes)) for name, rule in rules.items()]
return sorted(quotes, key=itemgetter(1))
- Closures replace constructors.
hourly(rate, grace_minutes)captures its configuration and returns the rule; the call site cannot tell the two forms apart. - Dict dispatch replaces the factory.
rules_by_name()maps a configuration string to a rule; a new rule is a new entry. sorted(key=)is the Strategy you already use. The key function is an injected ordering algorithm;rankedpassesoperator.itemgetter(1), and everykey=lambdayou have written was a strategy handed to a context.
A bound method is a callable too, so the two forms compose without an adapter:
capped = daily_cap(HourlyPricing().price, cap=Money.of("20.00"))
assert capped(26 * 60) == DailyCapPricing().price(26 * 60)
When is the function enough?
| Reach for | When |
|---|---|
| A plain function or lambda | One method, no configuration, no name needed in logs or diagrams |
A closure such as hourly(rate) |
One method plus configuration fixed at creation |
A frozen dataclass with price |
You want equality, a readable repr, validation in __post_init__, or a second method |
A Protocol declaring __call__ |
Instances and plain functions must satisfy one type hint |
Draw the class diagram, then say "in Python I would start with a callable and promote it to a class when it grows configuration or a second method".
Real-world usage¶
jsonships both forms:json.dumps(obj, cls=MyEncoder)injects aJSONEncodersubclass whosedefaultmethod decides how unknown objects serialise;json.dumps(obj, default=fn)injects the same decision as a function.logging.Formatteris aHandler's rendering strategy:handler.setFormatter(JsonFormatter())changes how every record renders, at runtime, and the handler never learns what changed.logging.Filterdoes the same for the accept-or-drop decision.sorted,min,max,heapq.nlargesttakekey=;functools.cmp_to_keyadapts a comparator strategy into a key strategy.- Frameworks: Django's
PASSWORD_HASHERSand cache backends, Django REST Framework'spagination_classandthrottle_classes, SQLAlchemy'spoolclass.
Related patterns and confusions¶
| Looks like Strategy | How to tell them apart |
|---|---|
| State | Both delegate to a swappable object, so ask who switches. With Strategy the client picks a rule and the rules do not know each other exist; with State the object switches itself as events arrive (a vending machine going idle, has-coin, dispensing). |
| Template Method | Same goal, opposite mechanism: a skeleton in a base class (BoardGame.play) with steps overridden per subclass, fixed when the class is written. Strategy replaces the whole algorithm by composition, at runtime. |
| Bridge | The same picture, an abstraction holding an implementor, but Bridge lets two hierarchies vary independently. One axis of variation is Strategy; two is Bridge. |
| Command | A command is a request (verb plus arguments, often with undo) that can be queued or logged; a strategy is one way of performing a single operation. |
| Decorator | DailyCapPricing wrapping HourlyPricing is half a Decorator: same interface, one extra rule. A true Decorator stacks on any implementation; the cap wraps one specific inner type. |
| Null Object | A FreeParking rule whose price returns zero deletes every if pricing is None from the gate: a strategy that does nothing. |
| Factory Method | The factory chooses a strategy from a name; the strategy is the behaviour. The dict in rules_by_name is the smallest factory there is. |
Where it appears in LLD problems¶
- Design a parking lot —
PricingStrategyandSpotAllocationStrategy; every extensibility answer there is a new strategy. - Design Splitwise —
SplitStrategyfor equal, exact, percentage and share-based splits. - Design an in-memory cache (LRU, LFU, TTL) —
EvictionPolicy(LRU, LFU, FIFO) behindgetandput. - Design a rate limiter (LLD) — token bucket, fixed window and sliding window behind one
allow(key). - Design an elevator system —
DispatchStrategy(FCFS, SCAN/LOOK, nearest car) in the controller. - Design a food delivery system (Swiggy, Zomato, DoorDash) —
AssignmentStrategyfor couriers,DiscountStrategyfor coupons. - Design Uber (LLD) with driver matching —
MatchingStrategy(nearest, ETA, rating) and surge pricing.
Interview tips¶
Interview tip
Name the axis of change before you name the pattern: "pricing is what they will ask me to change, so it goes behind a PricingStrategy Protocol injected into the gate." Then add the two sentences that mark an SDE2: which test you would write for the next rule, and when a plain callable would do instead.
Common mistake
Moving the if/elif ladder instead of removing it. A context whose quote does if self.mode == "hourly": ... has three classes' worth of ceremony and none of the benefit. Selection happens once, in the composition root (main, a settings file, a dict lookup); the context only ever calls price. Runner-up: a strategy with mutable per-call state shared across threads.
Related¶
- State — the delegate that switches itself
- Template Method — the inheritance-based alternative
- Null Object — a strategy that does nothing
- Dependency Injection — how the chosen strategy reaches the context
- Design a parking lot — pricing and allocation strategies in a full problem
- Design Splitwise — split strategies over money
- Gamma, Helm, Johnson and Vlissides, Design Patterns (1994), Strategy
- PEP 544 — Protocols: Structural subtyping
- Python documentation: Sorting Techniques