Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Design Patterns (GoF and modern)  Module 4 – Behavioral patterns: algorithms and requests

Strategy: swap algorithms at runtime

Replace a growing if/else with interchangeable strategies: objects, functions or generics, picked from configuration, with code that runs in six languages.

  • Beginner
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0, Java 25, Go 1.27.1, Rust 1.99.0 and Clang 21.0.0
  • By MySmartCoPilot
Free: 25 minutes

What you will learn

  • Replace conditional algorithm selection with interchangeable strategies
  • Implement strategies as objects, functions and generic parameters, choosing per language
  • Compare static and dynamic dispatch for strategies in Rust and C++
  • Select a strategy from configuration without a growing switch

Before you start

What you’ll learn · Before you start

What you will learn

  • Replace conditional algorithm selection with interchangeable strategies
  • Implement strategies as objects, functions and generic parameters, choosing per language
  • Compare static and dynamic dispatch for strategies in Rust and C++
  • Select a strategy from configuration without a growing switch

Before you start

On this page

Strategy is a design pattern for code that can do one job in several ways. Each way of doing it, each algorithm, goes behind one small interface, and the code that needs the job done holds one of them and calls it without knowing which one it holds. Changing the behaviour then means handing over a different strategy, not adding a branch to a growing if/elif chain. The Gang of Four catalogue states the intent as defining a family of algorithms, wrapping each one up and making them interchangeable. Reach for it when one step of a program varies by customer, country, configuration or experiment, and new variants keep arriving. In a language with first-class functions a strategy is often just a function: Python’s sorted(key=…) and Java’s Comparator lambdas are strategies you have already used.

This lesson starts from a shipping-cost function with every rule in one place, turns the rules into strategies, runs the same table of orders in six languages, and ends with when the pattern is not worth it.

The problem: one function that knows every rule

A shop charges shipping by one of three rules, picked in its settings: a flat amount per order, a base amount plus a charge for every started kilogram, or nothing on orders above a threshold. The first version keeps all three in one function, chosen by the rule’s name:

Every rule in one function, chosen by name Python · shipping/before.py
# Shipping cost for an order, with every rule in one function. The rule comes from the shop's settings.
ORDERS = [  # (order id, subtotal, weight in grams)
    ("A", 120, 400),
    ("B", 499, 2000),
    ("C", 500, 2000),
    ("D", 1250, 7500),
    ("E", 60, 12000),
    ("F", 999, 0),
]


def shipping_cost(subtotal, grams, settings):
    rule = settings["rule"]
    if rule == "flat":
        return settings["amount"]
    elif rule == "weight":
        kilos = (grams + 999) // 1000  # every started kilogram counts
        return settings["base"] + settings["per_kg"] * kilos
    elif rule == "free-above":
        if subtotal >= settings["threshold"]:
            return 0
        return settings["otherwise"]
    else:
        return 0  # "nothing to charge" looked like a safe default


for settings in [
    {"rule": "free-above", "threshold": 500, "otherwise": 60},
    {"rule": "free_above", "threshold": 500, "otherwise": 60},  # one character different
]:
    costs = [shipping_cost(subtotal, grams, settings) for _, subtotal, grams in ORDERS]
    print(f"{settings['rule']:<11} {costs}  shipping collected: {sum(costs)}")

Output

free-above  [60, 60, 0, 0, 60, 0]  shipping collected: 180
free_above  [0, 0, 0, 0, 0, 0]  shipping collected: 0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 before.py

The first line of the output is right. The second runs the same settings with an underscore where a hyphen should be, the kind of typo that a settings file or a feature flag makes easy, and it reaches the final else: every order ships at no charge, and the shop collects nothing. Nothing failed, so nothing told anyone.

The typo is the visible problem; the shape of the function is the real one. Every new rule edits a function that every order goes through, so each change puts the old rules at risk. No rule can be tested on its own, because all of them live behind one name. The settings of one rule (threshold) sit in the same dictionary as the settings of another (per_kg), and nothing checks that they belong together. And the else at the bottom has to invent an answer for rules it has never heard of.

The pattern: a context, a strategy and the concrete strategies

Strategy splits that function into three roles. The strategy is the interface every rule shares: here one operation, cost(order). Each concrete strategy is one rule that implements it, with its own settings. The context is the code that needs a cost, the checkout, and it holds one strategy and calls it:

Class diagram: Checkout holds one ShippingRule interface, which FlatRate, ByWeight and FreeAbove each realise.Checkout (context)-ruleShippingRule+total(order)int«interface»ShippingRule (strategy)+cost(order)intFlatRate-amountint+cost(order)intByWeight-baseint-per_kgint+cost(order)intFreeAbove-thresholdint-otherwiseint+cost(order)intholds 1,calls cost()

Strategy in the shipping example: the context, the strategy and three concrete strategies

Text description of the diagram

Five class boxes in three rows. Each box shows the class name, then its attributes, then its operations; a plus sign marks a public member and a minus sign a private one, and the type follows each name.

At the top is Checkout, labelled the context. It has a private attribute rule of type ShippingRule and a public operation total(order) returning int.

In the middle is ShippingRule, marked «interface» and labelled the strategy, with one operation, cost(order), returning int. A line with a hollow diamond at Checkout and an arrowhead at ShippingRule, labelled "holds 1, calls cost()", says that a checkout holds one rule and calls it without knowing which rule it is.

In the bottom row are the three concrete strategies, each with its own cost(order): FreeAbove, with a private threshold and otherwise; ByWeight, with a private base and per_kg; and FlatRate, with a private amount. Each is joined to ShippingRule by a dashed line with a hollow triangle at ShippingRule: it realises the interface.

