A fixed skeleton with replaceable steps
A cake recipe card at a bakery says: mix the base, add the flavor, bake for 30 minutes, let it cool, add the topping. The steps and their order never change; each cake fills in only the two blanks. A new baker can’t forget to let the cake cool, because cooling isn’t their job; it’s on the card.
In code, the card is a method in a base class (the template method) that calls the steps in order. The blanks are methods the subclass must provide. The subclass doesn’t decide the order, and it can’t skip a step.
Template Method defines an algorithm’s skeleton in a base class operation, deferring some steps to subclasses via primitive operations (abstract, must be implemented) and hooks (overridable with a default, often empty). The base class calls the subclass (“Hollywood principle”: don’t call us, we’ll call you). It’s inheritance-based inversion of control, so it’s well suited when the variants share real behavior and invariants and are few, and poorly suited when variants need to be combined or swapped at runtime (that’s Strategy). The template itself must not be overridable: final in Java and Swift classes, non-open in Kotlin; TypeScript has no final, so conventions or composition must stand in.
struct ContactRow { let name: String; let email: String }
struct ImportReport { let imported: Int; let rejected: Int }
class ContactImporter {
private let store: ContactStore
init(store: ContactStore) { self.store = store }
// The template method: final, so subclasses can't change the steps or their order
final func run(_ data: Data) throws -> ImportReport {
let rows = try parse(data) // varies: each format
let valid = rows.filter(isValid) // fixed, with a hook below
try store.saveAll(valid) // fixed: one batch, one transaction
didImport(valid) // hook: optional
return ImportReport(imported: valid.count, rejected: rows.count - valid.count)
}
// Must be overridden (Swift has no abstract methods)
func parse(_ data: Data) throws -> [ContactRow] { fatalError("Subclasses implement parse(_:)") }
// Hook with a default
func didImport(_ rows: [ContactRow]) {}
private func isValid(_ row: ContactRow) -> Bool {
!row.name.isEmpty && row.email.contains("@")
}
}
final class CsvContactImporter: ContactImporter {
override func parse(_ data: Data) throws -> [ContactRow] {
String(decoding: data, as: UTF8.self)
.split(separator: "\n").dropFirst() // skip the header line
.map { $0.split(separator: ",", omittingEmptySubsequences: false).map(String.init) }
.map { ContactRow(name: $0[0], email: $0[1]) }
}
}data class ContactRow(val name: String, val email: String)
data class ImportReport(val imported: Int, val rejected: Int)
abstract class ContactImporter(private val store: ContactStore) {
// The template method: not open, so subclasses can't change the steps or their order
fun run(data: ByteArray): ImportReport {
val rows = parse(data) // varies: each format
val valid = rows.filter(::isValid) // fixed
store.saveAll(valid) // fixed: one batch, one transaction
didImport(valid) // hook: optional
return ImportReport(imported = valid.size, rejected = rows.size - valid.size)
}
protected abstract fun parse(data: ByteArray): List<ContactRow>
protected open fun didImport(rows: List<ContactRow>) {}
private fun isValid(row: ContactRow) = row.name.isNotBlank() && "@" in row.email
}
class CsvContactImporter(store: ContactStore) : ContactImporter(store) {
override fun parse(data: ByteArray): List<ContactRow> =
data.decodeToString().lines().drop(1).filter { it.isNotBlank() }
.map { it.split(",") }
.map { ContactRow(name = it[0], email = it[1]) }
}public record ContactRow(String name, String email) {}
public record ImportReport(int imported, int rejected) {}
public abstract class ContactImporter {
private final ContactStore store;
protected ContactImporter(ContactStore store) { this.store = store; }
// The template method: final, so subclasses can't change the steps or their order
public final ImportReport run(byte[] data) {
List<ContactRow> rows = parse(data); // varies: each format
List<ContactRow> valid = rows.stream().filter(this::isValid).toList(); // fixed
store.saveAll(valid); // fixed: one batch
didImport(valid); // hook: optional
return new ImportReport(valid.size(), rows.size() - valid.size());
}
protected abstract List<ContactRow> parse(byte[] data);
protected void didImport(List<ContactRow> rows) {}
private boolean isValid(ContactRow row) {
return !row.name().isBlank() && row.email().contains("@");
}
}
public final class CsvContactImporter extends ContactImporter {
public CsvContactImporter(ContactStore store) { super(store); }
@Override
protected List<ContactRow> parse(byte[] data) {
return new String(data, StandardCharsets.UTF_8).lines().skip(1)
.filter(line -> !line.isBlank())
.map(line -> line.split(",", -1))
.map(cols -> new ContactRow(cols[0], cols[1]))
.toList();
}
}interface ContactRow { name: string; email: string }
interface ImportReport { imported: number; rejected: number }
abstract class ContactImporter {
constructor(private readonly store: ContactStore) {}
// The template method. TypeScript has no "final": don't override run() in subclasses
async run(data: string): Promise<ImportReport> {
const rows = this.parse(data); // varies: each format
const valid = rows.filter((r) => this.isValid(r)); // fixed
await this.store.saveAll(valid); // fixed: one batch
this.didImport(valid); // hook: optional
return { imported: valid.length, rejected: rows.length - valid.length };
}
protected abstract parse(data: string): ContactRow[];
protected didImport(_rows: ContactRow[]): void {}
private isValid(row: ContactRow): boolean {
return row.name.trim() !== "" && row.email.includes("@");
}
}
class CsvContactImporter extends ContactImporter {
protected parse(data: string): ContactRow[] {
return data.split("\n").slice(1).filter((l) => l.trim() !== "")
.map((l) => l.split(","))
.map(([name, email]) => ({ name, email }));
}
}runis the template: parse, validate, save, notify, report. It’sfinal(or notopenin Kotlin), so no importer can reorder or skip the steps.parseis the only thing each format must write. A JSON or XML importer is a few lines.didImportis a hook: an optional step with a default that does nothing. An importer that wants to send a “done” notification overrides it; the others ignore it.- Validation and saving are written once, so every format checks emails and saves in one batch. A fix there fixes all importers.
Tap the line with the bug.
class ContactImporter {
func run(_ data: Data) throws -> ImportReport {
let rows = try parse(data)
let valid = rows.filter(isValid)
try store.saveAll(valid)
return ImportReport(imported: valid.count, rejected: rows.count - valid.count)
}
}
final class PartnerXmlImporter: ContactImporter {
override func run(_ data: Data) throws -> ImportReport {
let rows = try XmlContacts.decode(data)
try store.saveAll(rows)
return ImportReport(imported: rows.count, rejected: 0)
}
}abstract class ContactImporter(protected val store: ContactStore) {
open fun run(data: ByteArray): ImportReport {
val rows = parse(data)
val valid = rows.filter(::isValid)
store.saveAll(valid)
return ImportReport(valid.size, rows.size - valid.size)
}
}
class PartnerXmlImporter(store: ContactStore) : ContactImporter(store) {
override fun run(data: ByteArray): ImportReport {
val rows = XmlContacts.decode(data)
store.saveAll(rows)
return ImportReport(rows.size, 0)
}
}public abstract class ContactImporter {
public ImportReport run(byte[] data) {
List<ContactRow> rows = parse(data);
List<ContactRow> valid = rows.stream().filter(this::isValid).toList();
store.saveAll(valid);
return new ImportReport(valid.size(), rows.size() - valid.size());
}
}
public final class PartnerXmlImporter extends ContactImporter {
@Override public ImportReport run(byte[] data) {
List<ContactRow> rows = XmlContacts.decode(data);
store.saveAll(rows);
return new ImportReport(rows.size(), 0);
}
}abstract class ContactImporter {
async run(data: string): Promise<ImportReport> {
const rows = this.parse(data);
const valid = rows.filter((r) => this.isValid(r));
await this.store.saveAll(valid);
return { imported: valid.length, rejected: rows.length - valid.length };
}
}
class PartnerXmlImporter extends ContactImporter {
async run(data: string): Promise<ImportReport> {
const rows = XmlContacts.decode(data);
await this.store.saveAll(rows);
return { imported: rows.length, rejected: 0 };
}
}Hint: Which method did the XML importer override, and which steps of the algorithm did that throw away?
The template method itself is overridable, and the XML importer replaced all of it instead of implementing parse. Its version skips validation, so contacts without an email reach the database, and it reports zero rejected rows. Every future change to run (deduplication, a progress hook, a size limit) silently skips this importer too.
The whole point of the pattern is that subclasses fill in steps and can’t change the skeleton. Lock the template, and make the varying step the only thing to implement:
final func run(_ data: Data) throws -> ImportReport { /* … */ }
final class PartnerXmlImporter: ContactImporter {
override func parse(_ data: Data) throws -> [ContactRow] { try XmlContacts.decode(data) }
}fun run(data: ByteArray): ImportReport { /* … */ } // not open: final by default in Kotlin
class PartnerXmlImporter(store: ContactStore) : ContactImporter(store) {
override fun parse(data: ByteArray) = XmlContacts.decode(data)
}public final ImportReport run(byte[] data) { /* … */ }
public final class PartnerXmlImporter extends ContactImporter {
@Override protected List<ContactRow> parse(byte[] data) { return XmlContacts.decode(data); }
}// No "final" in TypeScript: keep run() out of reach by composing instead of extending,
// or enforce it with a lint rule. Subclasses implement parse() only.
class PartnerXmlImporter extends ContactImporter {
protected parse(data: string): ContactRow[] { return XmlContacts.decode(data); }
}Predict the output
JsonContactImporter overrides only parse and the didImport hook (to log 'done'). A file has 10 rows, 2 without an email. In which order do things happen when run is called, and what's the report?
The base class's run decides the order, whatever the subclass overrides: parse (the JSON version), validate (the shared check drops the 2 rows without an email), save the 8 valid rows, then the didImport hook logs 'done'. The report says 8 imported, 2 rejected. The subclass only filled in two blanks; the recipe stayed the same.
- Base-class skeleton (the template method, non-overridable) calls primitive operations (abstract) and hooks (default no-op).
- Inversion of control via inheritance: the base calls the subclass.
- Lock the template:
final(Java, Swift classes), non-open(Kotlin); TypeScript lacksfinal, so prefer composition or lint rules. - Good for a few variants sharing real invariants; poor for runtime swapping or combining variants.
Check this part
In Template Method, who decides the order of the steps?
What is a hook?
Why should the template method be final?