Software Design Reference System Designer · Flexible & Composable Code

Lesson 0016 · System Designer · Module 1

Injecting Behavior: Strategies and Higher-Order Functions

Lesson 0004 extracted the Strategy as classes. Python's cheaper version: pass the behavior itself. Functions are strategies too — and often the better ones.

Reading ≈ 8 minPractice ≈ 25 min

Mission tie-in: "inject behavior using strategies and higher-order functions" — the System Designer's move for making code evolve without rewrites.

Knowledge: behavior as an argument

A strategy is just a unit of behavior chosen at runtime. In Python, a callable is the cheapest possible strategy interface — no protocol, no class, no ceremony:

from collections.abc import Callable

Discount = Callable[[Money], Money]             # strategy: total -> amount off

def percentage(rate: Decimal) -> Discount:      # factory: makes a strategy
    def apply(total: Money) -> Money:
        return total * rate
    return apply

def over_threshold(minimum: Money, then: Discount) -> Discount:
    def apply(total: Money) -> Money:
        return then(total) if total >= minimum else Money(Decimal("0"), total.currency)
    return apply

def final_price(total: Money, discount: Discount) -> Money:
    return total - discount(total)

# 10% off, but only on orders of 100 EUR or more:
policy = over_threshold(Money(Decimal("100"), "EUR"), percentage(Decimal("0.10")))

final_price never changes when pricing policy changes — new policies are new functions, passed in. That's the strategy pattern with the interface made implicit.

Higher-order functions — functions that take or return functions — give you composition for free:

def capped(discount: Discount, maximum: Money) -> Discount:   # wraps a strategy
    def apply(total: Money) -> Money:
        return min(discount(total), maximum)
    return apply

# 10% off, only over 100 EUR, never more than 20 EUR off:
policy = capped(
    over_threshold(Money(Decimal("100"), "EUR"), percentage(Decimal("0.10"))),
    maximum=Money(Decimal("20"), "EUR"),
)

Read what just happened: over_threshold and capped take a strategy and return a strategy. Each new rule is a small function, and business policy becomes an expression you can read top to bottom. The class version of this needs a decorator class per rule and an interface they all implement — the same design, three times the ceremony.

When should the strategy be a class instead? When it carries state (lesson 0015) or must participate in polymorphism with a nameable interface (lesson 0017). A stateless one-method strategy is a function's job.

The principle behind the principle

Injection works by converting a modification into a composition. The decision about which behaviour runs moves from inside the module to its edge, and the core stops being on the change path. The measurable form of that is diff size: adding a variant to a branch chain touches the module, its imports and its tests; adding a variant to an injected seam adds a new function and one wiring line. Open–closed is not a moral principle, it is a statement about which files appear in the next pull request.

The bill arrives as lost traceability. A reader of the core can no longer see what runs; a stack trace shows a closure called apply; a log line says a discount was applied without saying which one. You have traded "what happens here is obvious, changing it is expensive" for "changing it is cheap, knowing what happened requires a tool". That trade is good when variants are many or arrive from outside your team, and bad when there is exactly one variant that has been stable for two years — then the branch is both cheaper and more honest. And once an injection point becomes something other teams plug into, its signature is a compatibility surface you support forever, which is a much larger commitment than the flag you deleted.

How a senior designer works the case

A retailer's pricing service, injected discount callables, no branch chain anywhere — textbook. Then a customer receives a promotion twice. Support cannot reproduce it; the order log contains one line: discount_applied=12.40. Three engineers spend a day reconstructing which policies were in the list, in what order, for that customer, on that day.

What they look at first. Not the stacking logic. The log line — because the design question here is not "is this extensible?" (it plainly is) but "when this misbehaves at 02:00, what will the on-call engineer be able to see?" The refactor that removed the branch also removed the only place a name existed.

The alternative they reject. Someone proposes moving pricing rules into a YAML rules engine so marketing can change them without a deploy. It is the same instinct — push variation outward — taken one step too far: it produces a second programming language with no type checker, no debugger, no tests and no code review, and it converts the 02:00 problem from "read three Python functions" into "debug an interpreter we wrote ourselves". (Lesson 0029 is the longer argument.) Rejected — but the impulse behind it is right: the rules did need identity.