The checkout depends only on the interface, so a new rule is a new class or function, and no line of the checkout changes. Someone still has to choose: the code that builds the checkout, usually at start-up, reads the settings and hands over the matching rule. That choice now happens once, in one place, instead of on every call.

Strategies as functions, kept in a registry

In Python a strategy rarely needs a class. A rule is any function from an order to a cost, and a rule with settings is a function made by a factory, a closure that remembers its own settings. A dictionary from rule names to factories, a registry, replaces the if chain:

The same rules as functions in a registry Python · shipping/shipping.py
# The same rules as strategies: each rule is a function from an order to a cost, made by a factory that takes the
# rule's settings. The checkout (the context) calls whichever rule it was given and knows none of them.
from dataclasses import dataclass


@dataclass(frozen=True)
class Order:
    id: str
    subtotal: int
    grams: int


ORDERS = [Order("A", 120, 400), Order("B", 499, 2000), Order("C", 500, 2000),
          Order("D", 1250, 7500), Order("E", 60, 12000), Order("F", 999, 0)]

RULES = {}  # rule name -> factory that builds the rule from its settings


def register(name):
    def add(factory):
        RULES[name] = factory
        return factory
    return add


@register("flat")
def flat(amount):
    return lambda order: amount


@register("weight")
def by_weight(base, per_kg):
    return lambda order: base + per_kg * ((order.grams + 999) // 1000)


@register("free-above")
def free_above(threshold, otherwise):
    return lambda order: 0 if order.subtotal >= threshold else otherwise


def make_rule(settings):
    """The rule the settings name, built with the other settings; unknown names fail here, at start-up."""
    name, params = settings["rule"], {k: v for k, v in settings.items() if k != "rule"}
    if name not in RULES:
        raise ValueError(f"unknown shipping rule {name!r}; known rules: {', '.join(sorted(RULES))}")
    return RULES[name](**params)


class Checkout:
    def __init__(self, rule):
        self.rule = rule  # the strategy

    def total(self, order):
        return order.subtotal + self.rule(order)


SETTINGS = {
    "flat": {"rule": "flat", "amount": 40},
    "weight": {"rule": "weight", "base": 20, "per_kg": 12},
    "free-above": {"rule": "free-above", "threshold": 500, "otherwise": 60},
}
checkouts = {name: Checkout(make_rule(s)) for name, s in SETTINGS.items()}

print("order  subtotal  grams | " + "  ".join(f"{name:>10}" for name in checkouts))
for o in ORDERS:
    costs = "  ".join(f"{c.rule(o):>10}" for c in checkouts.values())
    print(f"{o.id:<5}  {o.subtotal:>8}  {o.grams:>5} | {costs}")
print("total of order D with free-above:", checkouts["free-above"].total(ORDERS[3]))


# A new rule joins by registration: nothing above changes.
@register("express")
def express(base, per_kg, minimum):
    weight_rule = by_weight(base, per_kg)
    return lambda order: max(minimum, 2 * weight_rule(order))


fast = Checkout(make_rule({"rule": "express", "base": 20, "per_kg": 12, "minimum": 90}))
print("express:", [fast.rule(o) for o in ORDERS])

try:
    make_rule({"rule": "free_above", "threshold": 500, "otherwise": 60})
except ValueError as e:
    print("ValueError:", e)

Output

order  subtotal  grams |       flat      weight  free-above
A           120    400 |         40          32          60
B           499   2000 |         40          44          60
C           500   2000 |         40          44           0
D          1250   7500 |         40         116           0
E            60  12000 |         40         164          60
F           999      0 |         40          20           0
total of order D with free-above: 1250
express: [90, 90, 90, 232, 328, 90]
ValueError: unknown shipping rule 'free_above'; known rules: express, flat, free-above, weight

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shipping.py

Four things changed, and the output shows each of them:

  • Each rule is a few lines that can be read and tested alone. by_weight(20, 12) is a complete strategy; nothing else needs to exist to call it.
  • Checkout calls self.rule(order) and knows no rule by name. The table calls the same three checkouts on six orders.
  • A new rule, express, joins with @register("express") and reuses by_weight inside it. No existing line changed.
  • make_rule refuses a name it does not know, with a list of the names it does. The typo that cost the shop all its shipping in the first version now stops the program before the first order, where someone has to fix it.

Rules without settings, and rules whose settings never change, can be one shared instance for every checkout: a strategy holds no state about any single order, so two checkouts can call the same function at once.

Python Online Compiler Run shipping.py with a rule of your own, for example one that charges by distance band.

The same table in five more languages

Every language in this track can hold a strategy, but each has its own way, and the natural one is rarely a class hierarchy. These five programs build the same three rules from the same settings and print the same six orders:

Shipping rules as strategies in TypeScript, Java, Go, C++ and Rust

TypeScript · langs/shipping.ts

// Shipping rules as strategies in TypeScript: a rule is any function from an order to a cost.
type Order = { id: string; subtotal: number; grams: number };
type Rule = (order: Order) => number;
type Settings = { rule: string; [key: string]: string | number };

const RULES: Record<string, (s: Settings) => Rule> = {
  flat: (s) => () => Number(s.amount),
  weight: (s) => (o) => Number(s.base) + Number(s.per_kg) * Math.ceil(o.grams / 1000),
  "free-above": (s) => (o) => (o.subtotal >= Number(s.threshold) ? 0 : Number(s.otherwise)),
};

function makeRule(settings: Settings): Rule {
  const factory = RULES[settings.rule];
  if (!factory) throw new Error(`unknown shipping rule "${settings.rule}"; known rules: ${Object.keys(RULES).sort().join(", ")}`);
  return factory(settings);
}

const orders: Order[] = [
  { id: "A", subtotal: 120, grams: 400 }, { id: "B", subtotal: 499, grams: 2000 }, { id: "C", subtotal: 500, grams: 2000 },
  { id: "D", subtotal: 1250, grams: 7500 }, { id: "E", subtotal: 60, grams: 12000 }, { id: "F", subtotal: 999, grams: 0 },
];
const rules = [
  makeRule({ rule: "flat", amount: 40 }),
  makeRule({ rule: "weight", base: 20, per_kg: 12 }),
  makeRule({ rule: "free-above", threshold: 500, otherwise: 60 }),
];
for (const o of orders) console.log(o.id, rules.map((rule) => rule(o)).join(" "));
try {
  makeRule({ rule: "free_above", threshold: 500, otherwise: 60 });
} catch (e) {
  console.log((e as Error).message);
}

Java · langs/Shipping.java

// Shipping rules as strategies in Java 25: ShippingRule is a functional interface, so each rule is a lambda.
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.function.Function;

public class Shipping {
    record Order(String id, int subtotal, int grams) {}

    @FunctionalInterface
    interface ShippingRule {
        int cost(Order order);
    }

    static final Map<String, Function<Map<String, Integer>, ShippingRule>> RULES = new TreeMap<>(Map.of(
        "flat", s -> order -> s.get("amount"),
        "weight", s -> order -> s.get("base") + s.get("per_kg") * ((order.grams() + 999) / 1000),
        "free-above", s -> order -> order.subtotal() >= s.get("threshold") ? 0 : s.get("otherwise")));

    static ShippingRule makeRule(String name, Map<String, Integer> settings) {
        var factory = RULES.get(name);
        if (factory == null) {
            throw new IllegalArgumentException("unknown shipping rule \"" + name + "\"; known rules: " + String.join(", ", RULES.keySet()));
        }
        return factory.apply(settings);
    }

    public static void main(String[] args) {
        var orders = List.of(new Order("A", 120, 400), new Order("B", 499, 2000), new Order("C", 500, 2000),
            new Order("D", 1250, 7500), new Order("E", 60, 12000), new Order("F", 999, 0));
        var rules = List.of(
            makeRule("flat", Map.of("amount", 40)),
            makeRule("weight", Map.of("base", 20, "per_kg", 12)),
            makeRule("free-above", Map.of("threshold", 500, "otherwise", 60)));
        for (var o : orders) {
            var line = new StringBuilder(o.id());
            for (var rule : rules) line.append(' ').append(rule.cost(o));
            System.out.println(line);
        }
        try {
            makeRule("free_above", Map.of("threshold", 500, "otherwise", 60));
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }
    }
}

Go · langs/shipping.go

// Shipping rules as strategies in Go: a rule is a function type, and a map from names to factories picks one.
package main

import (
	"fmt"
	"maps"
	"slices"
	"strings"
)

type Order struct {
	ID              string
	Subtotal, Grams int
}

type Rule func(Order) int

type Settings map[string]int

var rules = map[string]func(Settings) Rule{
	"flat":       func(s Settings) Rule { return func(Order) int { return s["amount"] } },
	"weight":     func(s Settings) Rule { return func(o Order) int { return s["base"] + s["per_kg"]*((o.Grams+999)/1000) } },
	"free-above": func(s Settings) Rule { return func(o Order) int { return freeAbove(o, s) } },
}

func freeAbove(o Order, s Settings) int {
	if o.Subtotal >= s["threshold"] {
		return 0
	}
	return s["otherwise"]
}

func makeRule(name string, s Settings) (Rule, error) {
	factory, ok := rules[name]
	if !ok {
		known := slices.Sorted(maps.Keys(rules))
		return nil, fmt.Errorf("unknown shipping rule %q; known rules: %s", name, strings.Join(known, ", "))
	}
	return factory(s), nil
}

func main() {
	orders := []Order{{"A", 120, 400}, {"B", 499, 2000}, {"C", 500, 2000}, {"D", 1250, 7500}, {"E", 60, 12000}, {"F", 999, 0}}
	var chosen []Rule
	for _, c := range []struct {
		name string
		s    Settings
	}{
		{"flat", Settings{"amount": 40}},
		{"weight", Settings{"base": 20, "per_kg": 12}},
		{"free-above", Settings{"threshold": 500, "otherwise": 60}},
	} {
		rule, err := makeRule(c.name, c.s)
		if err != nil {
			panic(err)
		}
		chosen = append(chosen, rule)
	}
	for _, o := range orders {
		costs := make([]string, len(chosen))
		for i, rule := range chosen {
			costs[i] = fmt.Sprint(rule(o))
		}
		fmt.Println(o.ID, strings.Join(costs, " "))
	}
	if _, err := makeRule("free_above", Settings{"threshold": 500, "otherwise": 60}); err != nil {
		fmt.Println(err)
	}
}

C++ · langs/shipping.cpp

// Shipping rules as strategies in C++23: std::function holds any callable with the right signature.
#include <functional>
#include <map>
#include <print>
#include <stdexcept>
#include <string>
#include <vector>

struct Order {
    std::string id;
    int subtotal;
    int grams;
};

using Rule = std::function<int(const Order&)>;
using Settings = std::map<std::string, int>;

const std::map<std::string, std::function<Rule(const Settings&)>> rules = {
    {"flat", [](const Settings& s) -> Rule { return [a = s.at("amount")](const Order&) { return a; }; }},
    {"weight", [](const Settings& s) -> Rule {
         return [base = s.at("base"), per_kg = s.at("per_kg")](const Order& o) { return base + per_kg * ((o.grams + 999) / 1000); };
     }},
    {"free-above", [](const Settings& s) -> Rule {
         return [t = s.at("threshold"), other = s.at("otherwise")](const Order& o) { return o.subtotal >= t ? 0 : other; };
     }},
};

Rule make_rule(const std::string& name, const Settings& settings) {
    auto it = rules.find(name);
    if (it == rules.end()) {
        std::string known;
        for (const auto& [key, _] : rules) known += (known.empty() ? "" : ", ") + key;
        throw std::invalid_argument("unknown shipping rule \"" + name + "\"; known rules: " + known);
    }
    return it->second(settings);
}

int main() {
    std::vector<Order> orders = {{"A", 120, 400}, {"B", 499, 2000}, {"C", 500, 2000}, {"D", 1250, 7500}, {"E", 60, 12000}, {"F", 999, 0}};
    std::vector<Rule> chosen = {
        make_rule("flat", {{"amount", 40}}),
        make_rule("weight", {{"base", 20}, {"per_kg", 12}}),
        make_rule("free-above", {{"threshold", 500}, {"otherwise", 60}}),
    };
    for (const auto& o : orders) {
        std::print("{}", o.id);
        for (const auto& rule : chosen) std::print(" {}", rule(o));
        std::println("");
    }
    try {
        make_rule("free_above", {{"threshold", 500}, {"otherwise", 60}});
    } catch (const std::invalid_argument& e) {
        std::println("{}", e.what());
    }
}

Rust · langs/shipping.rs

// Shipping rules as strategies in Rust: a rule chosen at run time is a boxed closure, Box<dyn Fn>.
use std::collections::BTreeMap;

struct Order {
    id: &'static str,
    subtotal: u32,
    grams: u32,
}

type Rule = Box<dyn Fn(&Order) -> u32>;
type Settings = BTreeMap<&'static str, u32>;

fn rules() -> BTreeMap<&'static str, fn(&Settings) -> Rule> {
    let mut m: BTreeMap<&'static str, fn(&Settings) -> Rule> = BTreeMap::new();
    m.insert("flat", |s| {
        let amount = s["amount"];
        Box::new(move |_| amount)
    });
    m.insert("weight", |s| {
        let (base, per_kg) = (s["base"], s["per_kg"]);
        Box::new(move |o| base + per_kg * o.grams.div_ceil(1000))
    });
    m.insert("free-above", |s| {
        let (threshold, otherwise) = (s["threshold"], s["otherwise"]);
        Box::new(move |o| if o.subtotal >= threshold { 0 } else { otherwise })
    });
    m
}

