// ───────────────────────────────────────────────────────────────────────────── // TEMPLATE METHOD [Behavioral] // ───────────────────────────────────────────────────────────────────────────── // INTENT : Define the skeleton of an algorithm in an operation, deferring some steps to subclasses; // subclasses redefine certain steps without changing the algorithm's structure. // PROBLEM : CSV, JSON and XLSX exports all do fetch → filter → transform → serialize → store → notify, and // each copy has drifted; you want the SEQUENCE fixed in one place and only the varying steps open. // SAY : Participants: AbstractClass (templateMethod() — the fixed skeleton — plus primitive operations // and optional hooks), ConcreteClass (implements the primitives, optionally overrides hooks). // Pitch: "the parent owns the algorithm's order; children fill in the blanks." // SEEN IN : Node streams — you override `_transform` / `_flush` / `_read` / `_write`, the base class runs the // machinery; React class lifecycle (`componentDidMount`, `render` called by the framework); // `JSON.stringify` calling your `toJSON()`; `Symbol.toPrimitive` / `valueOf` / `toString` hooks; // Jest/Mocha `beforeEach` / `afterEach` around each test; Passport `Strategy#authenticate`; // Mongoose `pre('save')` / `post('save')` hooks. // VS : Strategy swaps the WHOLE algorithm via composition (an object); Template Method keeps the skeleton // and swaps STEPS via inheritance (or, in JS, an options object of callbacks). Factory Method is a // Template Method whose varying step is "create the product". // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); // ── Classic shape (classes, so the participant names are visible) class DataExporter { // AbstractClass constructor(sink) { this.sink = sink; } export(rows) { // the Template Method — fixed order, never overridden const kept = rows.filter((r) => this.include(r)); // hook (default: keep all) const shaped = kept.map((r) => this.transform(r)); // hook (default: identity) const body = this.serialize(shaped); // primitive: MUST be implemented const name = `${this.baseName(rows)}.${this.extension()}`; // hook + primitive this.sink.push({ name, body, count: shaped.length }); this.afterExport(name, shaped.length); // hook (default: no-op) return name; } include() { return true; } transform(row) { return row; } baseName() { return 'export'; } afterExport() {} serialize() { throw new Error(`${this.constructor.name} must implement serialize()`); } extension() { throw new Error(`${this.constructor.name} must implement extension()`); } } class CsvExporter extends DataExporter { // ConcreteClass — primitives only serialize(rows) { const cols = Object.keys(rows[0] ?? {}); return [cols.join(','), ...rows.map((r) => cols.map((c) => r[c]).join(','))].join('\n'); } extension() { return 'csv'; } } class PaidOrdersJsonExporter extends DataExporter { // ConcreteClass — primitives + hooks constructor(sink, audit) { super(sink); this.audit = audit; } include(row) { return row.status === 'paid'; } transform(row) { return { id: row.id, total: (row.cents / 100).toFixed(2) }; } serialize(rows) { return JSON.stringify(rows); } extension() { return 'json'; } baseName() { return 'paid-orders'; } afterExport(name, n) { this.audit.push(`${name}: ${n} rows`); } } const rows = [ { id: 1, status: 'paid', cents: 1999 }, { id: 2, status: 'refunded', cents: 500 }, { id: 3, status: 'paid', cents: 100 }, ]; const sink = []; const audit = []; assert.equal(new CsvExporter(sink).export(rows), 'export.csv'); assert.equal(sink[0].body, 'id,status,cents\n1,paid,1999\n2,refunded,500\n3,paid,100'); assert.equal(sink[0].count, 3); assert.deepEqual(audit, []); // default hook did nothing assert.equal(new PaidOrdersJsonExporter(sink, audit).export(rows), 'paid-orders.json'); assert.equal(sink[1].body, '[{"id":1,"total":"19.99"},{"id":3,"total":"1.00"}]'); assert.equal(sink[1].count, 2); // include() hook filtered assert.deepEqual(audit, ['paid-orders.json: 2 rows']); // afterExport() hook fired assert.throws(() => new DataExporter([]).export(rows), /DataExporter must implement serialize\(\)/); // ── Idiomatic JS shape: a function holding the skeleton + an options object of step overrides const exportData = (rows, { include = () => true, transform = (r) => r, serialize, // required step name = 'export', onDone = () => {}, }) => { if (typeof serialize !== 'function') throw new TypeError('serialize is required'); const shaped = rows.filter(include).map(transform); const out = { name, body: serialize(shaped), count: shaped.length }; onDone(out); return out; }; const done = []; const tsv = exportData(rows, { serialize: (rs) => rs.map((r) => Object.values(r).join('\t')).join('\n'), name: 'all.tsv', onDone: (o) => done.push(o.name), }); assert.equal(tsv.body.split('\n').length, 3); assert.equal(tsv.count, 3); const bigOnly = exportData(rows, { include: (r) => r.cents > 400, transform: (r) => r.id, serialize: JSON.stringify }); assert.equal(bigOnly.body, '[1,2]'); assert.deepEqual(done, ['all.tsv']); // onDone only where supplied assert.throws(() => exportData(rows, {}), TypeError); // The language does this to you: JSON.stringify's skeleton calls your toJSON() hook class Money { constructor(cents) { this.cents = cents; } toJSON() { return { amount: (this.cents / 100).toFixed(2), currency: 'USD' }; } [Symbol.toPrimitive](hint) { return hint === 'number' ? this.cents : `$${(this.cents / 100).toFixed(2)}`; } } assert.equal(JSON.stringify({ price: new Money(1250) }), '{"price":{"amount":"12.50","currency":"USD"}}'); assert.equal(`${new Money(1250)}`, '$12.50'); assert.equal(+new Money(1250), 1250); console.log('ok: design-patterns/behavioral/template-method.js');