Loading learning content...
Every experienced software engineer encounters a recurring challenge: you've built an algorithm that works beautifully for one scenario, and now you need to apply it to a similar but not identical situation. The overall structure of the algorithm remains the same, but certain steps must change.
This is the algorithm with varying steps problem—one of the most fundamental challenges in software design. How do you reuse the invariant parts of an algorithm while allowing flexibility in the parts that change? This seemingly simple question leads us directly to one of the most elegant and widely-used behavioral patterns: the Template Method Pattern.
By the end of this page, you will understand the core problem that the Template Method Pattern solves. You'll see concrete examples of algorithms with varying steps, explore why naive solutions create maintenance nightmares, and develop the intuition needed to recognize when this pattern is the right choice.
Before we dive into code, let's recognize that algorithms with varying steps are everywhere—not just in software, but in any procedural domain.
Consider making a hot beverage:
This six-step algorithm works for tea, coffee, hot chocolate, and countless other beverages. Steps 1, 2, 4, and 6 are essentially identical regardless of what you're making. But steps 3 and 5 vary significantly—the kind of base you add and the kind of extras differ between beverages.
This is the essence of algorithms with varying steps: a fixed structure with customizable operations at specific points.
An algorithm with varying steps isn't about having completely different algorithms—it's about having one algorithm with customization points. The structure (the 'template') is invariant. Only specific operations within that structure vary.
Software examples are abundant:
| Domain | Fixed Algorithm Structure | Varying Steps |
|---|---|---|
| Data parsing | Read input → Parse → Validate → Transform → Output | Parsing logic, validation rules |
| Build systems | Fetch dependencies → Compile → Test → Package → Deploy | Compile commands, test frameworks |
| Report generation | Gather data → Format → Generate output → Deliver | Data sources, output format |
| Authentication | Accept credentials → Validate → Create session → Log event | Validation logic, session handling |
| Game AI | Observe state → Evaluate options → Select action → Execute | Evaluation criteria, action selection |
In each case, there's a clear sequence that defines the shape of the algorithm. But within that shape, certain steps must adapt to specific requirements.
When developers first encounter algorithms with varying steps, the instinctive response is often copy-paste programming. You have a working algorithm, you need a variation, so you copy the code and modify what needs to change.
Let's see this anti-pattern in action with a concrete example: document exporters for a report generation system.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
// PDF Exporter - works great in isolationclass PdfExporter { exportReport(data: ReportData): void { // Step 1: Gather data (shared logic) const enrichedData = this.gatherData(data); this.logProgress("Data gathered"); // Step 2: Open output stream (shared logic) const stream = this.openOutputStream("report.pdf"); // Step 3: Write header (PDF-specific) stream.write("%PDF-1.4"); stream.write("%\xE2\xE3\xCF\xD3"); this.logProgress("PDF header written"); // Step 4: Write body (PDF-specific) const pdfBody = this.renderToPdf(enrichedData); stream.write(pdfBody); this.logProgress("PDF body written"); // Step 5: Write footer (PDF-specific) stream.write("%%EOF"); // Step 6: Close stream (shared logic) stream.close(); this.logProgress("Export complete"); // Step 7: Notify completion (shared logic) this.notifyCompletion("PDF export finished"); } private gatherData(data: ReportData): EnrichedData { /* ... */ } private openOutputStream(filename: string): OutputStream { /* ... */ } private renderToPdf(data: EnrichedData): string { /* ... */ } private logProgress(message: string): void { /* ... */ } private notifyCompletion(message: string): void { /* ... */ }} // HTML Exporter - 70% identical, 30% differentclass HtmlExporter { exportReport(data: ReportData): void { // Step 1: Gather data (copy-pasted) const enrichedData = this.gatherData(data); this.logProgress("Data gathered"); // Step 2: Open output stream (copy-pasted) const stream = this.openOutputStream("report.html"); // Step 3: Write header (HTML-specific) stream.write("<!DOCTYPE html>"); stream.write("<html><head><title>Report</title></head>"); stream.write("<body>"); this.logProgress("HTML header written"); // Step 4: Write body (HTML-specific) const htmlBody = this.renderToHtml(enrichedData); stream.write(htmlBody); this.logProgress("HTML body written"); // Step 5: Write footer (HTML-specific) stream.write("</body></html>"); // Step 6: Close stream (copy-pasted) stream.close(); this.logProgress("Export complete"); // Step 7: Notify completion (copy-pasted) this.notifyCompletion("HTML export finished"); } // Many methods are duplicated with only minor differences private gatherData(data: ReportData): EnrichedData { /* identical to PDF */ } private openOutputStream(filename: string): OutputStream { /* identical */ } private renderToHtml(data: EnrichedData): string { /* HTML-specific */ } private logProgress(message: string): void { /* identical */ } private notifyCompletion(message: string): void { /* identical */ }} // CSV Exporter - another 70% duplicationclass CsvExporter { exportReport(data: ReportData): void { // Same pattern repeats: copy, modify specific parts // ... }}What's wrong with this approach?
At first glance, copy-paste seems pragmatic—it works, and you can see the entire algorithm in one place. But this approach creates severe problems as the system evolves:
gatherData() or logProgress(), you must find and update every copy. Miss one, and you have inconsistent behavior.Copy-paste feels efficient in the moment but creates exponential maintenance burden over time. Every duplicate is a liability waiting to cause inconsistency. The 'quick solution' becomes the expensive legacy.
A more 'sophisticated' approach that developers often try is consolidating the algorithm into a single class and using type flags or switch statements to handle the variations. This eliminates duplication but introduces its own severe problems.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
enum ExportFormat { PDF = 'pdf', HTML = 'html', CSV = 'csv', XML = 'xml', JSON = 'json',} class GenericExporter { exportReport(data: ReportData, format: ExportFormat): void { // Step 1: Gather data (shared) const enrichedData = this.gatherData(data); this.logProgress("Data gathered"); // Step 2: Open output stream (shared, but filename varies) const extension = this.getExtension(format); const stream = this.openOutputStream(`report.${extension}`); // Step 3: Write header (varies by format) switch (format) { case ExportFormat.PDF: stream.write("%PDF-1.4%\xE2\xE3\xCF\xD3"); break; case ExportFormat.HTML: stream.write("<!DOCTYPE html><html><head><title>Report</title>"); stream.write("</head><body>"); break; case ExportFormat.CSV: // CSV might have headers as first row stream.write(this.getCsvHeaders(enrichedData)); break; case ExportFormat.XML: stream.write('<?xml version="1.0" encoding="UTF-8"?>'); stream.write("<report>"); break; case ExportFormat.JSON: stream.write("{ \"report\": {"); break; } this.logProgress(`${format} header written`); // Step 4: Write body (varies by format) switch (format) { case ExportFormat.PDF: stream.write(this.renderToPdf(enrichedData)); break; case ExportFormat.HTML: stream.write(this.renderToHtml(enrichedData)); break; case ExportFormat.CSV: stream.write(this.renderToCsv(enrichedData)); break; case ExportFormat.XML: stream.write(this.renderToXml(enrichedData)); break; case ExportFormat.JSON: stream.write(this.renderToJson(enrichedData)); break; } this.logProgress(`${format} body written`); // Step 5: Write footer (varies by format) switch (format) { case ExportFormat.PDF: stream.write("%%EOF"); break; case ExportFormat.HTML: stream.write("</body></html>"); break; case ExportFormat.CSV: // No footer for CSV break; case ExportFormat.XML: stream.write("</report>"); break; case ExportFormat.JSON: stream.write(" }}"); break; } // Step 6: Close stream (shared) stream.close(); this.logProgress("Export complete"); // Step 7: Notify completion (shared) this.notifyCompletion(`${format} export finished`); } // Helper methods for each format... private renderToPdf(data: EnrichedData): string { /* ... */ } private renderToHtml(data: EnrichedData): string { /* ... */ } private renderToCsv(data: EnrichedData): string { /* ... */ } private renderToXml(data: EnrichedData): string { /* ... */ } private renderToJson(data: EnrichedData): string { /* ... */ } private getCsvHeaders(data: EnrichedData): string { /* ... */ } // ... more format-specific helpers}Why this is problematic:
The conditional approach trades duplication for a different set of problems—and in many ways, they're worse:
When the same switch statement (or the same set of type checks) appears in multiple places in your code, that's a strong signal that you have a behavioral variation that should be modeled with polymorphism—not conditionals.
Before we can solve the algorithm-with-varying-steps problem properly, we must clearly identify what parts are invariant (same for all variations) and what parts are variant (different for each variation).
Let's analyze our document export algorithm:
| Step | Operation | Nature | Reasoning |
|---|---|---|---|
| 1 | Gather data | INVARIANT | All exporters need the same enriched data |
| 2 | Open output stream | INVARIANT | All exporters write to a file stream (extension varies) |
| 3 | Write header | VARIANT | Each format has a unique header structure |
| 4 | Write body | VARIANT | Each format renders content differently |
| 5 | Write footer | VARIANT | Each format has a unique footer or closing |
| 6 | Close stream | INVARIANT | All exporters must close the stream |
| 7 | Notify completion | INVARIANT | All exporters trigger the same notification |
The key observation:
When we separate invariants from variants, we see that the sequence of steps is invariant. Every exporter will always:
This sequence never changes. What changes is how certain steps are executed.
This is the crucial insight that leads to the Template Method Pattern:
The algorithm's structure (the sequence of steps and their relationships) should be defined in one place. The implementation of specific steps should be customizable without affecting the structure.
1234567891011121314
// The algorithm structure (INVARIANT)// ──────────────────────────────────────────────────────// exportReport():// gatherData() ← INVARIANT (same for all)// openOutputStream() ← INVARIANT (same for all)// writeHeader() ← VARIANT (differs per format)// writeBody() ← VARIANT (differs per format)// writeFooter() ← VARIANT (differs per format)// closeStream() ← INVARIANT (same for all)// notifyCompletion() ← INVARIANT (same for all)// ────────────────────────────────────────────────────── // The invariant parts define the "skeleton" of the algorithm// The variant parts are "hot spots" that need customizationWhen analyzing any algorithm for this pattern, ask: 'If I listed the steps at a high level of abstraction, would they be the same across all variations?' If yes, you have a template. The steps that implement those abstractions differently are your customization points.
An important aspect of algorithms with varying steps is that the order and relationships between steps are often critical. The algorithm's correctness depends not just on what steps happen, but on their sequence.
Consider what happens if we violate our export algorithm's order:
This is exactly why the copy-paste and conditional approaches are so dangerous. Each copy or branch can accidentally violate the algorithm's implicit contract. A developer modifying the HTML exporter might reorder steps in a way that 'works' for HTML but violates assumptions other parts of the system depend on.
The algorithm's sequence is not just a guideline—it's a contract.
This contract has several components:
The ideal solution to algorithms with varying steps would enforce the algorithm's contract while allowing customization. Variant steps should be implementable without any ability to violate the invariant structure. This is precisely what the Template Method Pattern achieves.
The algorithm-with-varying-steps problem appears constantly in production software. Recognizing these situations is crucial for applying the Template Method Pattern effectively. Let's examine several common scenarios:
Almost every ETL (Extract-Transform-Load) system follows a common structure:
12345678910111213
// Invariant structure:// 1. Connect to source// 2. Extract data (VARIANT: SQL, API, file, etc.)// 3. Validate data (common validations)// 4. Transform data (VARIANT: format conversions, calculations)// 5. Connect to destination// 6. Load data (VARIANT: bulk insert, upsert, etc.)// 7. Log metrics and cleanup // Without Template Method, you'd have:class SqlToWarehouseETL { /* full algorithm, SQL-specific parts */ }class ApiToWarehouseETL { /* 80% duplicate, API-specific parts */ }class FileToWarehouseETL { /* 80% duplicate, file-specific parts */ }Every web request follows a predictable lifecycle:
12345678910111213
// Invariant structure:// 1. Receive request// 2. Parse request data// 3. Authenticate user (common middleware)// 4. Authorize action (VARIANT: permission checks per endpoint)// 5. Validate input (VARIANT: validation rules per endpoint)// 6. Handle request (VARIANT: business logic per endpoint)// 7. Format response (VARIANT: JSON, XML, HTML per endpoint)// 8. Send response// 9. Log request metrics // The framework defines the structure// Each endpoint implements specific stepsTesting frameworks embody this pattern explicitly:
123456789101112
// Invariant structure (the "test runner template"):// 1. Setup test suite (beforeAll)// 2. For each test:// a. Setup test (beforeEach) ← VARIANT per test class// b. Execute test (the test itself) ← VARIANT per test method// c. Verify results (assertions) ← VARIANT per test method// d. Teardown test (afterEach) ← VARIANT per test class// 3. Teardown test suite (afterAll)// 4. Report results // JUnit, pytest, Jest—all implement this template// Tests customize the variant partsLanguage processing follows consistent phases:
1234567891011
// Invariant structure:// 1. Read source file// 2. Lexical analysis (VARIANT: language-specific tokens)// 3. Parsing (VARIANT: language-specific grammar)// 4. Semantic analysis (VARIANT: type systems, rules)// 5. Optimization (common patterns, plus language-specific)// 6. Code generation (VARIANT: target architecture)// 7. Output result // GCC, Clang, Rust compiler—all follow this template// Each language implements specific compilation stepsGame loops are perhaps the most explicit example:
12345678910111213
// Invariant structure (every game, every frame):// 1. Process input (common: read controllers, keyboard, mouse)// 2. Update game state (VARIANT: game-specific logic)// - Update AI// - Update physics// - Update animations// 3. Render scene (VARIANT: 2D vs 3D, engine-specific)// 4. Present frame (common: buffer swap)// 5. Calculate delta time (common)// 6. Repeat // Unity, Unreal, custom engines—all follow this template// Games customize Update() and Render() methodsLook at frameworks and libraries you use. Almost every one defines a lifecycle or processing pipeline that you customize. React's component lifecycle, Spring's request handling, Express middleware—they're all manifestations of algorithms with varying steps.
Now that we understand the problem deeply, let's articulate what an ideal solution must achieve. These requirements will serve as our design criteria:
| Requirement | Copy-Paste | Conditionals |
|---|---|---|
| Define structure once | ❌ Structure copied N times | ✅ One method, but mixed with variants |
| Allow customization | ✅ Full customization per copy | ❌ Modify class to customize |
| Enforce contract | ❌ Each copy can violate | ⚠️ Sequence fixed but not enforced |
| Open/Closed Principle | ❌ Must copy entire algorithm | ❌ Must modify class |
| Maximize reuse | ❌ No reuse; full duplication | ⚠️ Helpers reused, but scattered |
| Make structure explicit | ❌ Implicit in duplication | ❌ Hidden in conditionals |
| Provide defaults | ⚠️ Copy provides 'defaults' | ❌ No default concept |
With these requirements clearly defined, we're ready to see how the Template Method Pattern addresses each one. The pattern provides an elegant solution that leverages inheritance to define the algorithm structure while allowing polymorphism at customization points.
We've thoroughly explored the problem that the Template Method Pattern solves. Let's consolidate our understanding:
What's Next:
In the next page, we'll discover how the Template Method Pattern solves this problem elegantly using inheritance and abstract methods. We'll see how a base class can define the algorithm's skeleton while delegating specific steps to subclasses—achieving all the requirements we've outlined while maintaining clean, maintainable code.
You now understand the fundamental problem that motivates the Template Method Pattern. You can identify algorithms with varying steps in real-world code and articulate why naive solutions create long-term problems. Next, we'll explore the elegant solution: abstract classes with hook methods.