fn make_rule(name: &str, settings: &Settings) -> Result<Rule, String> {
    let all = rules();
    match all.get(name) {
        Some(factory) => Ok(factory(settings)),
        None => {
            let known: Vec<&str> = all.keys().copied().collect();
            Err(format!("unknown shipping rule \"{name}\"; known rules: {}", known.join(", ")))
        }
    }
}

fn main() {
    let orders = [
        Order { id: "A", subtotal: 120, grams: 400 },
        Order { id: "B", subtotal: 499, grams: 2000 },
        Order { id: "C", subtotal: 500, grams: 2000 },
        Order { id: "D", subtotal: 1250, grams: 7500 },
        Order { id: "E", subtotal: 60, grams: 12000 },
        Order { id: "F", subtotal: 999, grams: 0 },
    ];
    let chosen: Vec<Rule> = vec![
        make_rule("flat", &Settings::from([("amount", 40)])).unwrap(),
        make_rule("weight", &Settings::from([("base", 20), ("per_kg", 12)])).unwrap(),
        make_rule("free-above", &Settings::from([("threshold", 500), ("otherwise", 60)])).unwrap(),
    ];
    for o in &orders {
        let costs: Vec<String> = chosen.iter().map(|rule| rule(o).to_string()).collect();
        println!("{} {}", o.id, costs.join(" "));
    }
    if let Err(e) = make_rule("free_above", &Settings::from([("threshold", 500), ("otherwise", 60)])) {
        println!("{e}");
    }
}
The five programs, built and run Python · langs/shipping_session.py
from session import run, versions

