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

  1. Template Method defines the algorithm skeleton in a base class while letting subclasses override specific steps
  2. Hook methods provide optional extension points -- subclasses can optionally customize behavior at specific points
  3. The Hollywood Principle ("Don't call us, we'll call you") -- the base class calls subclass methods, not the other way around
  4. Template Method uses inheritance, making it less flexible than Strategy but good for sharing common algorithm structure
  5. Combine with Strategy when you need both a fixed skeleton AND swappable individual steps
  6. In JavaScript, template method works well with abstract base classes even without formal abstract keyword

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?