Low-level designCore9 min

Strategy

When one job can be done several ways, give each way its own class behind one interface, and let the object that needs the job hold whichever one applies.

Watch it evolve1 / 9
The problem

PedalCity prices every ride by its plan

Ride stores the rider's plan, and switches on it to price the ride and to name the plan.

80
Start
Stage 1
Stage 2
Final

1 · The situation

A ride's fare depends on the rider's plan

PedalCity runs a bike share. Every ride ends with a receipt, and the fare depends on the rider's plan:

  • Pay as you go: $1.00 to unlock, then 25¢ a minute.
  • Day pass: the first 30 minutes are covered, then 20¢ a minute.
  • Member: the first 45 minutes are free, then 15¢ a minute.

The first version is what most of us would write on day one: an enum Plan for the plan, and a Ride that switches on it, once to price the ride in fareCents() and once to name the plan on the receipt in planName(). The names are the same in all four languages, except that Python writes methods in snake case: fare_cents().

The first versionJava
enum Plan { PAY_AS_YOU_GO, DAY_PASS, MEMBER }

class Ride {
    private final Plan plan;
    private final int minutes;

    Ride(Plan plan, int minutes) {
        this.plan = plan;
        this.minutes = minutes;
    }

    int fareCents() {
        switch (plan) {
            case PAY_AS_YOU_GO: return 100 + 25 * minutes;
            case DAY_PASS:      return 20 * Math.max(0, minutes - 30);
            case MEMBER:        return 15 * Math.max(0, minutes - 45);
            default: throw new IllegalStateException("unknown plan " + plan);
        }
    }

    String planName() {
        switch (plan) {
            case PAY_AS_YOU_GO: return "Pay as you go";
            case DAY_PASS:      return "Day pass";
            case MEMBER:        return "Member";
            default: throw new IllegalStateException("unknown plan " + plan);
        }
    }

    String receipt() {
        int c = fareCents();
        return String.format("%s, %d min: $%d.%02d", planName(), minutes, c / 100, c % 100);
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println(new Ride(Plan.PAY_AS_YOU_GO, 20).receipt());
        System.out.println(new Ride(Plan.DAY_PASS, 50).receipt());
        System.out.println(new Ride(Plan.MEMBER, 60).receipt());
    }
}
«enum»PlanPAY_AS_YOU_GODAY_PASSMEMBERRide− plan: Plan− minutes: int~ fareCents(): int~ planName(): String~ receipt(): String

The class diagram of this version, drawn from its code: Ride holds a Plan and decides everything else itself.

It works. Three rides print three receipts:

Pay as you go, 20 min: $6.00
Day pass, 50 min: $4.00
Member, 60 min: $2.25

2 · The change

One new plan means editing Ride in three places

The city council asks for a student plan: the first hour free, then 10¢ a minute, never more than $3.00 a ride. In the current design that costs three edits:

  1. a new constant in Plan;
  2. a new branch in fareCents();
  3. a new branch in planName(), which must stay in step with the first.
Where the member plan lives today, and where the student plan would goJava
class Ride {
    private final Plan plan;
    private final int minutes;

    Ride(Plan plan, int minutes) {
        this.plan = plan;
        this.minutes = minutes;
    }

    int fareCents() {
        switch (plan) {
            case PAY_AS_YOU_GO: return 100 + 25 * minutes;
            case DAY_PASS:      return 20 * Math.max(0, minutes - 30);
            case MEMBER:        return 15 * Math.max(0, minutes - 45);
            default: throw new IllegalStateException("unknown plan " + plan);
        }
    }

    String planName() {
        switch (plan) {
            case PAY_AS_YOU_GO: return "Pay as you go";
            case DAY_PASS:      return "Day pass";
            case MEMBER:        return "Member";
            default: throw new IllegalStateException("unknown plan " + plan);
        }
    }

    String receipt() {
        int c = fareCents();
        return String.format("%s, %d min: $%d.%02d", planName(), minutes, c / 100, c % 100);
    }
}

