Swap Behaviour: Strategy and Decorator
Replace an if/else ladder with an object you can swap, and stack extra rules around it without editing the original.
- 1 methodSize of the strategy interface: fee(stay)
- 80 vs 50Same night stay, opposite wrapping order
- 3rd caseCue to extract; two cases stay an if
Show what to say
“When a rule keeps changing, like how a stay becomes a price, I put it behind a one-method interface: Strategy. Optional adjustments such as a grace period, a cap or a discount are Decorators, wrappers with the same interface that stack. The wrapping order is part of the rule, so I pin it with a test.”
Trade-offs
- Strategy vs Decorator: a strategy is an alternative chosen from outside; a decorator adjusts another strategy by wrapping it, and they stack.
- Class vs plain function: a function is the shortest strategy; use a class for inspectable configuration, several methods, state or a name for logs.
- Cap(NightDiscount) vs NightDiscount(Cap): 80 against 50 on an 8-hour night stay; neither is a bug, they are different policies.
Bring it up before they ask
Say out loud that wrapping order changes the answer, put the grace period outermost, and test both orders of any two rules that interact.
Put the rule that keeps changing behind a one-method interface (Strategy). Add optional rules like caps and discounts as wrappers with the same interface (Decorator). Wrapping order is part of the rule, so test it.
The ladder that grows
On Monday the garage charges by the hour. By Friday bikes are cheaper, nights are half price, the first ten minutes are free, and finance wants a cap. Each request is one more if.
It works, and it hurts three ways:
- Every rule edits one function. A new discount can break the cap.
- The order is hidden. Discount runs before cap only because that
ifis higher up. - You cannot swap it. An event-day flat price needs a copy of the whole function.
// Every new rule is one more `if`, and the order of the ifs is the order of the rules, hidden.
export function parkingFee({ minutes, enterHour, vehicle }) {
const hours = Math.ceil(minutes / 60);
let fee;
if (vehicle === 'bike') fee = hours * 10;
else if (vehicle === 'truck') fee = hours * 40;
else fee = hours * 20;
if (minutes <= 10) fee = 0; // grace period
if (enterHour >= 22 || enterHour < 6) fee = Math.round(fee / 2); // night discount
if (fee > 100) fee = 100; // cap
return fee;
}Quick checkA pricing if/else ladder gets a new rule every month. What hurts?
Strategy: the rule behind an interface
Picture a game console with swappable cartridges. It only cares that the cartridge fits the slot. That is a Strategy.
Here the part that varies is how a stay becomes a price. Name it fee(stay), and let Garage depend on the name, not on any one rule.
Garage is handed its pricing (injection), so a test passes a FlatRate. A new scheme is a new class, and Garage stays untouched (open/closed).
// A PricingStrategy is any object with fee(stay) that returns whole currency units.
export class HourlyRate {
constructor(rate) { this.rate = rate; }
fee({ minutes }) { return Math.ceil(minutes / 60) * this.rate; } // every started hour counts
}
export class FlatRate {
constructor(amount) { this.amount = amount; }
fee() { return this.amount; }
}
export class Garage {
constructor(pricing) { this.pricing = pricing; } // handed in, never built in here
checkout(stay) { return this.pricing.fee(stay); }
switchTo(pricing) { this.pricing = pricing; } // stadium night: swap while running
}
// Choosing at runtime is a strategy that picks another strategy.
export class ByVehicle {
constructor(table, fallback) { this.table = table; this.fallback = fallback; }
fee(stay) { return (this.table[stay.vehicle] ?? this.fallback).fee(stay); }
}Quick checkWhat does Strategy let you swap?
Decorator: wrap, do not branch
Think of a gift wrapped in layers: ribbon over paper over box. Each layer adds something, and you still hold one gift. That is a Decorator.
Grace periods, caps and discounts adjust hourly pricing. Each implements fee(stay) and holds another strategy, which it calls and changes.
A wrapper looks like the thing it wraps, so wrappers stack:
Read it inside out: 20 an hour, half off at night, never over 100, free for the first 10 minutes.
const isNight = (hour) => hour >= 22 || hour < 6;
// A decorator is a PricingStrategy that holds another one: same interface in, same interface out.
export class GracePeriod {
constructor(inner, minutes) { this.inner = inner; this.minutes = minutes; }
fee(stay) { return stay.minutes <= this.minutes ? 0 : this.inner.fee(stay); }
}
export class NightDiscount {
constructor(inner, percentOff) { this.inner = inner; this.percentOff = percentOff; }
fee(stay) {
const fee = this.inner.fee(stay);
if (!isNight(stay.enterHour)) return fee;
return Math.floor((fee * (100 - this.percentOff) + 50) / 100); // integer math, halves round up
}
}
// One cap per stay. A cap per calendar day needs the stay split at midnight (see the parking lot question).
export class Cap {
constructor(inner, max) { this.inner = inner; this.max = max; }
fee(stay) { return Math.min(this.inner.fee(stay), this.max); }
}const pricing = new GracePeriod(new Cap(new NightDiscount(new HourlyRate(20), 50), 100), 10);Quick checkDecorator adds behaviour by doing what?
Watch the design grow
Step through. Stage one is the ladder. Each later stage adds classes without editing the old ones.
Garage starts with the whole fee calculation inside it. Step by step the calculation moves behind the PricingStrategy interface, concrete rates implement it, and decorators implement it too while holding another strategy, so grace periods, caps and discounts stack without touching Garage.
- ◆ composition (owns)
- ◇ aggregation
- △ inheritance / implements (dashed)
- → association · ⇢ dependency
The order of wrapping is part of the rule
An 8-hour night stay at 20 an hour is 160 before any rule. Try a 50% night discount and a cap of 100, in each order:
| Wrapping | Night, 8 hours | Day, 8 hours |
|---|---|---|
Cap(NightDiscount(base)) | 160, then 80, cap leaves 80 | 160, cap makes 100 |
NightDiscount(Cap(base)) | 160, cap makes 100, then 50 | 160, cap makes 100 |
Neither is a bug. They are different policies: "never pay over 100" or "half off the capped price". Decide on purpose and let a test hold it. Put a grace period outermost, so a free stay skips every other rule.
import { HourlyRate } from './strategy.js';
import { Cap, NightDiscount } from './rules.js';
const base = new HourlyRate(20);
// Same three rules, two wrapping orders. Read from the inside out.
export const capThenDiscount = new NightDiscount(new Cap(base, 100), 50); // the discount applies to the capped fee
export const discountThenCap = new Cap(new NightDiscount(base, 50), 100); // the cap applies to what the driver paysQuick check10% off then $5 off differs from $5 off then 10%. Why?
Strategy, decorator, or just a function
In JavaScript and Python you often do not need a class. A function is a strategy with one method, and a function that takes a function is a decorator.
The three versions give the same numbers. Use a class when the rule has configuration to inspect, several methods, state, or a name for logs.
// In JavaScript a function is the simplest strategy, and a function that wraps a function is the simplest decorator.
export const hourly = (rate) => ({ minutes }) => Math.ceil(minutes / 60) * rate;
export const capped = (fee, max) => (stay) => Math.min(fee(stay), max);
export const nightly = (fee, percentOff) => (stay) => {
const night = stay.enterHour >= 22 || stay.enterHour < 6;
const base = fee(stay);
return night ? Math.floor((base * (100 - percentOff) + 50) / 100) : base;
};
export const price = capped(nightly(hourly(20), 50), 100); // cap(discount(base)): read from the inside outWill this rule grow beyond two cases, or change on its own schedule?
Quick checkOne rule that will never change. What do you write?
The over-engineering trap
Habits that keep it cheap:
- One interface, one method.
- Readable wrapper names, and log the chain at startup.
- No registry or factory until rules really come from configuration.
- Keep wrappers pure: same stay, same fee, clock passed in.
Quick checkTwo pricing rules, no new ones expected. Best design?
Try it
BookingFee adds a flat 5. Move it inside or outside Cap and the total changes. Then write your own wrapper and place it.
Next: a rate limiter with a swappable algorithm, or then state machines and observers.
// The new rule is BookingFee, a flat 5 on top of its inner rule. Move it inside or outside Cap and watch the total.
const stay = { minutes: 480, enterHour: 23, vehicle: 'car' }; // 8 hours, arrived at 11 pm
const base = new HourlyRate(20); // 160
class BookingFee {
constructor(inner, amount) { this.inner = inner; this.amount = amount; }
fee(stay) { return this.inner.fee(stay) + this.amount; }
}
const feeAfterCap = new BookingFee(new Cap(base, 100), 5);
const capAfterFee = new Cap(new BookingFee(base, 5), 100);
console.log(feeAfterCap.fee(stay)); // 105: the cap comes first, the fee sits on top
console.log(capAfterFee.fee(stay)); // 100: the booking fee is capped too
console.log(new NightDiscount(feeAfterCap, 50).fee(stay)); // 53: 52.5 rounds up
// Your turn: write a MemberDiscount that takes 10 off (never below 0) and wrap it somewhere.Follow-up questions
Strategy and State look the same in code. What is the difference?
A strategy is chosen from outside and usually stays put. A state changes itself as events arrive.
How do you test a stack of decorators?
Test each wrapper alone with a constant inner strategy like FlatRate(160). Then test real stacks, both orders of any pair that interact.
Why inject the strategy instead of creating it inside Garage?
Creating it inside ties the class to one rule. Injection lets tests pass a stub.
Check yourself
Question 1 of 3Score 2 out of 3 or better to mark this page complete.
Code it yourself
Write the class in the browser editor, in JavaScript or Python. Each exercise runs against hidden tests, and the step-by-step solution unlocks after a wrong submission.
- Rate LimiterPut fixed-window, sliding-window and token-bucket limiting behind one allow() method and switch between them at runtime.Medium
- Pricing RulesCompute a parking fee by stacking grace, cap, discount and minimum rules that each wrap the fee so far, in the order they were added.Medium
- Eviction CacheBuild a fixed-size cache whose eviction rule (LRU, FIFO or LFU) is chosen by name, without any policy branching inside the cache.Medium
- Expense SplitterSplit bills equally, by exact amounts, by percent or by shares in integer cents, track who owes whom, and simplify the debts.Medium
- Logging FrameworkBuild a logger tree with inherited levels, appenders that each have their own threshold, and a swappable plain or JSON format.Medium
- Snake and LadderBuild a Snake and Ladder engine with jumps, three swappable ending rules, extra turns on a six and validation of bad boards, all driven by fixed dice.Medium
- Elevator SchedulerDrive one elevator car tick by tick with a swappable scheduling policy (LOOK sweeps or first-come-first-served) and exact call handling.Medium
- Library SystemModel books and copies, member tiers, a fair reservation queue with expiring holds, renewals and a tier-based fine policy, using only day numbers for time.Medium