Strategy pattern
Open in the interactive app: practice, quiz, mock interview →In one minute
- Strategy puts one varying algorithm behind a small interface, so the code that uses it (the context) can run any of several interchangeable versions without knowing which one it holds.
- In modern languages a strategy is often just a function: a
key=in Python'ssorted, a comparator in Java, afunc(a, b T) intin Go. Reach for an interface or class when the algorithm has a name, configuration, or more than one method. - The win is that choosing the algorithm and running it become separate decisions. The choice moves to configuration or the composition root; the context stays closed to modification and open to new strategies.
- The costs are an indirect call that blocks inlining, an extra allocation when strategies capture state, and a configuration surface that can name a strategy that does not exist. Validate the choice at startup.
- Agents add "strategies" freely. In review, check that there are at least two real implementations, that the context never asks which one it has, and that every strategy honours the same contract.
How it works
The problem
A notes app syncs documents between a phone and a server. When both sides edited the same note while the phone was offline, the sync engine has three versions: the common ancestor (the base), the local copy and the remote copy. The first engine had one rule baked in: keep whichever copy has the later timestamp. That loses work. If you fixed a typo in the title on your phone and your colleague added a paragraph on the laptop, one of those edits vanishes.
The obvious fix is a flag:
def merge(base, local, remote, mode):
if mode == "lww":
...
elif mode == "server":
...
elif mode == "fields":
... # 40 lines of three-way mergeEvery new rule edits this function, and its tests grow combinatorially. The checklist collection wants per-field merging, the billing settings must always take the server's copy, and a customer asks for "keep both and let me pick". Soon merge has six modes and each mode's bugs can break the others.
Pull the algorithm out
Strategy names the varying part and gives it an interface:
class LastWriterWins:
def resolve(self, base, local, remote):
newer = remote if (remote.ts, remote.node) > (local.ts, local.node) else local
return dict(newer.fields)
class Merger:
def __init__(self, strategies, default):
self.strategies, self.default, self.per_collection = strategies, default, {}
def merge(self, collection, base, local, remote):
name = self.per_collection.get(collection, self.default)
return self.strategies[name].resolve(base, local, remote)Merger is the context. It owns everything that does not vary: looking up which rule applies, loading the versions, writing the result. Each strategy owns one algorithm and can be tested alone with three tiny inputs. Adding "keep both" is a new class and one registration.
Strategies compose
A three-way field merge resolves most conflicts without help: if only one side changed a field, take that side. It still needs a rule for a field that both sides changed differently. Rather than hard-coding one, FieldMerge takes another strategy as its fallback and asks it only about the conflicting fields. Strategies that wrap strategies are common in production: a retry policy wraps a backoff policy, a load balancer wraps a health filter around a picking rule, and a cache wraps an eviction policy.
Functions are strategies too
In Python, TypeScript and Go, a single-method strategy is usually a function. sorted(rows, key=lambda r: r.price) passes a strategy. So does slices.SortFunc(xs, cmp) in Go and array.sort((a, b) => a - b) in JavaScript. Go's standard library shows both styles side by side: http.Handler is an interface, and http.HandlerFunc is an adapter type that lets any function satisfy it. Use a plain function when the algorithm has no name and no configuration. Use an object when it carries settings (a cap, a fallback), has more than one method, or must be listed by name in configuration and logs.
Strategy, State and Template Method
Strategy and State have the same class diagram and different intent. A strategy is chosen from outside, usually once, and does not know about its siblings. A state object is swapped by the states themselves as events arrive (see the State lesson). Template Method gets variation through inheritance instead: the base class owns the algorithm and subclasses override steps. Strategy is the composition version of the same idea, and it lets you change the algorithm at runtime without a new subclass.
Reviewing agent-written code
Ask an agent to "support a second merge mode" and it will often add an elif inside the existing function. Ask it to "use the Strategy pattern" and it may produce MergeStrategy, AbstractMergeStrategy, MergeStrategyFactory and one implementation. Steer between the two. A strategy interface is worth it when two implementations exist today, the choice comes from configuration, or tests need a stub. Then check three things: the context never calls isinstance or switches on the strategy's type; every strategy has the same failure behaviour (no strategy that silently returns None where others raise); and the configuration is validated when it is loaded, not when the first conflict arrives at 3 a.m.
The same shape elsewhere
Strategy is how systems handle hot spots and overload without rewriting the hot path. The Linux kernel picks a TCP congestion control algorithm per socket. Kafka producers pick a partitioner. Envoy picks a load-balancing policy per cluster. Each one keeps the data path fixed and makes the decision pluggable, so operators can change behaviour under load by changing a name in configuration.
Down the stack
Layer 1The contract
Every strategy in a family accepts the same inputs, returns the same kind of output, and fails the same way. For the merge family that means: never mutate the inputs, return a fresh dictionary, be deterministic for the same three versions (so two replicas that merge the same conflict converge), and break ties by a stable rule such as node id rather than by arrival order. The context promises to call only the interface and to validate a strategy name when configuration is loaded. Whether a strategy object may be shared between threads is part of the contract: stateless strategies can be one shared instance; stateful ones (a round-robin counter, a token bucket) need one instance per context or their own lock.
Layer 2The mechanism
Linux is a clean real example. A congestion control algorithm is a struct tcp_congestion_ops holding function pointers such as ssthresh, cong_avoid, cong_control and undo_cwnd, plus a name. Modules call tcp_register_congestion_control to add themselves to a list. The net.ipv4.tcp_congestion_control sysctl sets the default, and a single socket can choose another with setsockopt(fd, IPPROTO_TCP, TCP_CONGESTION, "bbr", 3). The TCP stack calls icsk->icsk_ca_ops->cong_avoid(sk, ack, acked) without knowing whether it is CUBIC or BBR.
In Java, Collections.sort(list, comparator) hands a Comparator to TimSort, which calls compare on every comparison. Python's sorted(key=...) calls the key function once per element and caches the results, which is why a key function beats functools.cmp_to_key for anything but the strangest orders. Go has both generations: sort.Sort(data sort.Interface) calls Len, Less and Swap through an interface, and slices.SortFunc(s, cmp) takes a generic function.
Layer 3The machine
A strategy call is an indirect call. In C++ and Java it loads the vtable pointer from the object, then the method pointer from the vtable. In Go it loads the method from the interface's itab, a table built once per (interface, concrete type) pair and cached. A predicted indirect call costs about 1 to 2 ns; a mispredicted one costs 15 to 20 cycles. The larger cost is lost inlining. A comparator called through an interface 20 million times in a sort cannot be inlined into the loop, so the compiler cannot keep values in registers or vectorise. That is why slices.Sort on a []int beats sort.Sort on the same data: the generic version compiles the comparison into the loop.
Runtimes fight back with speculation. The JVM records receiver types at each call site and, if one type dominates, inlines that method behind a type check. V8's inline caches do the same in JavaScript: a call site that has only seen one strategy class is monomorphic and fast, two to four classes is polymorphic, and beyond four it goes megamorphic and falls back to a slower generic lookup through the prototype chain. Go's profile-guided optimisation devirtualises hot interface calls when the profile shows one dominant type.
Allocation matters too. A stateless strategy can be a single shared object or a top-level function: zero allocations per call. A closure that captures variables usually escapes to the heap in Go and allocates a function object in JS and Python. Building a fresh strategy per request at 50,000 requests per second is 50,000 small objects per second for the garbage collector. Swapping a strategy at runtime from another thread needs an atomic publish, such as Go's atomic.Pointer[T] or a volatile field in Java, so readers see either the old or the new object, never a half-written one.
Why it's built this way
- Business rules vary along one axis (how to merge, how to price, how to pick a server) while the surrounding code stays the same.
- Branching on a mode inside that code couples every variant to every other, so the variant moves behind an interface.
- The choice often depends on configuration or the customer, so it must be a value chosen at runtime, not a compile-time branch.
- Runtime choice costs an indirect call and lost inlining, so hot inner loops prefer generics or monomorphic call sites, and cold policy decisions use plain interfaces.
Diagram
flowchart LR
CFG["config: collection to name"] --> M["Merger.configure"]
M --> V{"name registered?"}
V -- "no" --> E["error: unknown strategy"]
V -- "yes" --> T["per-collection table"]
REQ["merge(collection, base, local, remote)"] --> T
T --> L["LastWriterWins"]
T --> S["PreferRemote"]
T --> F["FieldMerge"]
F -- "conflicting fields only" --> L
L --> OUT["merged fields"]
S --> OUT
F --> OUTWorked example
A note-taking product with 4 million daily active users runs about 30 million sync merges a day. Roughly 0.5% of them, 150,000, have edits on both sides since the last sync. With last-writer-wins on whole documents, each of those drops one side's edits. Support tickets about "my changes disappeared" averaged 40 a day.
The team split the merge into strategies. Notes and checklists moved to FieldMerge(LastWriterWins()). Of the 150,000 concurrent merges, 141,000 touched different fields and now keep both sides' edits. The remaining 9,000 touch the same field; those still fall back to the newer value, and the app now records them so a later "keep both" strategy can show the user a choice. Workspace billing settings use PreferRemote, because a phone must never overwrite a plan change made by an admin. Tickets fell to about 4 a day.
The cost check was trivial. A merge is dominated by loading three versions from SQLite on the phone (about 2 ms) and the network round trip. The strategy lookup is one dictionary access and one indirect call, under a microsecond. The one regression was a configuration typo, field_merge instead of field-merge, that shipped to 2% of users in a canary. The old code fell back to the default silently. The fix was to make configure reject unknown names and to load configuration through it at startup, so the bad rollout would have failed the canary health check immediately.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
if/switch on a mode | Everything in one place; compiler can check exhaustiveness | Every variant edits one function; tests grow together | Two or three small, stable variants |
| Function parameter | Lightest form; no class needed | No name for logs or config; awkward with settings | One-method algorithms such as sort keys |
| Strategy interface | Named, configurable, testable alone; composable | Indirect call; more types to read | Variants chosen by configuration or customer |
| Template Method (subclass) | Base class enforces the skeleton | Inheritance coupling; fixed at construction | A framework owns the algorithm's steps |
| Generics or compile-time policy | Inlined, no dispatch cost | Choice fixed at build time; code bloat | Hot inner loops (sorting, hashing) |
Pitfalls in production
- Strategies that disagree on edge cases: one returns an empty result on bad input, another raises. Fix: write one shared contract test suite and run every strategy through it.
- An unknown strategy name that falls back silently to the default. Fix: validate names when configuration loads and fail the deploy or reject the reload.
- A stateful strategy (a round-robin index, a rate limiter) shared across contexts or threads, so counters interleave. Fix: document statefulness and create one per context, or guard it with a lock.
- The context inspecting the concrete type (
if isinstance(s, FieldMerge)) to add a special case. Fix: add the capability to the interface or move the special case into the strategy. - A non-deterministic strategy (ties broken by arrival order or a random number) in a replicated system, so replicas diverge. Fix: break ties with stable data such as node id.
- Building a fresh strategy object per request in a hot path. Fix: share stateless strategies and construct stateful ones once per context.
Leadership lens
- Ask what varies, who chooses, and how often. A strategy chosen by the customer or by operations is a product feature; it needs documentation, metrics per strategy and a safe default.
- Treat strategy names as an API. Renaming
lwwbreaks every saved configuration, so version names and keep aliases. - Require a shared contract test for each strategy family. It is the cheapest way to let other teams, or an agent, add strategies safely.
- Push back on speculative strategies. One implementation behind an interface is a cost with no benefit until the second one exists.
- Log which strategy made each important decision. When a merge, price or route looks wrong, the first question on call is "which rule ran?".
Check yourself
Strategy and State have the same class diagram. What is the practical difference?
Show answer
A strategy is chosen from outside the context and does not change itself, while state objects replace each other as events arrive. Strategies do not know their siblings; states usually do.
Why is
slices.Sorton a[]intfaster thansort.Sortwith asort.Interfaceon the same data?Show answer
sort.Sortmakes an indirect call through the itab for everyLessandSwap, which blocks inlining. The generic version compiles the comparison into the loop.A field merge falls back to last-writer-wins for conflicting fields. Why must the tie-break use node id rather than "whichever arrived first"?
Show answer
Two replicas can see the versions in different orders. A rule based on data in the versions gives the same answer everywhere, so they converge.
When is a plain function a better strategy than an interface?
Show answer
When the algorithm has one method, no settings, and does not need a name in configuration or logs, such as a sort key.
Watch
- Strategy Pattern, Design Patterns ep 1 (Christopher Okhravi): builds the pattern from the problem of duplicated behaviour in a class hierarchy.
- 10 Design Patterns Explained in 10 Minutes (Fireship): a fast tour that shows Strategy next to the patterns it is often confused with.
Free resources
- Strategy pattern (Refactoring.Guru): the structure, its relation to State and Template Method, and code in many languages.
- Composition over inheritance (Brandon Rhodes, python-patterns.guide): why passing behaviour in beats subclassing for each variant.
- Go Data Structures: Interfaces (Russ Cox): how itabs are laid out and how an interface method call is dispatched.
- Shapes and inline caches (Mathias Bynens): why a JavaScript call site that sees many strategy classes gets slower.
- package sort (Go docs):
sort.Interface, the classic strategy-shaped API, alongside its newer function-based helpers.