Episode 9 — System Design / 9.5 — Behavioral Design Patterns
9.5.f Template Method Pattern
Overview
The Template Method Pattern defines the skeleton of an algorithm in a base class, letting subclasses override specific steps without changing the algorithm's structure. The base class controls the flow; subclasses fill in the details.
Think of it like a recipe template: the steps are always the same (prep, cook, plate, serve), but each dish implements those steps differently.
+--------------------------------------------------------------+
| TEMPLATE METHOD PATTERN |
| |
| AbstractClass |
| +---------------------------+ |
| | | |
| | templateMethod() { | <-- Fixed skeleton |
| | step1(); | (final -- don't override)|
| | step2(); | |
| | if (hook()) { | <-- Optional hook |
| | step3(); | |
| | } | |
| | step4(); | |
| | } | |
| | | |
| | step1() { ... } | <-- Default implementation |
| | step2() { abstract } | <-- MUST override |
| | step3() { abstract } | <-- MUST override |
| | step4() { ... } | <-- Default (CAN override) |
| | hook() { return true; } | <-- Hook (CAN override) |
| +---------------------------+ |
| ^ ^ |
| | | |
| +--------+--+ +-----+------+ |
| | ConcreteA | | ConcreteB | |
| | step2() {} | | step2() {} | |
| | step3() {} | | step3() {} | |
| +------------+ +------------+ |
+--------------------------------------------------------------+
The Problem: Duplicated Algorithm Structure
// BAD: Similar algorithms with duplicated structure
class CSVReportGenerator {
generate(data) {
// Step 1: Validate (same for all)
if (!data || data.length === 0) throw new Error('No data');
// Step 2: Header (format-specific)
let output = Object.keys(data[0]).join(',') + '\n';
// Step 3: Body (format-specific)
for (const row of data) {
output += Object.values(row).join(',') + '\n';
}
// Step 4: Footer (same for all)
output += `\nGenerated: ${new Date().toISOString()}\nRows: ${data.length}`;
return output;
}
}
class HTMLReportGenerator {
generate(data) {
// Step 1: Validate (SAME as above -- duplicated!)
if (!data || data.length === 0) throw new Error('No data');
// Step 2: Header (different format)
let output = '<table><thead><tr>';
Object.keys(data[0]).forEach(k => output += `<th>${k}</th>`);
output += '</tr></thead><tbody>';
// Step 3: Body (different format)
for (const row of data) {
output += '<tr>';
Object.values(row).forEach(v => output += `<td>${v}</td>`);
output += '</tr>';
}
// Step 4: Footer (SAME structure -- duplicated!)
output += '</tbody></table>';
output += `<footer>Generated: ${new Date().toISOString()} | Rows: ${data.length}</footer>`;
return output;
}
}
// Lots of duplicated logic! Steps 1 and 4 are identical.
Implementation 1: Data Processing Pipeline
// ============================================
// ABSTRACT CLASS: Data Processor Template
// ============================================
class DataProcessor {
/**
* TEMPLATE METHOD -- defines the algorithm skeleton.
* Subclasses should NOT override this method.
*/
process(rawInput) {
console.log(`\n=== ${this.getName()} Processing Pipeline ===`);
// Step 1: Validate input
this.validate(rawInput);
console.log('[Step 1] Validation passed');
// Step 2: Parse raw input into structured data
const parsed = this.parse(rawInput);
console.log(`[Step 2] Parsed: ${parsed.length} records`);
// Step 3: Hook -- should we filter?
let data = parsed;
if (this.shouldFilter()) {
data = this.filter(parsed);
console.log(`[Step 3] Filtered: ${data.length} records remaining`);
} else {
console.log('[Step 3] Filtering skipped (hook returned false)');
}
// Step 4: Transform the data
const transformed = this.transform(data);
console.log(`[Step 4] Transformed: ${transformed.length} records`);
// Step 5: Hook -- should we sort?
let result = transformed;
if (this.shouldSort()) {
result = this.sort(transformed);
console.log('[Step 5] Sorted');
} else {
console.log('[Step 5] Sorting skipped (hook returned false)');
}
// Step 6: Format output
const output = this.format(result);
console.log(`[Step 6] Formatted output (${output.length} chars)`);
// Step 7: Post-processing hook
this.onComplete(output, result.length);
return output;
}
// ----- ABSTRACT METHODS (must override) -----
getName() {
throw new Error('Subclass must implement getName()');
}
parse(rawInput) {
throw new Error('Subclass must implement parse()');
}
transform(data) {
throw new Error('Subclass must implement transform()');
}
format(data) {
throw new Error('Subclass must implement format()');
}
// ----- DEFAULT METHODS (can override) -----
validate(input) {
if (!input) {
throw new Error('Input cannot be null or undefined');
}
if (typeof input === 'string' && input.trim().length === 0) {
throw new Error('Input cannot be empty');
}
}
filter(data) {
return data; // Default: no filtering
}
sort(data) {
return [...data]; // Default: no sorting
}
// ----- HOOK METHODS (optional override) -----
shouldFilter() {
return true; // Override to skip filtering
}
shouldSort() {
return true; // Override to skip sorting
}
onComplete(output, recordCount) {
// Default: do nothing. Override for logging, metrics, etc.
}
}
// ============================================
// CONCRETE: CSV Data Processor
// ============================================
class CSVProcessor extends DataProcessor {
getName() { return 'CSV'; }
parse(rawInput) {
const lines = rawInput.trim().split('\n');
const headers = lines[0].split(',').map(h => h.trim());
return lines.slice(1).map(line => {
const values = line.split(',').map(v => v.trim());
const record = {};
headers.forEach((h, i) => record[h] = values[i]);
return record;
});
}
transform(data) {
return data.map(record => ({
...record,
// Convert numeric fields
age: record.age ? parseInt(record.age) : null,
salary: record.salary ? parseFloat(record.salary) : null,
// Add computed field
seniorLevel: record.age && parseInt(record.age) > 30
}));
}
filter(data) {
return data.filter(record => record.name && record.name.length > 0);
}
sort(data) {
return [...data].sort((a, b) => (a.name || '').localeCompare(b.name || ''));
}
format(data) {
if (data.length === 0) return '';
const headers = Object.keys(data[0]);
const rows = data.map(record =>
headers.map(h => record[h] ?? '').join(',')
);
return [headers.join(','), ...rows].join('\n');
}
onComplete(output, count) {
console.log(`[CSV] Processed ${count} records successfully`);
}
}
// ============================================
// CONCRETE: JSON API Processor
// ============================================
class JSONAPIProcessor extends DataProcessor {
constructor(options = {}) {
super();
this.minAge = options.minAge || 0;
this.fields = options.fields || null;
}
getName() { return 'JSON API'; }
parse(rawInput) {
const parsed = typeof rawInput === 'string' ? JSON.parse(rawInput) : rawInput;
return Array.isArray(parsed) ? parsed : parsed.data || [parsed];
}
transform(data) {
return data.map(record => {
// Pick specific fields if configured
if (this.fields) {
const filtered = {};
this.fields.forEach(f => {
if (record[f] !== undefined) filtered[f] = record[f];
});
return filtered;
}
return { ...record };
});
}
filter(data) {
return data.filter(record =>
!record.age || record.age >= this.minAge
);
}
shouldSort() {
return false; // JSON API data comes pre-sorted
}
format(data) {
return JSON.stringify({
data,
meta: {
count: data.length,
processedAt: new Date().toISOString()
}
}, null, 2);
}
}
// ============================================
// CONCRETE: Log File Processor
// ============================================
class LogProcessor extends DataProcessor {
constructor(options = {}) {
super();
this.level = options.level || 'all'; // 'error', 'warn', 'info', 'all'
}
getName() { return 'Log File'; }
parse(rawInput) {
return rawInput.trim().split('\n').map(line => {
const match = line.match(/^\[(\w+)\]\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/);
if (match) {
return { level: match[1], date: match[2], message: match[3] };
}
return { level: 'UNKNOWN', date: '', message: line };
});
}
transform(data) {
return data.map(entry => ({
...entry,
level: entry.level.toUpperCase(),
severity: this._getSeverity(entry.level)
}));
}
filter(data) {
if (this.level === 'all') return data;
const minSeverity = this._getSeverity(this.level);
return data.filter(entry => entry.severity >= minSeverity);
}
sort(data) {
return [...data].sort((a, b) => b.severity - a.severity);
}
format(data) {
return data.map(entry =>
`[${entry.level}] (severity: ${entry.severity}) ${entry.date} - ${entry.message}`
).join('\n');
}
shouldFilter() {
return this.level !== 'all';
}
_getSeverity(level) {
const levels = { 'info': 1, 'warn': 2, 'error': 3, 'fatal': 4 };
return levels[level.toLowerCase()] || 0;
}
onComplete(output, count) {
console.log(`[Log] Processed ${count} log entries (filter: ${this.level})`);
}
}
// ============================================
// USAGE
// ============================================
const csvInput = `name,age,salary
Alice,30,85000
Bob,25,72000
,40,90000
Charlie,35,95000`;
const csvProcessor = new CSVProcessor();
console.log(csvProcessor.process(csvInput));
const jsonInput = JSON.stringify([
{ name: 'Alice', age: 30, role: 'Engineer' },
{ name: 'Bob', age: 25, role: 'Designer' },
{ name: 'Charlie', age: 35, role: 'Manager' }
]);
const apiProcessor = new JSONAPIProcessor({
minAge: 28,
fields: ['name', 'role']
});
console.log(apiProcessor.process(jsonInput));
const logInput = `[ERROR] 2025-01-15 Database connection failed
[INFO] 2025-01-15 Server started on port 3000
[WARN] 2025-01-15 Memory usage at 85%
[ERROR] 2025-01-15 API timeout after 30s
[INFO] 2025-01-15 Cache cleared successfully`;
const logProcessor = new LogProcessor({ level: 'warn' });
console.log(logProcessor.process(logInput));
Implementation 2: Report Generation
// ============================================
// ABSTRACT: Report Generator
// ============================================
class ReportGenerator {
/**
* Template method: fixed report generation flow
*/
generate(data, options = {}) {
const report = [];
// Step 1: Header
report.push(this.renderHeader(options.title || 'Report'));
// Step 2: Summary section (hook)
if (this.includeSummary()) {
report.push(this.renderSummary(data));
}
// Step 3: Body
report.push(this.renderBody(data));
// Step 4: Charts/Visualizations (hook)
if (this.includeCharts()) {
report.push(this.renderCharts(data));
}
// Step 5: Footer
report.push(this.renderFooter(data, options));
return report.join('\n');
}
// Abstract methods
renderHeader(title) { throw new Error('Must implement'); }
renderBody(data) { throw new Error('Must implement'); }
renderFooter(data, options) { throw new Error('Must implement'); }
// Default methods
renderSummary(data) {
return `Summary: ${data.length} records`;
}
renderCharts(data) {
return '[Charts placeholder]';
}
// Hook methods
includeSummary() { return true; }
includeCharts() { return false; }
}
// ============================================
// CONCRETE: Plain Text Report
// ============================================
class TextReport extends ReportGenerator {
renderHeader(title) {
const border = '='.repeat(title.length + 4);
return `${border}\n| ${title} |\n${border}`;
}
renderSummary(data) {
const total = data.reduce((sum, r) => sum + (r.amount || 0), 0);
return [
'--- Summary ---',
`Total Records: ${data.length}`,
`Total Amount: $${total.toFixed(2)}`,
`Average: $${(total / data.length).toFixed(2)}`,
'---------------'
].join('\n');
}
renderBody(data) {
const header = 'ID | Name | Amount';
const separator = '-'.repeat(header.length);
const rows = data.map(r =>
`${String(r.id).padEnd(9)}| ${(r.name || '').padEnd(16)}| $${(r.amount || 0).toFixed(2)}`
);
return [header, separator, ...rows].join('\n');
}
renderFooter(data, options) {
return `\nGenerated: ${new Date().toISOString()}\n${'='.repeat(40)}`;
}
}
// ============================================
// CONCRETE: HTML Report
// ============================================
class HTMLReport extends ReportGenerator {
renderHeader(title) {
return `<!DOCTYPE html>
<html><head><title>${title}</title>
<style>
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
th { background: #4CAF50; color: white; }
.summary { background: #f9f9f9; padding: 10px; margin: 10px 0; }
</style>
</head><body>
<h1>${title}</h1>`;
}
renderSummary(data) {
const total = data.reduce((sum, r) => sum + (r.amount || 0), 0);
return `<div class="summary">
<strong>Total Records:</strong> ${data.length} |
<strong>Total:</strong> $${total.toFixed(2)} |
<strong>Average:</strong> $${(total / data.length).toFixed(2)}
</div>`;
}
renderBody(data) {
const headers = Object.keys(data[0] || {});
const headerRow = headers.map(h => `<th>${h}</th>`).join('');
const bodyRows = data.map(r => {
const cells = headers.map(h => `<td>${r[h] ?? ''}</td>`).join('');
return `<tr>${cells}</tr>`;
}).join('\n');
return `<table>
<thead><tr>${headerRow}</tr></thead>
<tbody>${bodyRows}</tbody>
</table>`;
}
renderFooter(data, options) {
return `<footer><em>Generated: ${new Date().toISOString()}</em></footer>
</body></html>`;
}
includeCharts() { return true; }
renderCharts(data) {
// ASCII bar chart in HTML
const maxAmount = Math.max(...data.map(r => r.amount || 0));
const bars = data.map(r => {
const width = Math.round(((r.amount || 0) / maxAmount) * 200);
return `<div><span>${r.name}: </span><span style="display:inline-block;background:#4CAF50;width:${width}px;height:20px;"></span> $${r.amount}</div>`;
}).join('\n');
return `<h2>Distribution</h2>\n${bars}`;
}
}
// ============================================
// CONCRETE: Markdown Report
// ============================================
class MarkdownReport extends ReportGenerator {
renderHeader(title) {
return `# ${title}\n`;
}
renderSummary(data) {
const total = data.reduce((sum, r) => sum + (r.amount || 0), 0);
return `## Summary\n\n- **Total Records:** ${data.length}\n- **Total Amount:** $${total.toFixed(2)}\n- **Average:** $${(total / data.length).toFixed(2)}\n`;
}
renderBody(data) {
if (data.length === 0) return '*No data*\n';
const headers = Object.keys(data[0]);
const headerRow = '| ' + headers.join(' | ') + ' |';
const separator = '| ' + headers.map(() => '---').join(' | ') + ' |';
const rows = data.map(r =>
'| ' + headers.map(h => r[h] ?? '').join(' | ') + ' |'
);
return ['\n## Data\n', headerRow, separator, ...rows].join('\n');
}
renderFooter(data, options) {
return `\n---\n*Generated: ${new Date().toISOString()}*`;
}
includeSummary() { return true; }
includeCharts() { return false; }
}
// ============================================
// USAGE
// ============================================
const sampleData = [
{ id: 1, name: 'Widget A', amount: 150.00 },
{ id: 2, name: 'Widget B', amount: 275.50 },
{ id: 3, name: 'Widget C', amount: 89.99 },
{ id: 4, name: 'Widget D', amount: 420.00 },
];
// Same data, different formats -- algorithm structure is identical
const textReport = new TextReport();
console.log(textReport.generate(sampleData, { title: 'Sales Report' }));
const mdReport = new MarkdownReport();
console.log(mdReport.generate(sampleData, { title: 'Sales Report' }));
const htmlReport = new HTMLReport();
console.log(htmlReport.generate(sampleData, { title: 'Sales Report' }));
Hook Methods Explained
+------------------------------------------------------------------+
| HOOK METHODS |
| |
| Hooks are methods in the base class with a default (often empty) |
| implementation. Subclasses CAN override them, but don't HAVE to. |
| |
| Types of hooks: |
| |
| 1. Boolean hooks (control flow): |
| shouldFilter() { return true; } // Override to skip |
| includeCharts() { return false; } // Override to include |
| |
| 2. Lifecycle hooks (notifications): |
| onBeforeProcess() { } // Called before processing |
| onAfterProcess() { } // Called after processing |
| onError(error) { } // Called on error |
| |
| 3. Default behavior hooks (optional override): |
| validate(input) { /* basic check */ } |
| sort(data) { return data; } // No-op default |
| |
| The "Hollywood Principle": Don't call us, we'll call you. |
| Base class CALLS the hooks at the right time. |
+------------------------------------------------------------------+
// Example: Lifecycle hooks
class GameLoop {
// Template method
run() {
this.onInit();
while (!this.isGameOver()) {
this.onBeforeUpdate();
this.processInput();
this.update();
this.render();
this.onAfterUpdate();
}
this.onShutdown();
}
// Abstract -- MUST override
processInput() { throw new Error('Must implement'); }
update() { throw new Error('Must implement'); }
render() { throw new Error('Must implement'); }
isGameOver() { throw new Error('Must implement'); }
// Hooks -- CAN override
onInit() { console.log('Game initialized'); }
onBeforeUpdate() { /* nothing by default */ }
onAfterUpdate() { /* nothing by default */ }
onShutdown() { console.log('Game shut down'); }
}
Template Method vs Strategy
+------------------------------------------------------------------+
| |
| TEMPLATE METHOD STRATEGY |
| (Inheritance-based) (Composition-based) |
| |
| class Base { class Context { |
| template() { constructor(strategy) { |
| this.step1(); this.strategy = strategy;|
| this.step2(); // abstract } |
| this.step3(); execute(data) { |
| } return this.strategy |
| } .execute(data); |
| class Sub extends Base { } |
| step2() { /* impl */ } } |
| } |
| |
| - Fixed structure, variable steps - Completely swappable algo |
| - Compile-time (class hierarchy) - Runtime swappable |
| - Base class controls flow - Client controls choice |
| - Uses inheritance (IS-A) - Uses composition (HAS-A) |
| - Good for similar algorithms - Good for different algos |
| - Can share common steps - Each strategy independent |
| |
+------------------------------------------------------------------+
Key Takeaways
- Template Method defines the algorithm skeleton in a base class while letting subclasses override specific steps
- Hook methods provide optional extension points -- subclasses can optionally customize behavior at specific points
- The Hollywood Principle ("Don't call us, we'll call you") -- the base class calls subclass methods, not the other way around
- Template Method uses inheritance, making it less flexible than Strategy but good for sharing common algorithm structure
- Combine with Strategy when you need both a fixed skeleton AND swappable individual steps
- In JavaScript, template method works well with abstract base classes even without formal
abstractkeyword
Explain-It Challenge
You're building a CI/CD pipeline system. All pipelines follow the same structure: checkout code, install dependencies, run tests, build artifacts, deploy. However, different projects need different implementations (Node.js vs Python, Docker vs serverless). Design this using Template Method. How would you handle: (a) optional steps like linting, (b) parallel execution of independent steps, (c) rollback if a step fails?