Template Method
A base class fixes the steps and subclasses fill in two of them. It works, and passing the two steps in as functions does the same job without a hierarchy.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
Four import jobs share the same seven steps and differ in two. Should the shared part be a base class?
Nightly imports from four partners: fetch a file, decrypt it, parse it, validate rows, upsert, report, and archive. Fetching differs per partner, parsing differs per partner, and everything else is identical and must stay identical.
Copy the job for each partner. Seven steps, four files, and each partner's job is readable start to finish with nothing hidden.
The five shared steps are now in four places. The retry policy is changed in three of them and the fourth silently keeps the old one (Shotgun Surgery).
- The five shared steps are now in four places. The retry policy is changed in three of them and the fourth silently keeps the old one (Shotgun Surgery).
- The audit format changes for compliance and one job is missed. Nobody notices until an auditor does.
- The copies drift for good local reasons, so after a year they are no longer four instances of one thing — and consolidating them is now a risky refactor rather than an edit (Duplicate Knowledge).
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The shared steps encode operational requirements — retry policy, audit entry, archive location — and must not drift per partner (Duplicate Knowledge).
- A fifth partner is expected, and partners occasionally change their format with two weeks' notice.
- One partner needs an extra step: their file must be de-duplicated before upsert.
- Every import writes exactly one audit record and archives exactly one file, whatever happens in the partner-specific steps (Idempotency by Design).
- A failed parse never partially upserts: validation completes before any write (Where the Transaction Boundary Goes).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- One place owns the sequence and the operational steps. That is the knowledge worth sharing.
- Each partner owns exactly two things: how to fetch and how to parse. Nothing else.
- Nobody owns "a place to put things that most partners need", which is what the base class becomes if allowed (The Utility Dumping Ground).
- The extension points are the boundary and they should be named, few and explicit —
fetchandparse, not "override whatever you need" (Designing a Module Interface). - A base class makes the boundary implicit: every protected member and every overridable method is an extension point whether you meant it or not (Exposing Too Much).
- Passing functions makes the boundary the signature, which is visible at the call site and cannot be widened accidentally (Interface Versus Implementation).
The sequence is the thing worth sharing
The requirement contains one genuine finding: five of the seven steps encode operational rules that must not drift, and two are partner-specific. Everything else about this design follows from that split, and neither form of the pattern changes it.
Written as a function, the shape is visible at the call site: a reader sees exactly which two things vary and knows the other five are fixed for everyone (Local Reasoning).
1type ImportSteps = {2 fetch: () => Promise<EncryptedFile>3 parse: (rows: RawRow[]) => Result<Row[], ParseError>4 dedupe?: (rows: Row[]) => Row[] // partner five only; default: identity5}6 7export async function runImport(partner: PartnerId, steps: ImportSteps) {8 const file = await withRetry(steps.fetch) // shared: retry policy9 const raw = await decryptAndSplit(file) // shared10 const parsed = steps.parse(raw) // varies11 if (!parsed.ok) return audit.failed(partner, parsed.error) // shared12 const rows = (steps.dedupe ?? ((r) => r))(parsed.value) // optional step13 await upsertAll(rows) // shared14 await archive(file, partner) // shared15 return audit.succeeded(partner, rows.length) // shared16}17 18// partners/acme.ts19export const acme = { fetch: sftpFetch(acmeConfig), parse: parseAcmeCsv }Adding partner five is one object literal. The optional dedupe is how the extra step is absorbed without a second level of hierarchy — a default that does nothing, stated explicitly.
The same design as a base class
The inheritance version is not wrong and it is what most codebases contain. It has one real advantage — a new partner's author is told by the compiler exactly which methods to implement — and one real cost, which is that every protected member becomes an extension point forever.
The decisive difference is the second-level subclass. When partner five needs an extra step, the parameter form takes an optional argument and the hierarchy form grows a level, and levels are where the fragile-base-class problems live (When Inheritance Fits).
abstract class ImportJob {
protected abstract fetch(): Promise<EncryptedFile>
protected abstract parse(rows: RawRow[]): Result<Row[], ParseError>
protected beforeUpsert(rows: Row[]) { return rows } // hook #1
protected shouldArchive() { return true } // hook #2
async run() { /* the seven steps, calling the above */ }
}
class AcmeImport extends ImportJob { /* two overrides */ }
class DedupeImport extends AcmeImport { /* second level */ }
// run() is not final: a subclass can replace the sequence entirely.runImport('acme', { fetch: sftpFetch(cfg), parse: parseAcmeCsv })
runImport('globex', { fetch: httpFetch(cfg), parse: parseXml, dedupe })
// Extension points are exactly the fields of ImportSteps.
// There is no second level, no protected surface, and the
// sequence cannot be overridden because it is not a method.Both put the five shared steps in one place, which is the change that actually pays. The difference is what else they permit: the base class exposes every protected member as an extension point, allows run() itself to be replaced — losing the invariant the design existed to protect — and invites a second level of subclass the moment one partner differs slightly. The parameter version cannot be extended except through the fields you declared, and a reader of the call site sees the entire variation on one line (Exposing Too Much).
Watch the base class accumulate reasons to change
The failure mode is gradual and each step is reasonable. This profile is what a template base class looks like after four partners and two years, and the finding is in changesWhen — the count, not any individual entry.
- — The seven-step sequence
- — The retry policy
- — The audit format
- — The archive layout
- — Which partners need de-duplication
- — That one partner's files arrive gzipped
- — Runs the sequence
- — Retries fetching
- — Writes audit records
- — Archives files
- — Provides four hooks
- — Branches on partner id in two of them
- — SFTP client
- — HTTP client
- — Decryptor
- — Database
- — Audit log
- — Object storage
- — PartnerConfig
- — The sequence changes
- — Retry policy changes
- — Audit format changes
- — Archive layout changes
- — A partner needs a new hook
- — A partner-specific branch is added to a shared step
Six reasons to change, and the last two are the diagnosis: partner-specific knowledge has migrated into the shared base, which is exactly what the base existed to prevent. The dependsOn list is the corroboration — every subclass now inherits an SFTP client it may not use. The fix is not more hooks; it is to move each partner-specific branch back out into that partner's steps, and to convert the hooks into declared parameters so that adding one is a visible signature change rather than a protected method nobody reviews (Divergent Change).
How to build it
Most important first.
- Write the sequence once as a function that takes the varying steps as parameters. That is Template Method with the inheritance removed, and it is the version to reach for first (Strategy).
- Name the steps in the signature so a reader of the call site sees the whole shape:
runImport({ fetch, parse }). - If the varying steps need shared state between them — a session, a cursor — group them in one object that the caller supplies. That object is a strategy, not a subclass (Composition Over Inheritance).
- Use the inheritance form where the language pushes you there: a framework base class you must extend, or a language without convenient function values (What a Framework Charges).
- Where you do use a base class, make the template method final and the hooks abstract, so the extension points are exactly the ones you intended (When Inheritance Fits).
- Handle the fifth partner's extra step by making it an optional step in the shared sequence — an explicit no-op default — rather than by subclassing the subclass (Optional Values and Absence).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Named change — "the archive location changes": one edit in the shared sequence under either form, four edits under copy-paste. This is the saving, and both the base class and the function version deliver it identically.
- Named change — "partner five, new format": one module with two functions under the parameter form; one subclass under the inheritance form. Also identical.
- Named change — "one partner needs a de-duplication step": the function version takes an optional step and stays flat; the inheritance version tempts a second level of subclass, and the second level is where hierarchies start costing.
- Named change — "a shared step needs a new dependency": the function version passes it as an argument; the base class version adds it to the base constructor, which every subclass must now supply. Inheritance couples the hooks to the base's dependency list.
- The honest summary: Template Method as a *pattern* makes nothing cheaper than passing functions does. What makes things cheaper is having the sequence in one place, and the pattern is one of two equally-good ways to get that.
- Fixing the sequence buys consistency and gives up local flexibility: the partner whose file needs a different order simply cannot be expressed.
- The function form has a longer signature and moves construction to the caller, which is more visible and slightly noisier.
- The inheritance form reads more naturally to engineers used to frameworks, and that familiarity is a real, if unglamorous, benefit on a mixed team.
What can go wrong
- Hook proliferation:
beforeParse,afterValidate,shouldArchiveaccumulate until the base class is a plugin host and the sequence is no longer readable (Speculative Generality). - The fragile base class: a change to a shared step breaks one subclass because it had overridden a method the base calls internally. The base's own tests pass (When Inheritance Fits).
- A subclass overrides the template method itself, so the sequence is no longer guaranteed and the invariant this design existed to protect is gone (Invariant Leaks).
- The mitigation fails too: making everything final and abstract produces a base so rigid that the fifth partner cannot be expressed in it, and someone copies the whole job rather than fighting it.
- With functions, the shared sequence depends on two signatures and nothing else; each partner module depends on the shared runner. The direction is clean and the coupling is countable.
- With inheritance, each subclass depends on the base's implementation — including which of its own methods the base calls and in what order — which is the coupling that produces fragile-base-class failures.
- The base also acquires every dependency any hook needs, unless hooks receive theirs as arguments, which is the discipline most base classes lose first (Volatile Dependencies).
- "Template Method is the inheritance one, so it is bad." It is a fine design when the language or framework makes inheritance the natural mechanism. The argument is that it has no advantage over parameters, not that it is harmful (Composition Over Inheritance).
- "Hooks make it flexible." Hooks make the base class a framework, with a framework's obligation to keep its extension points stable forever (API Stability).
- "The base class guarantees the sequence." Only if the template method cannot be overridden. In most languages that requires an explicit
final, and most codebases omit it (Enforcing Invariants). - "Four copies were fine." They were, until the fifth compliance change hit three of them. The problem is not duplication of lines, it is duplication of an operational rule that must move together (DRY: Knowledge, Not Lines).
- duplicate-knowledge
- divergent-change
Testing it, and how it ages
- Test the shared sequence once with stub steps, asserting order, audit and archive — that is where the invariant lives (What a Unit Is).
- Test each partner's fetch and parse in isolation, with no sequence involved.
- Where inheritance is used, run the same sequence-level test against every subclass, since the base cannot guarantee a subclass has not overridden something (Contract Tests).
- A test that the optional step defaults to a no-op, because a default that quietly does something is how partner five's behaviour reaches partner two.
- Template hierarchies grow hooks in proportion to the number of subclasses and never lose them, so the base class is a good early-warning indicator: count the hooks each year (Divergent Change).
- The usual endgame is that two subclasses need incompatible sequences, at which point the shared base is holding two designs and should become two functions sharing helpers (Extract Function).
- A base class that has been stable for years with two abstract methods and no hooks is fine and should be left alone; the pattern's bad reputation comes from the ones that grew (When Inheritance Fits).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- PARADIGM-SPECIFICWith first-class functions the pattern is a function taking two functions, and the inheritance is pure overhead. In languages without them — pre-8 Java, older C++ — the subclass *was* the only way to pass behaviour, which is precisely why the catalogue contains this entry (Patterns as Vocabulary).
- FRAMEWORK-SPECIFICMany frameworks require the inheritance form: extend
TestCase,Activity,Job,Command. Inside those, the pattern is not a choice and the useful discipline is to keep your overrides thin and delegate immediately to code that has no framework in it, so the logic stays testable without the framework (What a Framework Charges). - CONTESTEDThe strongest defence of the inheritance form: a base class documents the extension points in one place, gives every subclass the same shape, and lets a reader of any subclass know exactly what the missing pieces are — a discoverability property that a function with two callbacks does not have, especially for engineers joining a large codebase. The counter is that the same discoverability comes from a named parameter object at a fraction of the coupling.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — why this pattern exists at all: it is the shape behaviour parameterisation takes in a language without function values, and every language that added closures made it redundant without removing it from the books.