Each plan's rule is spread across Ride, so a change to any plan edits the class every receipt depends on, and every test of a fare has to build a ride. The two switches grow together: with n plans, Ride carries 2 × n branches.

3 plans (today)
6
4 plans (with the student plan)
8
6 plans
12

Branches inside Ride: one per plan in each of its two switches.

This has a name: a switch on a type code, the same switch over the same enum repeated in several methods. The practice checks flag it on this version: Plan is switched on in 2 places.

3 · The refactor

Move each plan's rule into its own class

What changes from plan to plan is how a fare is computed and what the plan is called. What stays the same is a ride asking for its fare. Separate the two in three small steps, running the program after each one:

Declare the job once, as an interface FarePolicy with name() and fareCents(minutes). Move each case into a class of its own: PayAsYouGo, DayPass and Membership. Each class holds one plan's rule and nothing else.

Java
interface FarePolicy {
    String name();
    int fareCents(int minutes);
}

class PayAsYouGo implements FarePolicy {
    public String name() { return "Pay as you go"; }
    public int fareCents(int minutes) { return 100 + 25 * minutes; }
}

class DayPass implements FarePolicy {
    public String name() { return "Day pass"; }
    public int fareCents(int minutes) { return 20 * Math.max(0, minutes - 30); }
}

class Membership implements FarePolicy {
    public String name() { return "Member"; }
    public int fareCents(int minutes) { return 15 * Math.max(0, minutes - 45); }
}
«interface»FarePolicy+ name(): String+ fareCents(int): intPayAsYouGo+ name(): String+ fareCents(int): intDayPass+ name(): String+ fareCents(int): intMembership+ name(): String+ fareCents(int): intRide− policy: FarePolicy− minutes: int~ fareCents(): int~ receipt(): String

After the first two steps the program prints exactly the three receipts it printed before: a refactor changes the structure, not the behavior. The third step adds the student ride:

Pay as you go, 20 min: $6.00
Day pass, 50 min: $4.00
Member, 60 min: $2.25
Student, 100 min: $3.00
Before the refactor
3
After the refactor
0

Places in existing code edited to add one plan. Before: Plan, fareCents() and planName(). After: none; one class is added.

4 · The pattern

Strategy: interchangeable ways to do one job

Strategy gives each way of doing a job its own class, all behind one interface. The object that needs the job holds one of them and calls it through that interface, so it can be handed a different one without being changed itself.

«interface»FarePolicy+ name(): String+ fareCents(int): intPayAsYouGo+ name(): String+ fareCents(int): intDayPass+ name(): String+ fareCents(int): intMembership+ name(): String+ fareCents(int): intStudentPlan+ name(): String+ fareCents(int): intRide− policy: FarePolicy− minutes: int~ fareCents(): int~ receipt(): String

The four roles in PedalCity's code: Ride is the context, FarePolicy the strategy, and each plan a concrete strategy.

Role

In PedalCity

Its job

Strategy

FarePolicy

The interface every way of doing the job implements

Concrete strategy

PayAsYouGo, DayPass, Membership, StudentPlan

One way of doing the job each

Context

Ride

Holds a strategy and calls it, without asking which one it is

Client

The code that starts a ride

Picks the strategy and hands it to the context

One receipt: the client chooses the policy once, and Ride calls it through the interface.

You have used Strategy already. Each of the four languages lets you pass the sort order to its sort function: a Comparator to Collections.sort in Java, a comparison to std::sort in C++, a compareFn to Array.prototype.sort in JavaScript, a key function to sorted in Python. The sort is the context, and the order is the strategy you hand it.

5 · What it costs

One class per way, and someone has to choose

  • More types. Four plans are four classes and an interface, where there was one enum.
  • The choice moves out. Ride no longer decides. The client must know the plans and pick one, usually in one place, such as a lookup from the rider's account.
  • One more hop when reading. The fare is one call through an interface away from the ride. The run-time cost is negligible in almost every program; the reading cost is real but small.
  • Not worth it for two cases that never change. If there will only ever be two plans, a plain if is simpler and fine.

