Skip to main content

Template Method — Inheritance and Injecting Hooks

This chapter compares fixing the skeleton through inheritance against composing it by injecting functions.

The problem this pattern set out to solve

GoF catalogs Template Method as a pattern that defines the skeleton of an algorithm in an abstract class and leaves the concrete behavior to subclasses1.

When several processes follow similar steps, copying the shared part around means every change to the steps ripples through all the copies. Template Method writes the order of the steps in the parent class exactly once and leaves the varying parts open as abstract methods.

The difference from Strategy is the granularity of the swap. Strategy swaps a whole algorithm. Template Method keeps the skeleton fixed and swaps only what sits in the holes.

Writing it straightforwardly with classes

The subject here is an import job. Reading, validating, and tallying are shared steps; parsing always differs per format; and validation is something you sometimes want to vary per format.

type Row = Record<string, string>;
type ImportSummary = {inserted: number; skipped: number};

abstract class ImportJob {
// Fixes the procedure. This is the template method
run(raw: string): ImportSummary {
const rows = this.parse(raw);
const valid = rows.filter((row) => this.isValid(row));
return {inserted: valid.length, skipped: rows.length - valid.length};
}

// A hole that must be filled
protected abstract parse(raw: string): Row[];

// A hole that may or may not be filled (a hook)
protected isValid(_row: Row): boolean {
return true;
}
}

// The parsing is a simplified version for explanation. Quoting, CRLF, and column-count mismatches are not handled
class CsvImportJob extends ImportJob {
protected parse(raw: string): Row[] {
const [header, ...lines] = raw.trim().split('\n');
const keys = header.split(',');
return lines.map((line) =>
Object.fromEntries(line.split(',').map((value, index) => [keys[index], value])),
);
}
}

class JsonImportJob extends ImportJob {
protected parse(raw: string): Row[] {
return JSON.parse(raw) as Row[];
}

protected override isValid(row: Row): boolean {
return row.id !== undefined;
}
}

const summary = new CsvImportJob().run('id,name\n1,alice\n2,bob');

The procedure lives in the four lines of run, and adding a format leaves those lines untouched. As a pattern it does its job correctly.

Replacing it with language features

Methods are not the only way to fill a hole. Taking a function does the same thing, and holes that need not be filled can be expressed as optional properties.

type Row = Record<string, string>;
type ImportSummary = {inserted: number; skipped: number};

type ImportHooks = {
parse: (raw: string) => Row[];
isValid?: (row: Row) => boolean;
};

function runImport(raw: string, hooks: ImportHooks): ImportSummary {
const rows = hooks.parse(raw);
const isValid = hooks.isValid ?? (() => true);
const valid = rows.filter(isValid);
return {inserted: valid.length, skipped: rows.length - valid.length};
}

const csvHooks: ImportHooks = {
parse: (raw) => {
const [header, ...lines] = raw.trim().split('\n');
const keys = header.split(',');
return lines.map((line) =>
Object.fromEntries(line.split(',').map((value, index) => [keys[index], value])),
);
},
};

const jsonHooks: ImportHooks = {
parse: (raw) => JSON.parse(raw) as Row[],
isValid: (row) => row.id !== undefined,
};

const summary = runImport('id,name\n1,alice\n2,bob', csvHooks);

Holes that must be filled become required properties and holes that need not be filled become optional ones, so the list of holes lines up in the type. In the class version you had to read the parent class body to tell which was abstract and which was a hook.

The other difference is that no inheritance chain forms. In the class version, once a derived class extends CsvImportJob further, finding where isValid was overridden means walking the hierarchy. When you pass an object, looking at the place you passed it is enough.

How this series judges it

Verdict: conditional. It comes down to the number of holes and whether the holes share state.

  • One to three holes that are independent of each other means injecting hooks — the rewrite above applies directly. The list of holes shows up in the type, and no inheritance hierarchy appears.
  • Many holes, or holes that share intermediate state, means an abstract class — with inheritance, hole implementations reach shared state through protected fields. Doing the same with injected hooks means passing state through arguments or capturing it in a closure. Once you pass five holes and half of them need the same intermediate data, an abstract class is the more natural write. That rule of thumb is this guide's judgment and is not based on measurement.

What to look at when deciding is not "do I want to avoid inheritance" but "do the hole implementations need to see the same data". If they do not, passing them in is enough.

What would overturn this judgment

Injecting hooks has a weak point: the order in which they are called does not appear in the type. That isValid runs after parse is only clear once you read the body of runImport. The situation is the same with an abstract class, but lining hooks up in a single object makes it even less apparent that an order exists.

When the holes depend on each other in sequence (the next hole receiving what the previous one returned, for instance), expressing that handoff in the type is safer. Go that far and you are no longer writing Template Method but composing a pipeline of stages.

How this relates to existing articles

This site has several structures equivalent to Template Method, none of which name the pattern. This chapter changes the subject and approaches it from the pattern's side.

  • Inheritance and interfaces has an example where an abstract class holds the shared processing and factors the varying part out into an abstract method. That article is the authority on abstract class syntax itself.
  • Classes and interfaces also has an example with an abstract class and derived classes.

The comparison table for choosing between abstract classes and interfaces is also in Inheritance and interfaces. This chapter does not repeat it.

Footnotes

  1. Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Design Patterns: Elements of Reusable Object-Oriented Software (Addison-Wesley, 1994). Template Method is catalogued there as a behavioral pattern. The summary of the intent is this guide's own; we have not checked it against the original text.