Skip to content
BytePatterns

Strategy Pattern Explained: Replace an If/Elif Chain With Objects

7 min readBytePatterns

The strategy pattern explained: lift the step that varies into swappable objects, delete the if/elif chain, and know when a plain function is the better fit.

Most code that needs a design pattern starts as an if statement that kept growing. A pricing function gains a branch per promotion, a retry loop gains a branch per backoff policy, a stage timer gains a branch per road surface. The strategy pattern is the standard fix: take the one step that varies, give each variant its own object with the same method name, and let the surrounding code call whichever one it was handed. It is the pattern low-level design interviews expect you to reach for first, because it is small, and because knowing when not to use it matters as much.

The problem it solves

Picture a workflow with one step that changes and several that do not. A rally stage always converts grip into a stage time the same way; only the grip depends on the tyres. Written as one function, the tyre choice becomes an if/elif chain inside the workflow. Every new surface means reopening that function, retesting every existing branch, and trusting that nobody broke the dry path while adding the snow path.

That is the smell the pattern targets: a conditional that picks an algorithm, living inside code whose job is something else. The chain grows with every variant, and the variants cannot be tested on their own because they are not things, only branches.

The intuition

Three roles, all small.

  • The strategy interface is one method signature, such as grip(base) or delay(attempt). Nothing more.
  • Concrete strategies each implement it one way: slicks, wets, gravel; fixed, linear, exponential backoff.
  • The context holds a reference to one strategy and calls it at the right moment. It never asks which one it has.

The decision about which variant to use does not disappear; it moves. Instead of being made inside the workflow every time, it is made once, at the edge of the program: from a config value, a user setting, a feature flag. After that, the context just delegates. Swapping at runtime is an assignment, because the context only holds a reference.

This is the open/closed principle in its most practical form. The workflow is closed for modification, since adding a surface never touches it, and open for extension, since a new surface is a new strategy.

In Python there is a shortcut worth saying out loud: functions are objects, so a strategy is often just a function. sorted(words, key=str.lower) is the strategy pattern; key is the swappable step and sorted is the context. Classes earn their place when a strategy needs configuration or state, like a backoff with a cap.

Watch it run

The animation is the lesson's rally stage. Same car, same driver, same stage; only one step varies, how much grip today. First the if/elif version appears, baking every surface into Stage and growing with each new one. Strategy then lifts the varying step out: one object each, one fixed method name. The context holds whichever one is plugged in and calls it, never asking which. With slicks loaded, s.time(1) gives 120.0: dry stage, full grip. It starts raining, so the part is swapped between runs and the context is not rebuilt. The same method call gives a different answer, 150.0, and Stage was not reopened for it; not one line of the workflow changed. A third surface is a third function plus one assignment, no new class: Stage(gravel).time(1) prints 200.0. The closing frame makes the testing point: each branch of the old chain is now an object you can test on its own.

Strategy Pattern

Step 1 of 11

Same car, same driver, same stage. Only one step varies: how much grip today.

The same interactive animation as the lesson — step through it with the controls.

The code

The chain and its replacement side by side. Both produce the same stage times; only one of them has to be edited when a fourth surface arrives:

def stage_time_chain(surface, base):
    if surface == "dry":                   # every new surface reopens this function
        grip = base * 1.0
    elif surface == "wet":
        grip = base * 0.8
    elif surface == "gravel":
        grip = base * 0.6
    else:
        raise ValueError(surface)
    return round(120 / grip, 2)

def slicks(base): return base * 1.0
def wets(base):   return base * 0.8
def gravel(base): return base * 0.6

class Stage:
    def __init__(self, tyres):
        self.tyres = tyres                 # the swappable step
    def time(self, base):
        return round(120 / self.tyres(base), 2)

s = Stage(slicks)
print(s.time(1), stage_time_chain("dry", 1))       # 120.0 120.0
s.tyres = wets                                     # swapped at runtime
print(s.time(1), stage_time_chain("wet", 1))       # 150.0 150.0
print(Stage(gravel).time(1))                       # 200.0

Strategies with configuration are where classes help. A retry loop is the context; the backoff policy is the strategy. Protocol states the interface without forcing inheritance, and the choice from config happens once, in a dictionary, at the edge:

from typing import Protocol

class Backoff(Protocol):
    def delay(self, attempt: int) -> float: ...

class Fixed:
    def __init__(self, seconds): self.seconds = seconds
    def delay(self, attempt): return self.seconds

class Linear:
    def __init__(self, step): self.step = step
    def delay(self, attempt): return self.step * attempt

