GUIDE · Behavioral

Template Method

Define the skeleton of an algorithm in an operation, deferring some steps to subclasses; subclasses redefine certain steps without changing the algorithm's structure.

lesson2 sectionsruns in browserBehavioral17 asserts116 lines

At a glance

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

The idea#

Picture a cookie recipe card. The order of the steps is printed in ink, and one line is a blank you fill in pencil. Heat the oven. Mix the butter and sugar. Stir in the ___. Scoop. Bake. The blank might say chocolate chips. It might say raisins. Nobody gets to bake before they mix, and nobody gets to scribble a new step order on the card. The card owns the sequence. You only fill the blank.

That card is a template method. The parent defines export as a fixed skeleton: keep some rows, shape them, serialize them, name the file, store it, then run an after-step. Subclasses, or an options object of callbacks, fill the blanks. serialize and extension are required blanks. The base throws if you leave them empty, because a recipe that never says what the dough became is not a recipe. include, transform, baseName, and afterExport are optional hooks with boring defaults: keep every row, leave it unchanged, name the file "export", and do nothing when you are done.

CSV and JSON exports want that same card. Left as two copied functions, they drift. One filters before it shapes, the other shapes before it filters, and a third forgets to tell anyone the tray came out. The skeleton makes that drift something you cannot do by accident. You can change the blank. You cannot change the ink.

Why it matters#

The bug this pattern prevents is three exporters that were twins in March and strangers by November. Someone writes the file before filtering on the spreadsheet path. Counts stop matching. The "tell the audit log" step runs for JSON and not for CSV, because that line was a blank someone forgot to copy. Fixing the sequence means hunting every card you photocopied.

You already fill blanks like this. A stream base class runs the machinery, and you override the one method that transforms a chunk. JSON.stringify walks its own card and calls your toJSON when it hits the blank for "what should this object look like." Test runners call your setup and teardown around a test they still own. The framework holds the order. You hold the pencil.

Strategy is the pattern to keep separate. Strategy swaps the whole algorithm by plugging in a different chip. The button does not care how the swing is computed, and there is no printed order of steps inside the call. Template method keeps the skeleton and swaps steps. Classically that is inheritance: a child class overrides a step and does not override export. In plain JavaScript it is often an options object of callbacks, which is the same card with the blanks passed in. Factory method is a template method whose blank happens to be "create the product." Same ink. Narrower pencil line.

A small example#

JS
function exportRows(rows, steps) {  const include = steps.include || (() => true);  const transform = steps.transform || ((row) => row);  const kept = rows.filter(include);  const shaped = kept.map(transform);  return steps.serialize(shaped);} const rows = [  { id: 1, status: 'paid' },  { id: 2, status: 'open' },];const csv = exportRows(rows, {  serialize: (list) => list.map((row) => `${row.id},${row.status}`).join('\n'),});const paid = exportRows(rows, {  include: (row) => row.status === 'paid',  serialize: (list) => JSON.stringify(list),});

exportRows is the card. Filter, then shape, then serialize. The first call fills only the serialize blank, so csv has both rows. The second call also fills include, so paid is a JSON array of the one paid row. Nobody reordered the steps.

What the code below is doing#

The sheet shows the same card twice, then a blank the language already calls for you.

The first shape uses classes so the names are visible. DataExporter.export is the template method, the ink. It filters with include, maps with transform, serializes, builds a file name from baseName and extension, pushes the name, body, and count to the sink, and calls afterExport. serialize and extension throw, and the error names the class, if a child leaves a required blank empty. The hooks default to keep-all, identity, "export", and a no-op. CsvExporter fills only the two required blanks, so all three orders become a CSV string named export.csv, the count is 3, and the audit list stays empty because the default after-step does nothing. PaidOrdersJsonExporter fills primitives and hooks. include keeps rows whose status is paid. transform turns cents into a money string. The name becomes paid-orders.json. afterExport writes "paid-orders.json: 2 rows" on the audit log. The body is the two paid orders. The refunded row never made the tray. Calling export on the bare base class throws, because that card still has empty required blanks.

The second shape is the card as a function, for when you do not want a subclass. Defaults cover include, transform, name, and onDone. serialize is required, and a missing one throws a TypeError. A TSV caller fills serialize, a name, and onDone, and gets three lines. A second call keeps only cents over 400 and maps each row down to its id, so the body is [1,2]. onDone ran for the first call only. The pencil marks differ. The ink does not.

The last fragment is the language holding the card. JSON.stringify calls Money.toJSON at the blank, so 1250 cents becomes "12.50" USD. Symbol.toPrimitive is the blank for coercion: the same object prints as $12.50 or as the number 1250. You did not rewrite JSON.stringify. You filled the step it left open.

When you talk about it, say this: the parent owns the algorithm's order. Children fill in the blanks.

Everything below is the demonstration. Same idea, written as code you can run.

JSsystem-design/design-patterns/behavioral/template-method.js · setup
'use strict';const assert = require('node:assert/strict');
01

Classic shape (classes, so the participant names are visible)#

JS
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 = [];
Verified by 9 asserts
JS
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 nothingassert.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 filteredassert.deepEqual(audit, ['paid-orders.json: 2 rows']);    // afterExport() hook firedassert.throws(() => new DataExporter([]).export(rows), /DataExporter must implement serialize\(\)/);
02

Idiomatic JS shape: a function holding the skeleton + an options object of step overrides#

JS
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),});const bigOnly = exportData(rows, { include: (r) => r.cents > 400, transform: (r) => r.id, serialize: JSON.stringify }); // The language does this to you: JSON.stringify's skeleton calls your toJSON() hookclass 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)}`; }}
Verified by 8 asserts
JS
assert.equal(tsv.body.split('\n').length, 3);assert.equal(tsv.count, 3);assert.equal(bigOnly.body, '[1,2]');assert.deepEqual(done, ['all.tsv']);                      // onDone only where suppliedassert.throws(() => exportData(rows, {}), TypeError);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);

See also