# The same six orders and three rules in five more languages. TypeScript runs on Node.js, which strips the types;
# Java runs as a single source file; the C++ file is built with the sanitizers on.
versions("node -p \"'Node.js ' + process.versions.node\"", "java -version", "go version", "clang++ --version", "rustc --version")
run("""
node shipping.ts
java Shipping.java
go run shipping.go
clang++ -std=c++23 -O1 -Wall -Wextra -fsanitize=address,undefined shipping.cpp -o shipping_cpp && ./shipping_cpp
rustc --edition 2024 -O shipping.rs -o shipping_rs && ./shipping_rs
""")

Output

# Node.js 24.21.0
# openjdk version "25.0.4.1" LTS
# go version go1.27.1 darwin/arm64
# Apple clang version 21.0.0 (clang-2100.3.34.2)
# rustc 1.99.0
$ node shipping.ts
A 40 32 60
B 40 44 60
C 40 44 0
D 40 116 0
E 40 164 60
F 40 20 0
unknown shipping rule "free_above"; known rules: flat, free-above, weight
$ java Shipping.java
A 40 32 60
B 40 44 60
C 40 44 0
D 40 116 0
E 40 164 60
F 40 20 0
unknown shipping rule "free_above"; known rules: flat, free-above, weight
$ go run shipping.go
A 40 32 60
B 40 44 60
C 40 44 0
D 40 116 0
E 40 164 60
F 40 20 0
unknown shipping rule "free_above"; known rules: flat, free-above, weight
$ clang++ -std=c++23 -O1 -Wall -Wextra -fsanitize=address,undefined shipping.cpp -o shipping_cpp && ./shipping_cpp
A 40 32 60
B 40 44 60
C 40 44 0
D 40 116 0
E 40 164 60
F 40 20 0
unknown shipping rule "free_above"; known rules: flat, free-above, weight
$ rustc --edition 2024 -O shipping.rs -o shipping_rs && ./shipping_rs
A 40 32 60
B 40 44 60
C 40 44 0
D 40 116 0
E 40 164 60
F 40 20 0
unknown shipping rule "free_above"; known rules: flat, free-above, weight

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shipping_session.py