What they choose. Keep the injected callables; give each one a name, and make the applied names part of the result rather than a log statement:

@dataclass(frozen=True)
class Policy:
    name: str
    apply: Discount                      # Callable[[Money], Money]

def percentage(rate: Decimal) -> Policy:
    def apply(total: Money) -> Money:
        return total * rate
    return Policy(f"percentage:{rate}", apply)

def final_price(total: Money, policies: Sequence[Policy]) -> PricedTotal:
    off = Money(Decimal("0"), total.currency)
    applied: list[str] = []
    for policy in policies:
        amount = policy.apply(total)
        if amount.amount:
            off = off + amount
            applied.append(policy.name)  # the receipt travels with the price
    return PricedTotal(total - off, tuple(applied))

The consequence over time. The next stacking incident is a grep, not an investigation: the order stores ("percentage:0.10", "percentage:0.10") and the duplicate is visible in the data. Finance's quarterly "why did this order get 22% off" is answerable without a code reading. The cost is real and small — a name per policy, and a team rule that lambdas are not allowed at this seam — and it buys back exactly the property injection took away. That is the general shape of the move: when you push a decision outward, push its identity outward with it, or you will pay for the flexibility in incident time.

† functools.partial and closures do the same binding work without inventing classes: a partially applied function is a configured strategy.
Field notes · what it looks like in real code
You see thisWhat it costsThe move
def send(data, urgent: bool)Control coupling: the caller steers with a flag it must decode.Inject the behaviour; delete the branch.
if callback is None: callback = defaultThe default is a policy hidden inside the callee.Choose the default at the composition root.
a class with __init__ and one methodA closure with extra syntax around it.A function factory returning the callable.
a callable threaded through five layersThat parameter is configuration, not an argument.Bind it once with functools.partial at the edge.
What people get wrong after reading this
"Dependency injection means a DI container."
Passing an argument is dependency injection. In Python the container is almost always optional and frequently a net loss: it moves wiring from readable code into a registry with its own lifecycle rules, and stack traces stop naming the thing that constructed your object.
"Callables beat strategy classes."
They win on ceremony and lose on identity. A class gives you a type name in tracebacks, a repr, a home for construction-time validation, and room for a second related method. When either of those matters more than brevity, take the class.
"If it's injected, it's decoupled."
The coupling moved; it did not vanish. Every implementation is now bound to a signature and to unwritten semantics — does a discount return the amount off, or the reduced total? Get that backwards and every strategy inverts silently. Contracts at a seam need a docstring and a shared test, or the seam is a trap.
"More injection points means more flexible."
Each one is a decision deferred to whoever wires the system. A module with six injected collaborators has not become configurable; it has relocated its design into its caller, who now needs to understand all six to use it correctly.

When this is the wrong move

Skill: callable or class?

A stateless discount strategy is best injected as:

A new pricing policy arrives. With injected callables you:

The class version of a strategy earns its keep when:

Practice on your own code

Find a function with a flag or a type field that selects behavior (a control-coupling smell from the coupling ladder). Replace the flag with an injected callable, and move the selection to the caller.

In the lab: the phase-2 checkpoint gives you a returns-resolution routine steered by two boolean parameters and a mode string, and asks you to replace them with injected, named behaviour — then to show, from the recorded result alone, which path a given return actually took.

Reveal: flag to injection
# before: control coupling — the flag steers behavior
def send_report(data, urgent: bool) -> None:
    if urgent:
        channel = pager()
    else:
        channel = email()
    channel.deliver(data)

# after: the behavior is injected; no flag, no branch
def send_report(data, channel: Channel) -> None:
    channel.deliver(data)

send_report(data, pager())     # or email() — the caller decides
Teach it back
  1. In two sentences, explain to a junior what injection takes away, not only what it gives.
  2. Find one injected callback or strategy in your code this week. Ask what a production log would tell you about which one ran, and price the gap in minutes of investigation.
  3. Assume this seam gains one new variant per quarter for a year, all anonymous. Describe the first incident where nobody can say what executed.

Your win

You can now turn flag-driven branches into injected behavior, and you know the class-vs-callable rule: state earns the class, everything else takes the function.

Read and watch deeper

Convert a flag-parameter function with your agent-teacher and compare the injected-callable design.