Foundation 3 of 6 · 25 min

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.

✓ Pull a varying rule behind an interface✓ Combine rules by wrapping instead of branching✓ Tell Strategy and Decorator apart
Say it in 30 seconds
  • 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.

0 of 0 sections ticked
30-second answer

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 if is 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.

The decorator pattern is nested gift wrap: each layer adds something around the same gift without changing it.
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.

Stage 1 of 4

One class, one ladder

Garage computes the fee itself: every vehicle, grace period, discount and cap is an if inside checkout. Each new rule edits this class and can break the old ones.

Pricing design, built up in four stepsGarage 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.Garagecheckout(stay): fee

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:

WrappingNight, 8 hoursDay, 8 hours
Cap(NightDiscount(base))160, then 80, cap leaves 80160, cap makes 100
NightDiscount(Cap(base))160, cap makes 100, then 50160, 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 pays

Quick 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 out
If, function, strategy or decorator?yesyesnonoyesnoPass a functionDecoratorStrategyDoes the new rule adjustthe answer of anotherrule instead ofIs it one smallcalculation with nothingto configure and noKeep the ifWill this rule growbeyond two cases, orchange on its own

Will 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.

try-it.js
// 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 3

Score 2 out of 3 or better to mark this page complete.

1. A stay costs 160 before rules. The rules are a night discount of 50% and a cap of 100. What does a night driver pay for Cap(NightDiscount(base))?

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.

Next: Some behaviour changes with what the object is doing right now. That is State, and its cousin Observer.