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.

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.

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.

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.

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

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.