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.
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.
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().
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());
}
}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:
- a new constant in
Plan; - a new branch in
fareCents(); - a new branch in
planName(), which must stay in step with the first.
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.
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.
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); }
}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
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.
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 |
| The interface every way of doing the job implements |
Concrete strategy |
| One way of doing the job each |
Context |
| 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.
Rideno 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
ifis 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:
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
Rideasks "is this aStudentPlan?", 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 wholeRide, until a strategy truly needs more. Passing the context ties every strategy to it. - Share strategies that hold no data.
PayAsYouGohas 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
Ridecallednew 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 |
JavaScript | No interfaces: any object with the right methods is a strategy (duck typing), documented in a comment. Often just a function |
Python | A |
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
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.