Strategy
Rider assignment began as one rule: pick the nearest. When it rained, nearest riders
turned up late and soaked, so a mode parameter was added: 'nearest' or 'topRated'.
At lunch peak, the nearest rider was already carrying three orders, so 'leastBusy'
joined it. The function now had three branches, 80 lines, and a comment asking people not
to add a fourth.
Then the growth team wanted to try a fourth on 5% of orders, as an experiment, without a deploy each time they tuned it.
You have three ways to pick a rider (nearest, top rated, least busy), you need to switch between them while the app is running, and more are coming. How would you structure the code?
The idea
You type a destination. Then you tap car, bike or walk, and the route changes. The map, the search box and the ETA label are all the same. Only the piece that works out the route was swapped, and it was swapped while the app was running.
Three roles. The strategy is the interface for the swappable algorithm. A concrete strategy is one algorithm. The context is the object that holds a strategy and uses it, without knowing which one it has.
The classic form
type Rider = {
name: string
distanceKm: number
rating: number
activeOrders: number
}
interface AssignmentStrategy {
pick(riders: Rider[]): Rider
}
class Nearest implements AssignmentStrategy {
pick(riders: Rider[]): Rider {
return riders.reduce((best, rider) =>
rider.distanceKm < best.distanceKm ? rider : best,
)
}
}
class TopRated implements AssignmentStrategy {
pick(riders: Rider[]): Rider {
return riders.reduce((best, rider) =>
rider.rating > best.rating ? rider : best,
)
}
}
class LeastBusy implements AssignmentStrategy {
pick(riders: Rider[]): Rider {
return riders.reduce((best, rider) =>
rider.activeOrders < best.activeOrders ? rider : best,
)
}
}
class Dispatcher {
constructor(private strategy: AssignmentStrategy) {}
setStrategy(strategy: AssignmentStrategy): void {
this.strategy = strategy
}
assign(riders: Rider[]): string {
return `Assigned to ${this.strategy.pick(riders).name}`
}
}
const riders: Rider[] = [
{ name: 'Asha', distanceKm: 1.2, rating: 4.4, activeOrders: 2 },
{ name: 'Ravi', distanceKm: 2.5, rating: 4.9, activeOrders: 1 },
{ name: 'Meena', distanceKm: 3.1, rating: 4.7, activeOrders: 0 },
]
const dispatcher = new Dispatcher(new Nearest())
console.log(dispatcher.assign(riders)) // Assigned to Asha
dispatcher.setStrategy(new TopRated())
console.log(dispatcher.assign(riders)) // Assigned to Ravi
dispatcher.setStrategy(new LeastBusy())
console.log(dispatcher.assign(riders)) // Assigned to MeenaDispatcher is the context. Its assign method has no if and no mode. It calls
this.strategy.pick and uses the answer. The same dispatcher object produced three
different assignments because the strategy inside it was replaced between calls.
The growth team’s experiment is now a fourth class and one line that decides when to plug
it in. Dispatcher, Nearest, TopRated and LeastBusy are not opened. That is the
open closed principle, and Strategy is the most common
way of getting it.
The TypeScript form: a strategy is a function
Each strategy above is a class with one method and no fields. In TypeScript that is a function, and you can say so in the type.
type Rider = {
name: string
distanceKm: number
rating: number
activeOrders: number
}
type AssignmentStrategy = (riders: Rider[]) => Rider
const nearest: AssignmentStrategy = (riders) =>
riders.reduce((best, rider) =>
rider.distanceKm < best.distanceKm ? rider : best,
)
const topRated: AssignmentStrategy = (riders) =>
riders.reduce((best, rider) =>
rider.rating > best.rating ? rider : best,
)
type StrategyName = 'nearest' | 'topRated'
const strategies: Record<StrategyName, AssignmentStrategy> = {
nearest,
topRated,
}
function assign(riders: Rider[], pick: AssignmentStrategy): string {
return `Assigned to ${pick(riders).name}`
}
const riders: Rider[] = [
{ name: 'Asha', distanceKm: 1.2, rating: 4.4, activeOrders: 2 },
{ name: 'Ravi', distanceKm: 2.5, rating: 4.9, activeOrders: 1 },
]
const isRaining = true
const pick = isRaining ? strategies.topRated : strategies.nearest
console.log(assign(riders, pick)) // Assigned to RaviSame pattern, a third of the code. The strategies table lets you choose one by name,
from a config value or an experiment flag, which is what the growth team asked for.
You have been using this pattern for as long as you have written JavaScript:
const prices = [120, 40, 60]
const ascending = [...prices].sort((a, b) => a - b)
const descending = [...prices].sort((a, b) => b - a)
console.log(ascending) // [ 40, 60, 120 ]
console.log(descending) // [ 120, 60, 40 ]sort is the context. The comparison function you pass in is the strategy. The sorting
algorithm never changes. You plug in the one decision that varies.
| Choice | What you gain | What you pay | Pick it when |
|---|---|---|---|
| Strategy as a function | Minimal code. Any function with the right signature works, including an inline arrow. | No place for configuration or dependencies, other than closing over them. | The algorithm is one operation with no state. This is most cases. |
| Strategy as a class | Can hold configuration, take dependencies through its constructor, and offer several related methods. | More code, and one class per algorithm. | The algorithm needs its own data, like a surge multiplier, or a service to call, or more than one method. |
Mistakes people make
If TopRated.pick starts reading fields off the Dispatcher, or the dispatcher checks
if (this.strategy instanceof LeastBusy), the two are tied together again and the
swap is no longer clean. Pass the strategy what it needs as arguments. The context
should treat every strategy the same way.
If Dispatcher contains this.strategy = isRaining ? new TopRated() : new Nearest(),
the if ladder you removed has come back in a different method. The choice belongs
outside: in a factory, in config, or in the code
that creates the dispatcher.
The class diagrams are identical: a context holding an object behind an interface. The difference is who swaps it and why. With Strategy, the caller picks the algorithm, and strategies do not know about each other. With State, the object changes its own state as things happen to it, and each state knows which one comes next. Strategy is “how should I do this?”. State is “what am I right now?”.
interface AssignmentStrategy { pick(riders: Rider[]): Rider }constructor(private strategy: AssignmentStrategy) {}type AssignmentStrategy = (riders: Rider[]) => Riderriders.reduce((best, rider) => ...)Record<StrategyName, AssignmentStrategy>{ nearest, topRated }[...prices].sort((a, b) => a - b)Checkpoint
1. In the Strategy pattern, what does the context know about the algorithm it is using?
2. Each of your strategies is a class with one method and no fields. What is the idiomatic TypeScript simplification?
3. What is the key difference between Strategy and State, given that their structure looks the same?
Strategy is for when there are several ways to do one job and I need to switch between them at runtime. I define an interface for the algorithm, put each version in its own implementation, and have a context object hold one and call it without knowing which it is. Adding a new algorithm is a new class or function, and nothing existing changes, so it is the usual way to satisfy open closed. In TypeScript a strategy with one method and no state is a function type, and passing a comparator to sort is the pattern in everyday use. The choice of which strategy to use stays outside the context, in config or a factory. It differs from State in who does the swapping: with Strategy the caller chooses, with State the object changes itself.