All five print the table of the Python version and refuse the misspelt rule with the same message. The strategies differ only in how a language spells “a function with this signature”:

  • TypeScript: a function type, (order: Order) => number. Node.js runs the file directly: it removes the type annotations and does no type checking, so tsc is still the tool that checks them.
  • Java: a functional interface, an interface with a single abstract method, so a lambda can implement it. Comparator, the standard library’s best-known strategy, is declared the same way, which is why Comparator.comparing(Person::name) reads like a sentence.
  • Go: a function type, type Rule func(Order) int, stored in a map. The standard library takes strategies the same way: slices.SortFunc sorts with whatever comparison function it is given.
  • C++: std::function<int(const Order&)>, a wrapper that can hold any callable with that signature, here lambdas that capture their settings.
  • Rust: Box<dyn Fn(&Order) -> u32>, a boxed closure, because the rule is picked at run time; the next section shows the alternative.

Peter Norvig’s talk “Design Patterns in Dynamic Languages” counted 16 of the catalogue’s 23 patterns as invisible or simpler in languages such as Lisp and Dylan, and named first-class functions as the reason for four of them: Command, Strategy, Template Method and Visitor. In his words the strategy becomes “a variable whose value is a function”. A class still earns its place when a strategy has several related operations (a shipping rule that also estimates the delivery date and names itself on the invoice), when it validates its settings, or when it holds a resource such as a connection that must be opened and closed. The lesson How language features reshape patterns follows the same idea through the rest of the catalogue.

Static or dynamic dispatch: Rust and C++

Rust and C++ make you choose, in the type of the parameter, whether the strategy is fixed when the program is compiled or picked while it runs. A generic parameter, F: Fn(&Order) -> u32 in Rust or a template parameter in C++, is static dispatch: the compiler makes a separate copy of the function for each strategy type it is called with, a process Rust calls monomorphisation, and every copy knows exactly which rule it calls. A trait object, dyn Fn in Rust, or std::function in C++, is dynamic dispatch: one copy of the function, which looks up the rule to call when it runs. These programs compute the same totals both ways, then count the compiled copies:

One strategy, static and dynamic dispatch, in Rust and C++

Rust · langs/dispatch.rs

// One strategy, two kinds of dispatch. total_generic is compiled once for every closure type it is called with
// (monomorphisation); total_dyn is compiled once and calls the closure through a vtable chosen at run time.
struct Order {
    subtotal: u32,
    grams: u32,
}

#[inline(never)]
fn total_generic<F: Fn(&Order) -> u32>(orders: &[Order], rule: F) -> u32 {
    orders.iter().map(|o| rule(o)).sum()
}

#[inline(never)]
fn total_dyn(orders: &[Order], rule: &dyn Fn(&Order) -> u32) -> u32 {
    orders.iter().map(|o| rule(o)).sum()
}

fn main() {
    let orders = [
        Order { subtotal: 120, grams: 400 },
        Order { subtotal: 499, grams: 2000 },
        Order { subtotal: 500, grams: 2000 },
        Order { subtotal: 1250, grams: 7500 },
        Order { subtotal: 60, grams: 12000 },
        Order { subtotal: 999, grams: 0 },
    ];
    let flat = |_: &Order| 40;
    let weight = |o: &Order| 20 + 12 * o.grams.div_ceil(1000);
    let free_above = |o: &Order| if o.subtotal >= 500 { 0 } else { 60 };

    // Static dispatch: three closure types, so three copies of total_generic.
    println!("generic: {} {} {}", total_generic(&orders, flat), total_generic(&orders, weight), total_generic(&orders, free_above));
    // Dynamic dispatch: one copy of total_dyn; the closures can also live in one Vec, chosen by name at run time.
    let by_name: Vec<(&str, Box<dyn Fn(&Order) -> u32>)> = vec![("flat", Box::new(flat)), ("weight", Box::new(weight)), ("free-above", Box::new(free_above))];
    let totals: Vec<String> = by_name.iter().map(|(_, rule)| total_dyn(&orders, rule.as_ref()).to_string()).collect();
    println!("dyn:     {}", totals.join(" "));
}

C++ · langs/dispatch.cpp

// One strategy, two kinds of dispatch in C++. total_template is instantiated once for every lambda type it is called
// with; total_function takes std::function, compiled once, and calls whatever callable it holds at run time.
#include <functional>
#include <print>
#include <vector>

struct Order {
    int subtotal;
    int grams;
};

template <typename Rule>
[[gnu::noinline]] int total_template(const std::vector<Order>& orders, Rule rule) {
    int sum = 0;
    for (const auto& o : orders) sum += rule(o);
    return sum;
}

[[gnu::noinline]] int total_function(const std::vector<Order>& orders, const std::function<int(const Order&)>& rule) {
    int sum = 0;
    for (const auto& o : orders) sum += rule(o);
    return sum;
}