When a function is enough. If a strategy has one operation and no data of its own, a function does the same job with less ceremony. Every language here can store and pass one. If Ride only needed a fare, it could hold a fare function:

A strategy as a functionJava
import java.util.function.IntUnaryOperator;

class Ride {
    private final IntUnaryOperator fare;
    private final int minutes;

    Ride(IntUnaryOperator fare, int minutes) {
        this.fare = fare;
        this.minutes = minutes;
    }

    int fareCents() { return fare.applyAsInt(minutes); }
}

public class Main {
    public static void main(String[] args) {
        IntUnaryOperator student = minutes -> Math.min(300, 10 * Math.max(0, minutes - 60));
        System.out.println(new Ride(student, 100).fareCents());
    }
}

Reach for classes when a strategy has several operations that belong together (here name() and fareCents()), data of its own, or a name in the design that people will talk about.

6 · Getting it right

Keep the context unaware of which strategy it holds

  • Don't test the strategy's type. If Ride asks "is this a StudentPlan?", the old switch is back in a new form.
  • Give the strategy what it needs, and no more. Pass the values (minutes) rather than the whole Ride, until a strategy truly needs more. Passing the context ties every strategy to it.
  • Share strategies that hold no data. PayAsYouGo has no fields, so one instance can serve every ride.

Where the strategy comes from matters too:

  • Choose in one place. Map the rider's plan to a policy at the edge of the system, where the ride is created, so the mapping isn't repeated.
  • Let the context be given its strategy. If Ride called new PayAsYouGo() itself, it couldn't be given another; the checks flag that.

Language

The usual shape

Java

An interface and one class per strategy; a lambda when the interface has a single method

C++

An abstract class with pure virtual functions, owned through std::unique_ptr; std::function or a template parameter for a single operation

JavaScript

No interfaces: any object with the right methods is a strategy (duck typing), documented in a comment. Often just a function

Python

A typing.Protocol documents the contract without inheritance; type checkers enforce it, not the runtime. A plain function for one operation

7 · Look-alikes

Same shape, different reason

Several patterns put behavior behind an interface and hold it in a field. What tells them apart is what varies, and who decides.

Pattern

What varies

Who picks it

Use it when

Strategy

How one job is done

The client, from outside

There are several interchangeable ways to do the same thing

State

Behavior as the object's state changes

The object itself, or its states, as it moves between states

The same call should act differently in each state

Template Method

Some steps of a fixed algorithm

A subclass, by overriding

The order of the steps is fixed and only some steps vary

Command

Which action runs, and when

Whoever queues or triggers it

Actions must be stored, undone or run later

Strategy and State are the pair most often confused. Concrete strategies don't know about each other, and a ride's policy never changes itself. States usually know their neighbors and switch the object from one state to the next.

8 · In practice

Pricing a stay at Hilltop Garage

The parking-lot practice problem prices each stay, and the rates change on match days. Its checks recognize a Strategy when an interface has implementations, a class holds one of them in a field, is given it from outside, and calls it without testing which one it is. A lot that creates its own pricing with new is flagged, because it can't be given another.

Design the parking lot, in code, with the checks reading your classes.

Check yourself

4 questions

1. PedalCity adds a weekend plan after the refactor. Which existing classes change?
2. A Ride checks if (policy instanceof StudentPlan) to print a student discount line. What has gone wrong?
3. A turnstile acts differently when locked and unlocked. A coin unlocks it, and walking through locks it again, so the turnstile changes its own mode. Which pattern fits?
4. Your strategy has one method and no fields. In Python, what's the idiomatic choice?

Takeaways

Remember this

  • Strategy separates how a job is done from who needs it done: one interface, one class per way.
  • A new way is a new class; the context and the other strategies don't change.
  • The client chooses the strategy and hands it in; the context never asks which one it holds.
  • The usual signal is the same switch on a type code repeated in several methods.
  • One operation and no data? Pass a function instead.