Design Patterns, Explained Simply · Behavioral Patterns: How Objects Share Work
Template Method
Write the skeleton of an algorithm once in a base class and let subclasses fill in specific steps, so every variant follows the same order of steps.
The problem
A data platform imports customer files: CSV from one partner, JSON from another, and fixed-width text from a bank. Each importer was written separately. All of them read the file, parse rows, validate them, save them and write an audit line, but one importer forgot validation and another saves before validating. Bugs come from the steps being in the wrong order, not from the parsing itself.
Real-world analogy
A franchise restaurant's recipe card: "1. Prepare the base. 2. Add toppings. 3. Bake for 12 minutes. 4. Slice and box." Head office fixes the steps and their order. Each restaurant can choose its own toppings (the varying step), but nobody skips baking.
Without the pattern
def import_csv(text):
rows = parse_csv(text)
save(rows) # oops: saved before validating
validate(rows)
def import_json(text):
rows = parse_json(text) # forgot to validate at all
save(rows)
The pattern
- Abstract class with a template method (
run()) that calls the steps in a fixed order. The template method itself is not meant to be overridden (finalin Java). - Abstract steps that subclasses must implement (
parse()). - Hooks: optional steps with a default implementation that subclasses may override (
clean()that does nothing by default). - Concrete classes (
CsvImporter,JsonImporter): implement the varying steps only.
Template Method uses inheritance to vary steps. Strategy uses composition to vary a whole algorithm. Modern code often prefers Strategy, but Template Method is everywhere in frameworks.
The code
class Importer:
def run(self, text): # the template method: fixed order of steps
rows = self.parse(text)
rows = self.clean(rows)
valid = [r for r in rows if r["email"] and "@" in r["email"]]
print(f"{type(self).__name__}: {len(valid)} saved, {len(rows) - len(valid)} rejected")
def parse(self, text): # abstract step: subclasses must implement it
raise NotImplementedError
def clean(self, rows): # hook: optional, does nothing by default
return rows
class CsvImporter(Importer):
def parse(self, text):
rows = []
for line in text.strip().split("\n")[1:]: # skip the header
name, email = line.split(",")
rows.append({"name": name, "email": email})
return rows
class LegacyBankImporter(Importer):
def parse(self, text): # fixed-width: name in 10 chars, then email
return [{"name": line[:10], "email": line[10:]} for line in text.strip().split("\n")]
def clean(self, rows): # this source pads fields with spaces
return [{k: v.strip() for k, v in r.items()} for r in rows]
CsvImporter().run("name,email\nAda,[email protected]\nBob,\nCy,[email protected]")
LegacyBankImporter().run("Dee [email protected] \nEve not-an-email ")
Output
CsvImporter: 2 saved, 1 rejected LegacyBankImporter: 1 saved, 1 rejected
type Row = { name: string; email: string };
abstract class Importer {
run(text: string): void { // the template method: fixed order of steps
const rows = this.clean(this.parse(text));
const valid = rows.filter((r) => r.email && r.email.includes("@"));
console.log(`${this.constructor.name}: ${valid.length} saved, ${rows.length - valid.length} rejected`);
}
protected abstract parse(text: string): Row[]; // abstract step: subclasses must implement it
protected clean(rows: Row[]): Row[] { return rows; } // hook: optional, does nothing by default
}
class CsvImporter extends Importer {
protected parse(text: string): Row[] {
return text.trim().split("\n").slice(1).map((line) => { // skip the header
const [name, email] = line.split(",");
return { name, email };
});
}
}
class LegacyBankImporter extends Importer {
protected parse(text: string): Row[] { // fixed-width: name in 10 chars, then email
return text.trim().split("\n").map((line) => ({ name: line.slice(0, 10), email: line.slice(10) }));
}
protected clean(rows: Row[]): Row[] { // this source pads fields with spaces
return rows.map((r) => ({ name: r.name.trim(), email: r.email.trim() }));
}
}
new CsvImporter().run("name,email\nAda,[email protected]\nBob,\nCy,[email protected]");
new LegacyBankImporter().run("Dee [email protected] \nEve not-an-email ");
Output
CsvImporter: 2 saved, 1 rejected LegacyBankImporter: 1 saved, 1 rejected
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args) {
new CsvImporter().run("name,email\nAda,[email protected]\nBob,\nCy,[email protected]");
new LegacyBankImporter().run("Dee [email protected] \nEve not-an-email ");
}
}
record Row(String name, String email) {}
abstract class Importer {
final void run(String text) { // the template method: final, so the order can't change
List<Row> rows = clean(parse(text));
long valid = rows.stream().filter(r -> r.email().contains("@")).count();
System.out.println(getClass().getSimpleName() + ": " + valid + " saved, " + (rows.size() - valid) + " rejected");
}
protected abstract List<Row> parse(String text); // abstract step: subclasses must implement it
protected List<Row> clean(List<Row> rows) { return rows; } // hook: optional, does nothing by default
}
class CsvImporter extends Importer {
protected List<Row> parse(String text) {
return Arrays.stream(text.strip().split("\n")).skip(1).map(line -> { // skip the header
String[] parts = line.split(",", -1);
return new Row(parts[0], parts[1]);
}).toList();
}
}
class LegacyBankImporter extends Importer {
protected List<Row> parse(String text) { // fixed-width: name in 10 chars, then email
return Arrays.stream(text.strip().split("\n")).map(l -> new Row(l.substring(0, 10), l.substring(10))).toList();
}
protected List<Row> clean(List<Row> rows) { // this source pads fields with spaces
return rows.stream().map(r -> new Row(r.name().strip(), r.email().strip())).toList();
}
}
Output
CsvImporter: 2 saved, 1 rejected LegacyBankImporter: 1 saved, 1 rejected
Run this on your computer — see “Get Started” for setup.
<?php
abstract class Importer {
final public function run(string $text): void { // the template method: final, so the order can't change
$rows = $this->clean($this->parse($text));
$valid = array_filter($rows, fn($r) => $r["email"] !== "" && str_contains($r["email"], "@"));
printf("%s: %d saved, %d rejected\n", static::class, count($valid), count($rows) - count($valid));
}
abstract protected function parse(string $text): array; // abstract step: subclasses must implement it
protected function clean(array $rows): array { return $rows; } // hook: optional, does nothing by default
}
class CsvImporter extends Importer {
protected function parse(string $text): array {
$lines = array_slice(explode("\n", trim($text)), 1); // skip the header
return array_map(function ($line) {
[$name, $email] = explode(",", $line);
return ["name" => $name, "email" => $email];
}, $lines);
}
}
class LegacyBankImporter extends Importer {
protected function parse(string $text): array { // fixed-width: name in 10 chars, then email
return array_map(fn($l) => ["name" => substr($l, 0, 10), "email" => substr($l, 10)], explode("\n", trim($text)));
}
protected function clean(array $rows): array { // this source pads fields with spaces
return array_map(fn($r) => array_map("trim", $r), $rows);
}
}
(new CsvImporter())->run("name,email\nAda,[email protected]\nBob,\nCy,[email protected]");
(new LegacyBankImporter())->run("Dee [email protected] \nEve not-an-email ");
Output
CsvImporter: 2 saved, 1 rejected LegacyBankImporter: 1 saved, 1 rejected
Both importers validate before saving because neither one controls the order; the base class does. The bank importer used the optional clean() hook; the CSV importer didn't need it.
When to use it
- Several classes run the same steps in the same order and differ only in some steps.
- You are writing a framework or base class and want users to customise specific steps without breaking the overall flow.
- You want to enforce invariants like "always validate before saving".
When not to use it
- Variants differ in many steps. Subclasses become hard to follow when half the logic is in the base class and half in the subclass.
- You need to combine variations (CSV parsing + bank cleaning). Inheritance can't mix them; Strategy (passing a parser and a cleaner) can.
- Deep inheritance chains of template methods ("yo-yo problem": reading jumps up and down the hierarchy).
You've already used it
- Test frameworks:
setUp()/tearDown()in unittest, JUnit and PHPUnit are hooks around a fixed test-running skeleton. - Web frameworks: Django's class-based views (
get_queryset(),get_context_data()), React class components' lifecycle methods, Laravel'sFormRequest(rules(),authorize()). - Java:
AbstractListimplements most list methods on top of the abstractget()andsize().
Key points
- Template Method fixes an algorithm's skeleton in a base class; subclasses fill in steps.
- Abstract steps must be implemented; hooks are optional overrides.
- It enforces step order, but relies on inheritance. Prefer Strategy when you need to mix variations.
- Further reading: Refactoring Guru: Template Method.
Practice questions
What is the difference between an abstract step and a hook in Template Method?
Answer
An abstract step must be implemented by every subclass (parse()). A hook has a default (often empty) implementation in the base class, and subclasses may override it if they need to (clean(), setUp()).
How does Template Method differ from Strategy?
Answer
Template Method uses inheritance: the base class fixes the algorithm and subclasses override steps. Strategy uses composition: the whole algorithm (or a step) is a separate object passed in. Strategy is more flexible for combining variations; Template Method is convenient for frameworks.
Give an example of Template Method in a testing framework.
Answer
xUnit frameworks (unittest, JUnit, PHPUnit) run each test as a fixed skeleton: setUp(), the test method, then tearDown(). You override the hooks; the framework controls the order.
Open in the app to mark lessons complete, take notes and track your progress.