Lesson 10 / 30
Decorator
Adding behavior in layers: comparing, by type and line count, the arrangement that produces a subclass for every combination of four independently toggled fee additions against a decorator chain that wraps while preserving the same interface; measuring the growth when a fifth addition arrives; the cost in chain depth and order sensitivity.
Contents
In the composite, what got wrapped was a collection: a node asked its parts through the same interface as itself, and wrapping built a tree. The same technique is also used on a single object, for a different purpose.
A shipment’s fee needs four additions applied in sequence: a fuel surcharge, insurance, a contracted-customer discount, and tax. All four toggle independently — one shipment wants only tax, another wants fuel surcharge and insurance. If every combination becomes a subclass, the class count grows as a power of two in the option count. The decorator builds this combination at the object level instead of the class level: each addition is a wrapper that returns the exact interface of the object it wraps and places its own contribution on top of the result. The measures are type count, line count, chain depth, and order sensitivity.
Problem: The Product of Independent Options
The base fee and the sample shipments are common to both arrangements.
// tariff.mjs — the base fee and sample shipments both arrangements use; amount is in cents const TIER = [[1, 4990], [5, 8490], [15, 14990], [30, 24990]]; const COEFFICIENT = { 34: 100, "06": 115, 35: 120, 65: 145 }; export const tierFee = (weight, postalCode) => { const t = TIER.find(([cap]) => weight <= cap) ?? [0, 24990]; return Math.round((t[1] * (COEFFICIENT[postalCode.slice(0, 2)] ?? 165)) / 100); }; export const SHIPMENTS = [ { code: "G-1", weight: 0.8, postalCode: "34100", value: 120000 }, { code: "G-2", weight: 12, postalCode: "06500", value: 40000 }, { code: "G-3", weight: 26, postalCode: "65100", value: 900000 }, ];
The subclass arrangement needs one class per option subset: one applying only the fuel surcharge, one applying the fuel surcharge and tax, one applying all three… Writing all sixteen classes by hand does not fit this lesson’s text; a generator writes them instead. The generator’s existence is already the measure itself — that is the number of bodies that would have to be written by hand.
// generate.mjs — produces the subclass explosion: every subset of the four options import { mkdirSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; export const STEP = [ ["F", "t = Math.round(t * 1.08);"], ["I", "t = t + Math.max(500, Math.round(g.value * 0.005));"], ["D", "t = Math.round(t * 0.88);"], ["T", "t = Math.round(t * 1.2);"], ]; export function generateAndWrite(path, steps) { const chunk = ['import { tierFee } from "../tariff.mjs";', ""]; const names = []; for (let mask = 0; mask < 2 ** steps.length; mask += 1) { const selected = steps.filter((_, i) => (mask >> i) & 1); const name = `Fee${selected.map(([h]) => h).join("") || "0"}`; names.push(name); chunk.push(`export class ${name} {`, " calculate(g) {", " let t = tierFee(g.weight, g.postalCode);", ...selected.map(([, code]) => ` ${code}`), " return t;", " }", "}"); } chunk.push(`export const CLASS = { ${names.join(", ")} };`); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${chunk.join("\n")}\n`); return names.length; } console.log(`generated classes = ${generateAndWrite("subclass/fees.mjs", STEP)}`);
generated classes = 16
The generated file’s beginning shows how the bodies repeat.
head -16 subclass/fees.mjs
import { tierFee } from "../tariff.mjs";
export class Fee0 {
calculate(g) {
let t = tierFee(g.weight, g.postalCode);
return t;
}
}
export class FeeF {
calculate(g) {
let t = tierFee(g.weight, g.postalCode);
t = Math.round(t * 1.08);
return t;
}
}
export class FeeI {
The fuel-surcharge line is written out separately in all eight of the eight classes that include it. The ratio is the same for every option: an option’s code repeats in half of the subsets.
Solution: A Wrapper That Returns the Same Interface
The decorator’s solution is to make the addition an object, not a class. A wrapper offers the same interface as the object it takes in, so it can itself be wrapped.
// decorator/base.mjs — the core to be wrapped: knows only the base fee import { tierFee } from "../tariff.mjs"; export const baseFee = { calculate: (g) => tierFee(g.weight, g.postalCode) };
// decorator/wrappers.mjs — four decorators: each returns the same interface, does not know what it wraps export const fuelSurcharge = (inner) => ({ inner, calculate: (g) => Math.round(inner.calculate(g) * 1.08) }); export const insurance = (inner) => ({ inner, calculate: (g) => inner.calculate(g) + Math.max(500, Math.round(g.value * 0.005)), }); export const contractedDiscount = (inner) => ({ inner, calculate: (g) => Math.round(inner.calculate(g) * 0.88) }); export const tax = (inner) => ({ inner, calculate: (g) => Math.round(inner.calculate(g) * 1.2) }); export const chain = (base, wraps) => wraps.reduce((n, f) => f(n), base); export const depth = (n) => (n.inner === undefined ? 1 : 1 + depth(n.inner));
Every wrapper holds a reference to the object it wraps under the name inner; depth
follows this reference to say how many bodies the chain consists of. The run below computes
and compares all sixteen subsets in both arrangements.
// run.mjs — do the two arrangements give the same amount for every option subset import { SHIPMENTS } from "./tariff.mjs"; import { CLASS } from "./subclass/fees.mjs"; import { baseFee } from "./decorator/base.mjs"; import { fuelSurcharge, insurance, contractedDiscount, tax, chain, depth } from "./decorator/wrappers.mjs"; const WRAP = [["F", fuelSurcharge], ["I", insurance], ["D", contractedDiscount], ["T", tax]]; const N = 2 ** WRAP.length; let mismatched = 0; for (let mask = 0; mask < N; mask += 1) { const selected = WRAP.filter((_, i) => (mask >> i) & 1); const name = `Fee${selected.map(([h]) => h).join("") || "0"}`; const sub = new CLASS[name](); const wrapped = chain(baseFee, selected.map(([, f]) => f)); for (const g of SHIPMENTS) if (sub.calculate(g) !== wrapped.calculate(g)) mismatched += 1; if ([0, 1, 5, N - 1].includes(mask)) { console.log(`${name.padEnd(10)} G-1=${sub.calculate(SHIPMENTS[0])} G-3=${sub.calculate(SHIPMENTS[2])} ` + `chain depth=${depth(wrapped)}`); } } console.log(`options=${WRAP.length} subsets=${N} mismatched amounts = ${mismatched} / ${N * SHIPMENTS.length}`);
Fee0 G-1=4990 G-3=36236 chain depth=1 FeeF G-1=5389 G-3=39135 chain depth=2 FeeFD G-1=4742 G-3=34439 chain depth=3 FeeFIDT G-1=6324 G-3=46079 chain depth=5 options=4 subsets=16 mismatched amounts = 0 / 48
All forty-eight calculations agree — sixteen subsets, three shipments. The difference is not in the amounts, but in how many types are carried.
// measure.mjs — counts the type and line count in both arrangements import { readdirSync, readFileSync } from "node:fs"; for (const dir of ["subclass", "decorator"]) { let type = 0, line = 0; for (const d of readdirSync(dir).filter((x) => x.endsWith(".mjs")).sort()) { const text = readFileSync(`${dir}/${d}`, "utf8"); type += (text.match(/^export class \w+/gm) ?? []).length + (text.match(/^export const \w+ = \(inner\)/gm) ?? []).length + (text.match(/^export const baseFee/gm) ?? []).length; line += text.split("\n").filter((s) => s.trim() !== "" && !s.trim().startsWith("//")).length; } console.log(`${dir.padEnd(10)} type=${String(type).padStart(2)} line=${String(line).padStart(3)}`); }
subclass type=16 line=130 decorator type= 5 line= 10
Sixteen types and 130 lines against five types and 10 lines. The five types are the core plus four wrappers; the combination of options is never written as a type anywhere, it is assembled at run time.
When a Fifth Addition Arrives
The new requirement is a remote-zone surcharge: a fixed 1500 cents. It adds one line to the generator’s table in the subclass arrangement, one line to the wrapper file in the decorator arrangement.
mkdir -p new && cp -r tariff.mjs generate.mjs decorator run.mjs measure.mjs new/ cd new sed -i.y 's#^\];# ["R", "t = t + 1500;"],\n];#' generate.mjs cat >> decorator/wrappers.mjs <<'WRAP' export const remoteZoneSurcharge = (inner) => ({ inner, calculate: (g) => inner.calculate(g) + 1500 }); WRAP sed -i.y -e 's#, tax, chain, depth }#, tax, remoteZoneSurcharge, chain, depth }#' \ -e 's#\["T", tax\]\];#["T", tax], ["R", remoteZoneSurcharge]];#' run.mjs rm -f *.y node generate.mjs node run.mjs | tail -2 node measure.mjs echo "decorator: added lines=$(diff -rN ../decorator decorator | grep '^>' | grep -cvE '^> *(//|$)') edited files=$(diff -rq ../decorator decorator | grep -c '^Files ')"
generated classes = 32 FeeFIDTR G-1=7824 G-3=47579 chain depth=6 options=5 subsets=32 mismatched amounts = 0 / 96 subclass type=32 line=274 decorator type= 6 line= 11 decorator: added lines=1 edited files=1
The two measurement points give the growth rule: in the subclass arrangement the type count went from 16 to 32 and the line count from 130 to 274; in the decorator arrangement from 5 to 6 and from 10 to 11. The subclass count doubles per option, the decorator count grows by one per option. All ninety-six calculations agreed across both arrangements.
Cost: Chain Depth and Order
The decorator’s first cost already shows in the run output: chain depth went from 1 to 5,
and to 6 with the fifth addition. In the subclass arrangement, one amount is computed in a
single body, and debugging means reading that single body; in the decorator arrangement,
the same amount means tracing six bodies in sequence. Code that wants to reach the core must
also follow the inner reference — this lengthens the access chain measured by the Law of
Demeter lesson in the Design Principles course.
The second cost is more insidious: the result is sensitive to wrapping order.
// order.mjs — the same two decorators in two orders: insurance first, or discount first import { SHIPMENTS } from "./tariff.mjs"; import { baseFee } from "./decorator/base.mjs"; import { insurance, contractedDiscount, chain } from "./decorator/wrappers.mjs"; const g = SHIPMENTS[0]; const a = chain(baseFee, [insurance, contractedDiscount]).calculate(g); const b = chain(baseFee, [contractedDiscount, insurance]).calculate(g); console.log(`insurance -> discount = ${a}`); console.log(`discount -> insurance = ${b}`); console.log(`difference = ${b - a} cents`);
insurance -> discount = 4919 discount -> insurance = 4991 difference = 72 cents
The seventy-two cents comes from whether the discount applies to the insurance amount or not: the result changes when a multiplicative addition and an additive addition swap places. In the subclass arrangement, order is fixed in the class body and readable; in the decorator arrangement, order lives wherever the chain is assembled, and stays there as an unwritten rule. Wherever the pattern is used, where the order is defined must be shown explicitly.
The third cost is the inability to block invalid combinations. Assembling a chain accepts any subset; a rule like “contracted-customer discount and volume discount never coexist on the same shipment” is a rule no wrapper can see, because every wrapper knows only what it wraps.
Two conditions for not applying the pattern follow from this. If the option count is one or two, the power of two is small (two or four) and the decorator’s indirection does not pay off. If the additions depend on each other — one requiring or excluding the other’s presence — the assumption of independent layering fails; in that case a calculation gathering the rule in a single body produces fewer invalid combinations.
This is also where the pattern splits from the adapter and the bridge: the adapter changes the interface, the bridge separates two axes, the decorator preserves the interface and adds behavior on top. A wrapper returning the same interface is the only reason the chain can extend without limit.
Summary
- The problem the decorator solves is that every combination of independently toggled additions needs a type; the solution is making the addition an object that returns exactly the interface of what it wraps.
- For four options, the subclass arrangement carried 16 types and 130 lines, the decorator arrangement 5 types and 10 lines; the sixteen subsets’ forty-eight calculations agreed across both arrangements.
- A fifth addition took the subclass arrangement to 32 types and 274 lines, the decorator arrangement to 6 types and 11 lines: the subclass count doubles per option, the decorator count grows by one.
- The cost is measured: chain depth went from 1 to 6, meaning the number of bodies to trace for one amount reached six; swapping order on the same two additions produced a 72-cent difference.
- The pattern does not pay off when the option count is one or two, or when the additions depend on each other; assembling the chain cannot block invalid combinations.
Next Step
The code assembling the decorator chain had to know which additions to apply in which order. That knowledge is tolerable for a single call, but if the whole library works this way, the client has to learn every sequential step: normalize the address, resolve the zone, select the tariff, apply the additions, choose the carrier, compute the delivery date. The knowledge of calling six modules in the right order repeats in every client; if a step is skipped, the error surfaces at run time. The next lesson counts the client’s direct dependency count and how many places repeat the sequential-step knowledge, writes the facade pattern that puts a single entry point in front of the subsystem, and measures whether the facade shrinks the import closure.
To keep your progress and take notes, Log in
My notes
Log in to take notes.