int main() {
    std::vector<Order> orders = {{120, 400}, {499, 2000}, {500, 2000}, {1250, 7500}, {60, 12000}, {999, 0}};
    auto flat = [](const Order&) { return 40; };
    auto weight = [](const Order& o) { return 20 + 12 * ((o.grams + 999) / 1000); };
    auto free_above = [](const Order& o) { return o.subtotal >= 500 ? 0 : 60; };

    std::println("template: {} {} {}", total_template(orders, flat), total_template(orders, weight), total_template(orders, free_above));
    std::vector<std::function<int(const Order&)>> chosen = {flat, weight, free_above};
    std::print("function:");
    for (const auto& rule : chosen) std::print(" {}", total_function(orders, rule));
    std::println("");
}

Rust: mixed closures · langs/mixed.rs

// Two rules that capture their settings, in one Vec without a Box. Every closure has a type of its own, so this
// does not compile. (Closures that capture nothing would compile here: they turn into plain function pointers.)
fn main() {
    let amount = 40;
    let per_kg = 12;
    let flat = move |_grams: u32| amount;
    let weight = move |grams: u32| 20 + per_kg * grams.div_ceil(1000);
    let rules = vec![flat, weight];
    println!("{}", rules.len());
}
Both kinds of dispatch, and the copies they compile to Python · langs/dispatch_session.py
from session import run, versions

# Built without optimisation, so every function the source defines stays in the program. nm lists a program's
# symbols (-C turns them back into source names, -j prints only the names), so the compiled copies can be counted.
versions("rustc --version", "clang++ --version")
run("""
rustc --edition 2024 -C opt-level=0 dispatch.rs -o dispatch_rs && ./dispatch_rs
nm -C -j dispatch_rs | grep -E '^dispatch::total_(generic::<[^>]*>|dyn)$' | sort
clang++ -std=c++23 -O0 dispatch.cpp -o dispatch_cpp && ./dispatch_cpp
nm -C -j dispatch_cpp | grep -E '^(int )?total_(template|function)' | sed 's/(.*//' | sort
rustc --edition 2024 mixed.rs -o mixed
""", expect_failure=("rustc --edition 2024 mixed.rs -o mixed",), keep=r"^error|= note: no two closures|= help: consider boxing")

Output

