Hermes Wiki

Strategy Pattern

Concept

Strategy extracts a family of interchangeable algorithms behind a common interface so the code that uses one of them can be written once against the interface, and the specific algorithm can be selected, injected, or swapped independently — at construction time, at call time, or even at runtime — without the calling code changing. A "context" object holds a reference to a strategy object implementing a shared interface (e.g. compress(data), sort(items), route(request)) and delegates to it; concrete strategies (GzipCompression, QuickSort, RoundRobinRouting) each implement the interface with their own algorithm and carry no knowledge of each other.

The pattern is one of the most common in everyday code, often invisible as "Strategy" because most languages let you pass a function directly instead of wrapping it in a class: a comparator passed to a sort function, a validation function passed into a form library, an HTTP client's configurable retry policy, a payment processor picking between CreditCard/PayPal/Wire handlers behind one process(payment) call, or dependency-injecting a Cache interface that could be backed by Redis, Memcached, or an in-process map. In languages with first-class functions (Python, JS/TS, Go), a plain function or closure passed as a parameter is Strategy without any class ceremony — the class-based version from the GoF book is the shape the pattern takes in languages/eras without first-class functions.

Tradeoffs

Approach Benefit Cost
Strategy (interface + swappable implementations) New algorithms added without touching the context or existing strategies; each algorithm is independently testable; runtime selection is trivial An interface/abstraction to design and maintain even when only one implementation currently exists — premature if a second algorithm is never actually needed
Conditional dispatch (if type == 'credit_card': ... elif type == 'paypal': ...) No interface to design; fine when there are 2 fixed options that will never grow Every call site with the same conditional needs updating when a new option is added; algorithm logic and dispatch logic are tangled in one function
Passing a bare function/closure (no formal interface, just a callable parameter) Zero ceremony in languages with first-class functions; often the most idiomatic option Loses the discoverability of a named interface (harder to find "what are all the valid strategies") and any strategy-specific extra state/config a class could hold

Strategy and passing a plain function are the same idea at different formality levels — a bare closure is Strategy without the interface declaration, and reaches for the class-based version specifically when a strategy needs to carry its own state/configuration beyond a single call signature, or when the "which implementations exist" question benefits from being answerable by grepping for implementers of a named interface.

When to use / when not to

  • Use when a piece of behavior has, or is likely to gain, more than one valid implementation, and the caller shouldn't need to know which one is active — payment processing, compression/serialization format selection, pricing/discount rules, routing/load-balancing algorithms, retry/backoff policies.
  • Especially valuable when the choice of algorithm needs to vary per call or per request (a customer's chosen payment method, a feature flag picking between two ranking algorithms) rather than being fixed for the whole application's lifetime.
  • Don't introduce the interface for a single implementation with no concrete second one on the horizon — that's speculative abstraction; add the interface when the second implementation actually shows up, not before.
  • In languages with first-class functions, prefer passing the function directly over wrapping it in a class hierarchy unless the strategy needs to bundle real state alongside its behavior.

Common pitfall

Building the Strategy interface too narrowly around the first implementation's needs, then discovering the second implementation doesn't fit it — a compress(data) -> bytes interface works fine until a strategy that needs a streaming API (compress(stream) -> stream) comes along, and now the interface itself needs breaking changes across every existing implementation. The fix isn't to guess the interface perfectly upfront; it's to write the second real implementation before finalizing the interface shape, since a single implementation can't reveal which parts of its interface are genuinely general versus accidentally specific to how that one algorithm happens to work.

Engineering Lens

Strategy's real value shows up in what a code review can not find, not what it can: if adding a new payment provider or ranking algorithm requires touching code outside the new strategy's own file, the abstraction has a leak somewhere — either the interface is missing a hook the new strategy needs, or the context is doing algorithm-specific work it shouldn't. The pattern is also a quiet enabler of experimentation infrastructure: A/B testing two ranking algorithms, or canarying a new retry policy against the incumbent, is nearly free once the algorithm is already behind a Strategy interface, and painful to retrofit if it isn't — worth flagging in a design review before the first "just add a temporary if-check for the experiment" shortcut gets merged and start compounding.

  • State Pattern — structurally similar (both delegate to a swappable object behind an interface), but Strategy's implementations are independent and caller-selected while State's are mutually exclusive modes the object transitions through itself

Sources

Hermes Wiki