class Exponential:
    def __init__(self, base, cap): self.base, self.cap = base, cap
    def delay(self, attempt): return min(self.cap, self.base * 2 ** (attempt - 1))

class Retrier:
    """The context: owns the retry workflow, delegates only the wait."""
    def __init__(self, backoff: Backoff, attempts: int):
        self.backoff, self.attempts = backoff, attempts

    def run(self, call):
        waits = []
        for attempt in range(1, self.attempts + 1):
            if call(attempt):
                return "ok", waits
            if attempt < self.attempts:    # no pointless wait after the last try
                waits.append(self.backoff.delay(attempt))
        return "gave up", waits

flaky = lambda attempt: attempt == 5           # succeeds on the fifth try
for b in (Fixed(1), Linear(1), Exponential(1, cap=10)):
    print(type(b).__name__, Retrier(b, attempts=6).run(flaky))
# Fixed ('ok', [1, 1, 1, 1])
# Linear ('ok', [1, 2, 3, 4])
# Exponential ('ok', [1, 2, 4, 8])

BACKOFFS = {"fixed": Fixed(2), "linear": Linear(2), "exp": Exponential(2, cap=30)}
policy = BACKOFFS["exp"]                       # chosen from config, once, at the edge
print(Retrier(policy, attempts=6).run(lambda a: False))
# ('gave up', [2, 4, 8, 16, 30])

The standard library already speaks this pattern. sorted is the context and key is the strategy:

words = ["pear", "Fig", "apple", "kiwi"]
print(sorted(words), sorted(words, key=str.lower), sorted(words, key=len))
# ['Fig', 'apple', 'kiwi', 'pear'] ['apple', 'Fig', 'kiwi', 'pear'] ['Fig', 'pear', 'kiwi', 'apple']

A refactor is only safe if it changes nothing observable. On 3,000 seeded random inputs, the strategy version must match the original chain, and every backoff must match its formula written out by hand:

import random

TYRES = {"dry": slicks, "wet": wets, "gravel": gravel}

def backoff_by_hand(kind, n, attempt):
    """Brute force: the delay written out as the old if/elif would have it."""
    if kind == "fixed":
        return n
    if kind == "linear":
        return n * attempt
    return min(30, n * 2 ** (attempt - 1))

random.seed(25)
ok = True
for _ in range(3_000):
    surface, base = random.choice(list(TYRES)), random.randint(1, 50) / 10
    ok &= Stage(TYRES[surface]).time(base) == stage_time_chain(surface, base)
    kind, n, attempt = random.choice(["fixed", "linear", "exp"]), random.randint(1, 5), random.randint(1, 8)
    strategy = {"fixed": Fixed(n), "linear": Linear(n), "exp": Exponential(n, cap=30)}[kind]
    ok &= strategy.delay(attempt) == backoff_by_hand(kind, n, attempt)
print(ok)                                  # True

The complexity

  • Runtime cost: one extra indirect call per use. Negligible next to almost any real work.
  • Code cost: one small class or function per variant, plus the interface. For two variants that will never grow, that can be more ceremony than the if it replaces.
  • Change cost: adding a variant touches zero existing lines of the workflow, which is the whole point.

Where it goes wrong

  • Moving the chain instead of deleting it. If the context still does if isinstance(strategy, Wets), nothing was gained. The context must not know which strategy it holds.
  • Fat interfaces. A strategy with six methods, half of them unused by some variants, is several patterns glued together.
  • Strategy for two stable cases. A boolean parameter is fine when the variation is tiny and closed.
  • Confusing it with State. In the state pattern, the object swaps its own behaviour as it changes state; in Strategy, the caller chooses from outside.
  • Choosing the strategy deep inside. Pick it once, at the edge, and inject it.

When it shows up in interviews

Strategy is the pattern most low-level design rounds expect by name. In a parking lot design the fee rule is a strategy; in a payment system the payment method is; in a cache the eviction policy is; in a rate limiter the algorithm is. Interviewers also ask the comparison questions: strategy against inheritance, strategy against state, and strategy against the observer pattern, where many listeners react to one event rather than one step being swapped.

How to say it in an interview

"The part that varies is the fee rule, so I pull it out behind one method, fee(ticket), and give each rule its own class. The lot holds a reference to one rule and calls it, without knowing which. The choice is made once, from configuration, and injected. Adding a weekend rule is a new class and no edits to the lot, and every rule can be tested in isolation. In Python, if a rule has no state, I would just pass a function; that is the same pattern with less ceremony."