# rustc 1.99.0
# Apple clang version 21.0.0 (clang-2100.3.34.2)
$ rustc --edition 2024 -C opt-level=0 dispatch.rs -o dispatch_rs && ./dispatch_rs
generic: 240 420 180
dyn:     240 420 180
$ nm -C -j dispatch_rs | grep -E '^dispatch::total_(generic::<[^>]*>|dyn)$' | sort
dispatch::total_dyn
dispatch::total_generic::<dispatch::main::{closure#0}>
dispatch::total_generic::<dispatch::main::{closure#1}>
dispatch::total_generic::<dispatch::main::{closure#2}>
$ clang++ -std=c++23 -O0 dispatch.cpp -o dispatch_cpp && ./dispatch_cpp
template: 240 420 180
function: 240 420 180
$ nm -C -j dispatch_cpp | grep -E '^(int )?total_(template|function)' | sed 's/(.*//' | sort
int total_template<main::$_0>
int total_template<main::$_1>
int total_template<main::$_2>
total_function
$ rustc --edition 2024 mixed.rs -o mixed
error[E0308]: mismatched types
  = note: no two closures, even if identical, have the same type
  = help: consider boxing your closure and/or using it as a trait object
error: aborting due to 1 previous error
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 dispatch_session.py

Three rules called through the generic function produced three copies of it, one per closure type, in both languages; the dynamic version produced one. Static dispatch lets the compiler inline each rule into its copy of the loop, and the Rust book notes that dynamic dispatch prevents that inlining. Which is faster for your program depends on the call site, the optimiser and the work each rule does, so measure it with your own data before choosing for speed; this lesson makes no speed claim.

The last command shows the constraint that usually decides. Every closure in Rust has a type of its own, even two closures with identical code, so two rules that capture their settings cannot share a Vec until they are boxed as dyn Fn. (Closures that capture nothing would compile: they become plain function pointers.) A registry that picks a rule by name at run time therefore needs dynamic dispatch, while a rule fixed in the code, such as the comparison of a sort written once, is a natural generic parameter.

Choosing a strategy from configuration

Most strategies are chosen from data: a settings file, a database row, a request parameter, a feature flag. The registry keeps that choice in one place, and three habits keep it safe:

  1. Resolve names once, at the edge. Read the settings, look up the strategy and hand the object to the context at start-up or at the top of a request. Code deeper down receives a strategy, never a name.
  2. Fail fast on an unknown name. Raise with the list of known names, as make_rule does, rather than falling back to a default that hides the mistake.
  3. Register, do not edit. A new strategy adds an entry; the code that picks and the code that calls stay as they are.

Feature flags are the same problem with a deadline. Pete Hodgson’s article “Feature Toggles” accepts an if around a toggle that will be deleted within days, but advises against if-statement toggle points for flags that live long or appear in many places; the alternative it suggests is to “implement alternative codepaths using some sort of Strategy pattern”, decided once by the code that builds the object. Removing the flag later then deletes one registry entry instead of a scattering of if statements.

Fees by payment method

Indian checkouts usually let the customer pick UPI, a card, net banking or a wallet at the moment of payment, so the fee strategy is chosen per payment rather than per shop. The four methods are not four algorithms: in the example below, UPI and net banking use the same flat-fee strategy (with 0 and 12) and card and wallet use the same percentage strategy, the wallet with a minimum of 5. The fee rules are invented for the example and are not any provider’s real rates.

Fees by payment method, chosen for each payment Python · fees/fees.py
# Convenience fees by payment method, picked for each payment when the customer chooses how to pay. The fee rules
# are invented for this example: they are not any provider's real rates.
from math import ceil


def flat(amount):
    return lambda paid: amount


def percent(tenths_of_a_percent, minimum=0):
    """A share of the amount, rounded up to a whole rupee, never below the minimum."""
    return lambda paid: max(minimum, ceil(paid * tenths_of_a_percent / 1000))


# Four payment methods, but only two algorithms: the methods are data, the algorithms are the strategies.
FEES = {
    "UPI": flat(0),
    "card": percent(18),  # 1.8 %
    "net banking": flat(12),
    "wallet": percent(10, minimum=5),  # 1 %, at least 5
}


def fee(method, paid):
    return FEES[method](paid)


print("amount   " + "  ".join(f"{m:>11}" for m in FEES))
for paid in (150, 2000, 40000):
    print(f"{paid:>6}   " + "  ".join(f"{fee(m, paid):>11}" for m in FEES))

Output

amount           UPI         card  net banking       wallet
   150             0            3           12            5
  2000             0           36           12           20
 40000             0          720           12          400

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fees.py

When strategies differ only in their numbers, the numbers are data: one strategy per algorithm, with a row of settings per variant, keeps a new variant from needing new code. That table is also something a finance team can review without reading Python.

Strategy, State or Template Method?

Three patterns let one object behave in several ways, and they are easy to confuse in a design review. What tells them apart is who chooses the behaviour and when it changes:

Strategy, State and Template Method
Criterion StrategyStateTemplate Method
Who chooses The clientThe states, as events arriveThe subclass
When it changes When the client swaps itOn each event that moves itNever, once created
Plugged in by CompositionCompositionInheritance
Typical use Pricing, sorting, routingOrders, connections, gamesFrameworks with fixed steps

Strategy also sits next to Command, the next lesson. A strategy is a way of doing a job (how to price this order); a command is a job to be done (place this order), packed up so it can be queued, logged or undone.

When not to use it

  • Two variants that will not grow. An if with two branches that are stable is shorter and clearer than an interface, two classes and a registry.
  • One function will do. In Python, TypeScript, Go and most modern languages, pass the function. An interface with a single method and one implementation per call site is ceremony.
  • The variants differ only in numbers. Keep a table of settings and one strategy, as in the fee example; a class per customer or per country turns data into code.
  • The strategy needs the context’s private details. If a rule has to reach back into the checkout for half its fields, the boundary is in the wrong place: pass what the rule needs, or reconsider what the context owns.

Practise: refactor a discount calculator

Exercise · Easy · Python

Refactor a discount calculator into strategies

discounts.py works out a cart's discount from a code with one if/elif chain of six branches. Every new code means editing that function, and a mistyped code silently gives no discount. Refactor it into strategies kept in a registry, without changing a single result.

  • DISCOUNTS is a dict from a code to its rule: a function that takes a cart and returns the discount.
  • register(code) is a decorator that adds the function below it to DISCOUNTS under code. Register the six existing codes with it.
  • make_discount(code) returns the rule for code, and raises ValueError at once for a code that is not registered, so a typo is caught where the code is read, not at the first order.
  • discount(cart, code) keeps its name and arguments, looks the rule up with make_discount and applies it. No if, elif or match may be left in it: the registry does the choosing.

The sample tests check the six codes on six carts, register a seventh code from the test file without touching discounts.py, ask for an unknown code, and read the body of discount() to check that no branch is left.

Starter code · discounts.py

"""Discount codes for a shopping cart.

A cart is a dict: {"items": [prices of the items, whole currency units], "first_order": True or False}.
discount(cart, code) returns how much the code takes off the cart's total.
"""


def discount(cart, code):
    total = sum(cart["items"])
    if code == "NONE":
        return 0
    elif code == "FLAT100":  # 100 off an order of 1,000 or more
        return 100 if total >= 1000 else 0
    elif code == "TEN":  # 10 % off, at most 300
        return min(total // 10, 300)
    elif code == "CHEAPEST":  # the cheapest item free, from two items up
        return min(cart["items"]) if len(cart["items"]) >= 2 else 0
    elif code == "BULK":  # 5 % off from ten items up
        return total * 5 // 100 if len(cart["items"]) >= 10 else 0
    elif code == "WELCOME":  # 15 % off a first order, at most 200
        return min(total * 15 // 100, 200) if cart["first_order"] else 0
    else:
        return 0


# Your job: keep every result the same, but move each code's rule into a strategy of its own, kept in a registry.
#   DISCOUNTS: a dict from code to rule (a function that takes a cart and returns the discount)
#   register(code): a decorator that adds a rule to DISCOUNTS under that code
#   make_discount(code): the rule for a code; an unknown code raises ValueError at once
#   discount(cart, code): looks the rule up with make_discount and applies it, with no if or elif left in it
The sample tests · test_discounts.py
import ast
import inspect

import discounts
from discounts import discount

CARTS = [
    ({"items": [400, 600], "first_order": False}, {"NONE": 0, "FLAT100": 100, "TEN": 100, "CHEAPEST": 400, "BULK": 0, "WELCOME": 0}),
    ({"items": [999], "first_order": True}, {"NONE": 0, "FLAT100": 0, "TEN": 99, "CHEAPEST": 0, "BULK": 0, "WELCOME": 149}),
    ({"items": [2500, 1500, 99], "first_order": True}, {"NONE": 0, "FLAT100": 100, "TEN": 300, "CHEAPEST": 99, "BULK": 0, "WELCOME": 200}),
    ({"items": [50] * 10, "first_order": False}, {"NONE": 0, "FLAT100": 0, "TEN": 50, "CHEAPEST": 50, "BULK": 25, "WELCOME": 0}),
    ({"items": [120] * 12, "first_order": True}, {"NONE": 0, "FLAT100": 100, "TEN": 144, "CHEAPEST": 120, "BULK": 72, "WELCOME": 200}),
    ({"items": [100, 900], "first_order": False}, {"NONE": 0, "FLAT100": 100, "TEN": 100, "CHEAPEST": 100, "BULK": 0, "WELCOME": 0}),
]


def test_every_code_gives_the_same_discounts():
    """keeps the discount of each of the six codes on six carts"""
    for cart, expected in CARTS:
        for code, amount in expected.items():
            assert discount(cart, code) == amount, f"{code} on {cart}"


def test_a_seventh_code_joins_by_registration():
    """adds a STUDENT code from this test file, without editing discounts.py"""

    @discounts.register("STUDENT")
    def student(cart):
        return 50 if sum(cart["items"]) >= 500 else 0

    assert discount({"items": [500], "first_order": False}, "STUDENT") == 50
    assert discount({"items": [499], "first_order": False}, "STUDENT") == 0


def test_an_unknown_code_fails_fast():
    """raises ValueError for a code that is not registered, instead of giving no discount"""
    try:
        discounts.make_discount("TEN_PERCENT")
    except ValueError:
        return
    raise AssertionError("make_discount('TEN_PERCENT') returned instead of raising ValueError")


def test_discount_has_no_branches_left():
    """leaves no if or elif in discount(): the registry chooses the rule"""
    tree = ast.parse(inspect.getsource(discounts.discount))
    branches = [node for node in ast.walk(tree) if isinstance(node, (ast.If, ast.IfExp, ast.Match))]
    assert branches == [], "discount() still chooses the rule with if, elif or match"
A hint

Start with the decorator, because everything else uses it: register(code) returns a function add(rule) that stores rule in DISCOUNTS[code] and returns rule unchanged. Then turn each branch into a small function with the @register("…") line above it, copying the branch's expression into its return. make_discount is a dictionary lookup that turns the KeyError into a ValueError.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Check yourself

Check yourself

5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.

  1. Question 1 of 5 What does this print, and what does it say about the registry?

    Read the code, then choose one answer.

    RULES = {"flat": lambda order: 40}
    
    def make_rule(name):
        return RULES.get(name, lambda order: 0)
    
    print(make_rule("Flat")(None))
    Show the answer to question 1

    Answer: 0: the default rule hides the misspelt name, so the order ships at no charge

    Dictionary keys are case-sensitive, so "Flat" is not found, and get() returns the default rule, which charges 0. This is the bug of the if/elif version in a new place. A registry should raise for an unknown name, with the list of names it knows, so that the mistake stops the program at start-up.

  2. Question 2 of 5 A pricing object prices one order, then replaces itself with a different pricing object depending on how many orders the customer has placed. Which pattern is this closer to?

    Choose one answer.

    Show the answer to question 2

    Answer: State, because the object changes its own behaviour as events happen

    In Strategy the client chooses the algorithm and it stays until the client changes it. An object that moves itself to another behaviour in response to events is a state machine: that is the State pattern, which has a lesson of its own in the next module.

  3. Question 3 of 5 In Rust, vec![flat, weight] fails to compile when both closures capture a setting. Why, and what is the usual fix?

    Choose one answer.

    Show the answer to question 3

    Answer: Every closure has a type of its own, so the Vec needs a common type such as Box<dyn Fn(u32) -> u32>

    No two closures have the same type, even with identical code. Boxing them as trait objects gives them one type, Box<dyn Fn(u32) -> u32>, with dynamic dispatch. Closures that capture nothing are the exception: they can be turned into plain function pointers, which do share a type.

  4. Question 4 of 5 Which of these pass a strategy to the code that uses it?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • sorted(words, key=str.casefold) in Python
    • list.sort(Comparator.comparing(Person::name)) in Java
    • slices.SortFunc(people, byAge) in Go

    The first three hand a sort an algorithm for one of its steps (computing a key, comparing two items), and the sort calls it without knowing which one it is: the Strategy pattern, written as a function. A loop and a constant choose nothing.

  5. Question 5 of 5 Shipping for 40 countries uses the same formula, a flat amount plus a rate per kilogram, with different numbers for each country. What is the better design?

    Choose one answer.

    Show the answer to question 5

    Answer: One strategy with a table of settings, one row per country

    The countries differ in data, not in algorithm. One strategy plus a table of settings keeps a new country to one new row, which someone can review without reading code. Separate strategies pay off when the algorithm itself differs.

Interview questions

Fresher to mid-level · What problem does the Strategy pattern solve, and when is passing a plain function enough?

Strategy separates the choice of an algorithm from the code that uses it. The variants sit behind one interface, the context holds one and calls it, and a new variant is a new implementation instead of another branch in a conditional that every caller goes through. That keeps each variant small and testable on its own, and lets the choice come from configuration in one place. In languages with first-class functions, a function is enough when the variant is one operation without lifecycle or validation: sorted(key=...) and Comparator lambdas are strategies of that kind. Use an object when a variant has several related operations, validates its settings, or holds a resource that must be opened and closed.

Key takeaways

  • Strategy puts each way of doing one job behind a shared interface; the context calls whichever strategy it holds.
  • The choice moves to one place, usually start-up code reading the settings, and unknown names fail there, loudly.
  • In Python, TypeScript, Java, Go and C++ a strategy is most often a function or a lambda; a class pays off when the strategy has several operations, settings to check or a resource to manage.
  • Rust and C++ add a choice: generics or templates (one compiled copy per strategy, picked at compile time) or dyn Fn and std::function (one copy, picked at run time, required for a registry of mixed closures).
  • Strategies that differ only in numbers are one strategy with a table of settings.
  • The client chooses a strategy; a state object replaces itself as events happen.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.