Lesson 17 / 30
Template Method
Comparing sequencing the fee calculation steps separately in three carrier files with moving the skeleton to a single place: the number of lines duplicated in at least two files, the amount error produced by a deviation in step order, the number of files edited when a seventh step is added, and the pattern's cost as the total line increase and the number of empty hooks.
Contents
The three commands did the same three steps in the same order: save the previous value, write the new value, keep the entry. The same repetition exists at a larger scale on the fee calculation side. A fee calculation goes through six steps — determine chargeable weight, calculate base fee, apply zone factor, add the surcharge, subtract the discount, apply the minimum fee — and for the domestic, express, and international carriers, three files sequence these six steps separately. The steps’ order must be the same in all three; only the content of a few steps changes.
The Template Method moves the order to a single place and leaves the changing steps to hooks that come from outside. The gain is measured by two numbers: the number of duplicated lines and the number of files edited when a step is added to the sequence. There is a third number too, and it is the pattern’s real justification: if the order deviates in one copy, the amount produced is wrong.
The Duplicated Skeleton
The shared data and zone factors are shared by both versions.
mkdir -p duplicated template
// zone.mjs — zone factors and shipment set shared by both versions export const ZONE_FACTOR = { "1": 100, "2": 130, "3": 175 }; export const SHIPMENTS = [ { code: "GN-1", weight: 2, volume: 8, zone: "1", discount: 0 }, { code: "GN-2", weight: 6, volume: 60, zone: "2", discount: 10 }, { code: "GN-3", weight: 14, volume: 20, zone: "3", discount: 0 }, { code: "GN-4", weight: 1, volume: 3, zone: "2", discount: 40 }, { code: "GN-5", weight: 22, volume: 180, zone: "3", discount: 25 }, ];
The domestic carrier’s file sequences the six steps; because its surcharge is zero, that step was never written at all.
// duplicated/domestic.mjs — six steps sequenced in this file, surcharge step skipped import { ZONE_FACTOR } from "../zone.mjs"; export const MINIMUM_FEE = 3900; export function calculateFee(shipment) { const weight = Math.ceil(shipment.weight); let amount = 2500 + 420 * weight; amount = Math.round((amount * ZONE_FACTOR[shipment.zone]) / 100); amount -= Math.round((amount * shipment.discount) / 100); return Math.max(amount, MINIMUM_FEE); }
// duplicated/express.mjs — the same six steps sequenced again import { ZONE_FACTOR } from "../zone.mjs"; export const MINIMUM_FEE = 6500; export function calculateFee(shipment) { const weight = Math.ceil(shipment.weight); let amount = 4000 + 640 * weight; amount = Math.round((amount * ZONE_FACTOR[shipment.zone]) / 100); amount += 1500; amount -= Math.round((amount * shipment.discount) / 100); return Math.max(amount, MINIMUM_FEE); }
In the third file, the last two steps run in reverse order: the minimum fee is applied before the discount.
// duplicated/international.mjs — same six steps, last two steps swapped import { ZONE_FACTOR } from "../zone.mjs"; export const MINIMUM_FEE = 12000; export function calculateFee(shipment) { const weight = Math.max(Math.ceil(shipment.weight), Math.ceil(shipment.volume / 5)); let amount = 6000 + 900 * weight; amount = Math.round((amount * ZONE_FACTOR[shipment.zone]) / 100); amount += 2200; amount = Math.max(amount, MINIMUM_FEE); return amount - Math.round((amount * shipment.discount) / 100); }
Moving the Skeleton to One Place
In the template version, the order lives in one file, and the four things that change are hooks: chargeable weight, base fee, surcharge, and minimum fee.
// template/template.mjs — fixed skeleton, six steps in one place import { ZONE_FACTOR } from "../zone.mjs"; export function calculateFee(tariff, shipment) { const weight = tariff.chargeableWeight(shipment); let amount = tariff.baseFee(weight); amount = Math.round((amount * ZONE_FACTOR[shipment.zone]) / 100); amount += tariff.surcharge(shipment); amount -= Math.round((amount * shipment.discount) / 100); return Math.max(amount, tariff.minimumFee); }
// template/tariffs.mjs — only the steps that change const byWeight = (s) => Math.ceil(s.weight); export const domestic = { name: "domestic", minimumFee: 3900, chargeableWeight: byWeight, baseFee: (w) => 2500 + 420 * w, surcharge: () => 0, }; export const express = { name: "express", minimumFee: 6500, chargeableWeight: byWeight, baseFee: (w) => 4000 + 640 * w, surcharge: () => 1500, }; export const international = { name: "international", minimumFee: 12000, chargeableWeight: (s) => Math.max(Math.ceil(s.weight), Math.ceil(s.volume / 5)), baseFee: (w) => 6000 + 900 * w, surcharge: () => 2200, };
This differs from the tariff object in the Strategy lesson at one point: strategy took over the whole calculation, while a hook takes over only one step. The order is owned by the skeleton; a hook cannot choose when it is called.
Measuring the Deviation
The driver script performs fifteen calculations in both versions and counts the diverging results and the amounts that fall below the minimum fee.
// run.mjs — runs both versions on the same shipments, counts amounts falling below minimum import { SHIPMENTS } from "./zone.mjs"; import * as domestic from "./duplicated/domestic.mjs"; import * as express from "./duplicated/express.mjs"; import * as international from "./duplicated/international.mjs"; import { calculateFee } from "./template/template.mjs"; import * as tariffs from "./template/tariffs.mjs"; const DUPLICATED = { domestic, express, international }; let mismatch = 0; let belowMinimum = { duplicated: 0, template: 0 }; for (const name of ["domestic", "express", "international"]) { const row = []; for (const s of SHIPMENTS) { const a = DUPLICATED[name].calculateFee(s); const b = calculateFee(tariffs[name], s); if (a !== b) mismatch += 1; if (a < DUPLICATED[name].MINIMUM_FEE) belowMinimum.duplicated += 1; if (b < tariffs[name].minimumFee) belowMinimum.template += 1; row.push(`${s.code}=${a}/${b}`); } console.log(`${name.padEnd(14)} ${row.join(" ")}`); } console.log(`mismatches between the two versions = ${mismatch}`); console.log(`amounts falling below minimum fee: duplicated=${belowMinimum.duplicated} template=${belowMinimum.template}`);
domestic GN-1=3900/3900 GN-2=5873/5873 GN-3=14665/14665 GN-4=3900/3900 GN-5=15409/15409 express GN-1=6780/6780 GN-2=10523/10523 GN-3=24180/24180 GN-4=6500/6500 GN-5=24855/24855 international GN-1=12000/12000 GN-2=21636/21636 GN-3=34750/34750 GN-4=7200/12000 GN-5=52050/52050 mismatches between the two versions = 1 amounts falling below minimum fee: duplicated=1 template=0
Fourteen of the fifteen calculations agree; one diverges. GN-4, with a forty percent discount, comes out to 7200 in the duplicated version on the international carrier and 12000 in the template version. Since the minimum fee is 12000, the amount the duplicated version produces breaks the contract. The source of the deviation is not a formula error but the step order: when the skeleton was written three times, two steps swapped places in the third. In the template version, the order is not open to the hooks, so there is no place to write this deviation.
Duplicated Lines and a New Step
The second measure is static: how many meaningful lines exist in each version, and how many of them appear in at least two files.
// duplication.mjs — counts the number of duplicated lines in both versions import { readFileSync, readdirSync } from "node:fs"; const meaningful = (path) => readFileSync(path, "utf8") .split("\n") .map((s) => s.trim()) .filter((s) => s.length > 0 && s.startsWith("//") === false && s.startsWith("import ") === false); function count(dir) { const files = readdirSync(dir).sort(); const tally = new Map(); for (const f of files) { for (const s of new Set(meaningful(`${dir}/${f}`))) tally.set(s, (tally.get(s) ?? 0) + 1); } const duplicated = [...tally.entries()].filter(([, n]) => n >= 2); const total = files.reduce((t, f) => t + meaningful(`${dir}/${f}`).length, 0); return { files: files.length, total, duplicated: duplicated.length }; } for (const dir of ["duplicated", "template"]) { const r = count(dir); console.log(`${dir.padEnd(11)} files=${r.files} meaningful lines=${r.total} lines in at least two files=${r.duplicated}`); }
duplicated files=3 meaningful lines=26 lines in at least two files=6 template files=2 meaningful lines=30 lines in at least two files=0
Six versus zero. These six lines are knowledge duplication: the same step written the same way three times. Now a seventh step enters the sequence — a six percent fuel surcharge after the zone factor.
// step.mjs — adds a seventh step (fuel surcharge) to both versions import { cpSync, readFileSync, readdirSync, writeFileSync } from "node:fs"; cpSync("duplicated", "duplicated-new", { recursive: true }); cpSync("template", "template-new", { recursive: true }); const ANCHOR = " amount = Math.round((amount * ZONE_FACTOR[shipment.zone]) / 100);"; const FUEL = " amount += Math.round((amount * 6) / 100);"; const add = (path) => { const before = readFileSync(path, "utf8"); const after = before.replace(ANCHOR, `${ANCHOR}\n${FUEL}`); writeFileSync(path, after); return after === before ? 0 : 1; }; let duplicatedEdited = 0; for (const f of readdirSync("duplicated-new")) duplicatedEdited += add(`duplicated-new/${f}`); let templateEdited = 0; for (const f of readdirSync("template-new")) templateEdited += add(`template-new/${f}`); console.log(`files edited for the seventh step: duplicated=${duplicatedEdited} template=${templateEdited}`); const { SHIPMENTS } = await import("./zone.mjs"); const d = await import("./duplicated-new/domestic.mjs"); const tpl = await import("./template-new/template.mjs"); const tar = await import("./template-new/tariffs.mjs"); console.log(`GN-2 domestic new amount: duplicated=${d.calculateFee(SHIPMENTS[1])} template=${tpl.calculateFee(tar.domestic, SHIPMENTS[1])}`);
files edited for the seventh step: duplicated=3 template=1 GN-2 domestic new amount: duplicated=6226 template=6226
Three files versus one file, and both versions give the same amount after the new step. Editing three files is not just workload — it is three separate opportunities for deviation; the 7200 above came about exactly this way.
Tallying the Cost
The same static measurement also gives the cost: the meaningful line count rose from 26 to 30. The Template Method does not lower the line count; it lowers duplication and raises the total, because every hook needs a name and a signature. The second item is the hook that does no work.
echo "empty step (hook doing no work) count: duplicated=$(grep -h 'amount += 0;' duplicated/*.mjs | wc -l | tr -d ' ') template=$(grep -h 'surcharge: () => 0' template/*.mjs | wc -l | tr -d ' ')"
empty step (hook doing no work) count: duplicated=0 template=1
In the duplicated version, the domestic carrier never wrote the surcharge step at all; in the template version, it has to, because the skeleton calls that hook. A hook that returns zero is the formal cost the pattern demands. The third item is the reading cost: in the duplicated version, a carrier’s full flow appears in one file; in the template version, the skeleton sits in one file and the changing steps sit in another, so seeing the flow means reading two files.
Summary
- The Template Method moves the fixed step order into a single body and leaves the changing steps to hooks; a hook cannot choose when it is called, and the order belongs to the skeleton.
- The number of lines appearing in at least two files dropped from 6 to 0; the number of files edited when the seventh step was added dropped from 3 to 1, and both versions kept producing the same amount.
- The order deviating in one copy produced a wrong amount in one of fifteen calculations: a shipment with a forty percent discount on the international carrier fell below the minimum fee, to 7200 instead of 12000.
- Cost: the meaningful line count rose from 26 to 30, and the number of hooks doing no work rose from 0 to 1.
- A carrier’s full fee flow appears in a single file in the duplicated version and is spread across two files in the template version.
Next Step
In the template, the order was fixed; which step would run was known from the start. In the shipment itself, which operation is valid depends on the current state. A shipment can be created, collected, in transit, out for delivery, delivered, or returned; a shipment that has not been collected cannot go out for delivery, and a delivered shipment cannot be returned. Today, these rules are checked on the domain with flags and nested conditionals, and some invalid transitions slip past the check. The next lesson tries every state–event pair one by one, measures the number of accepted invalid transitions and the cyclomatic complexity, then turns the state into an object and recalculates the same numbers.
To keep your progress and take notes, Log in
My notes
Log in to take notes.