Engineering Atlas › Low-Level Design

Strategy pattern

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

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 merge

Every 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

  1. Business rules vary along one axis (how to merge, how to price, how to pick a server) while the surrounding code stays the same.
  2. Branching on a mode inside that code couples every variant to every other, so the variant moves behind an interface.
  3. The choice often depends on configuration or the customer, so it must be a value chosen at runtime, not a compile-time branch.
  4. 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 --> OUT

Worked 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

OptionStrengthsWeaknessesChoose when
if/switch on a modeEverything in one place; compiler can check exhaustivenessEvery variant edits one function; tests grow togetherTwo or three small, stable variants
Function parameterLightest form; no class neededNo name for logs or config; awkward with settingsOne-method algorithms such as sort keys
Strategy interfaceNamed, configurable, testable alone; composableIndirect call; more types to readVariants chosen by configuration or customer
Template Method (subclass)Base class enforces the skeletonInheritance coupling; fixed at constructionA framework owns the algorithm's steps
Generics or compile-time policyInlined, no dispatch costChoice fixed at build time; code bloatHot inner loops (sorting, hashing)

Pitfalls in production

Leadership lens

Check yourself

  1. 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.

  2. Why is slices.Sort on a []int faster than sort.Sort with a sort.Interface on the same data?

    Show answer

    sort.Sort makes an indirect call through the itab for every Less and Swap, which blocks inlining. The generic version compiles the comparison into the loop.

  3. 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.

  4. 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

Free resources

Open in the interactive app: practice, quiz, mock interview →