Template Method in Python: Abstract Base Classes
The Template Method pattern solves a specific problem: you have an algorithm with a fixed sequence of steps, but some of those steps need different implementations depending on context. Instead of...
Key Insights
- The Template Method pattern defines an algorithm’s skeleton in a base class while letting subclasses override specific steps, making it ideal for workflows with fixed structure but variable implementation details.
- Python’s
abcmodule enforces abstract contracts at instantiation time, not import time, meaning you’ll only catch missing implementations when you try to create an object. - Prefer Template Method when the algorithm structure is stable and shared; switch to Strategy pattern when you need runtime flexibility or want to avoid inheritance hierarchies.
Introduction to the Template Method Pattern
The Template Method pattern solves a specific problem: you have an algorithm with a fixed sequence of steps, but some of those steps need different implementations depending on context. Instead of duplicating the entire algorithm across multiple classes, you define the skeleton once and let subclasses fill in the blanks.
Consider a data processing pipeline. Every pipeline reads data, validates it, transforms it, and outputs results. That sequence never changes. But how you read data—from a CSV, an API, or a database—varies wildly. Template Method captures this invariant structure while allowing variation in the details.
This pattern shines when you need to enforce a specific workflow. Subclasses can’t skip validation or reorder steps because the base class controls the flow. You get consistency without sacrificing flexibility.
Template Method vs. Strategy Pattern
These patterns solve similar problems but make different tradeoffs. Template Method uses inheritance; Strategy uses composition. That single difference cascades into when you should use each.
| Aspect | Template Method | Strategy |
|---|---|---|
| Relationship | Inheritance (is-a) | Composition (has-a) |
| Algorithm control | Base class owns the skeleton | Client assembles strategies |
| Runtime flexibility | Fixed at class definition | Swappable at runtime |
| Code reuse | Shared steps in base class | Each strategy is independent |
| Testing | Requires subclassing or mocking | Easy to inject test doubles |
Use Template Method when the algorithm structure is fundamental to the abstraction and shouldn’t change. Use Strategy when you need to swap behaviors at runtime or when you’re already suffering from deep inheritance hierarchies.
A practical heuristic: if you’re saying “every X must do A, then B, then C,” Template Method fits. If you’re saying “X needs some way to do Y,” Strategy gives you more flexibility.
Python’s ABC Module Fundamentals
Python’s abc module provides the machinery for abstract base classes. The ABC class serves as a base, and the @abstractmethod decorator marks methods that subclasses must implement.
from abc import ABC, abstractmethod
class DataProcessor(ABC):
"""Abstract base class demonstrating ABC fundamentals."""
@abstractmethod
def process(self, data: str) -> str:
"""Subclasses must implement this method."""
pass
def log(self, message: str) -> None:
"""Concrete method available to all subclasses."""
print(f"[DataProcessor] {message}")
class UpperCaseProcessor(DataProcessor):
def process(self, data: str) -> str:
self.log("Converting to uppercase")
return data.upper()
Python enforces abstract contracts at instantiation time, not at class definition. This means you can define a subclass that doesn’t implement all abstract methods—Python won’t complain until you try to create an instance:
class BrokenProcessor(DataProcessor):
pass # Missing process() implementation
# This line is fine:
print("Class defined successfully")
# This line raises TypeError:
# broken = BrokenProcessor()
# TypeError: Can't instantiate abstract class BrokenProcessor
# with abstract method process
This delayed enforcement catches errors at runtime rather than import time. Write tests that actually instantiate your concrete classes to catch missing implementations early.
Implementing Template Method with ABCs
The Template Method pattern combines a concrete “template” method with abstract “primitive” operations. The template method defines the algorithm skeleton and calls abstract methods that subclasses must provide.
from abc import ABC, abstractmethod
from typing import Any
class DataPipeline(ABC):
"""
Template Method pattern implementation for data processing.
The run() method defines the algorithm skeleton.
Subclasses implement the abstract primitive operations.
"""
def run(self, source: str) -> dict[str, Any]:
"""
Template method defining the processing algorithm.
This method should not be overridden.
"""
raw_data = self.parse(source)
self.validate(raw_data)
transformed = self.transform(raw_data)
return self.output(transformed)
@abstractmethod
def parse(self, source: str) -> dict[str, Any]:
"""Parse raw input into structured data."""
pass
@abstractmethod
def validate(self, data: dict[str, Any]) -> None:
"""Validate data, raising ValueError if invalid."""
pass
@abstractmethod
def transform(self, data: dict[str, Any]) -> dict[str, Any]:
"""Apply business logic transformations."""
pass
@abstractmethod
def output(self, data: dict[str, Any]) -> dict[str, Any]:
"""Format and return final output."""
pass
class JsonPipeline(DataPipeline):
"""Concrete implementation for JSON data."""
def parse(self, source: str) -> dict[str, Any]:
import json
return json.loads(source)
def validate(self, data: dict[str, Any]) -> None:
if "id" not in data:
raise ValueError("Missing required field: id")
def transform(self, data: dict[str, Any]) -> dict[str, Any]:
data["processed"] = True
data["id"] = str(data["id"]).upper()
return data
def output(self, data: dict[str, Any]) -> dict[str, Any]:
return {"status": "success", "payload": data}
# Usage
pipeline = JsonPipeline()
result = pipeline.run('{"id": "abc123", "value": 42}')
print(result)
# {'status': 'success', 'payload': {'id': 'ABC123', 'value': 42, 'processed': True}}
Notice that run() is a concrete method that orchestrates the abstract methods. Subclasses can’t change the order of operations—they can only customize what each step does.
Hook Methods and Optional Overrides
Not every extension point needs to be mandatory. Hook methods provide optional customization points with sensible defaults. Subclasses can override them but aren’t required to.
from abc import ABC, abstractmethod
from datetime import datetime
class ReportGenerator(ABC):
"""
Report generator with required and optional hooks.
Abstract methods: generate_body() - must be implemented
Hook methods: header(), footer() - optional overrides
"""
def generate(self) -> str:
"""Template method assembling the full report."""
parts = [
self.header(),
self.generate_body(),
self.footer(),
]
return "\n".join(filter(None, parts))
def header(self) -> str | None:
"""Optional hook for report header. Override to customize."""
return f"Report Generated: {datetime.now().isoformat()}"
def footer(self) -> str | None:
"""Optional hook for report footer. Override to customize."""
return None # No footer by default
@abstractmethod
def generate_body(self) -> str:
"""Required: generate the main report content."""
pass
class SalesReport(ReportGenerator):
"""Uses default header, no footer."""
def __init__(self, sales: list[float]):
self.sales = sales
def generate_body(self) -> str:
total = sum(self.sales)
return f"Total Sales: ${total:,.2f}"
class ExecutiveReport(ReportGenerator):
"""Customizes both header and footer."""
def __init__(self, title: str, content: str):
self.title = title
self.content = content
def header(self) -> str:
return f"{'=' * 40}\n{self.title.upper()}\n{'=' * 40}"
def footer(self) -> str:
return f"{'=' * 40}\nCONFIDENTIAL"
def generate_body(self) -> str:
return self.content
# SalesReport uses defaults
sales = SalesReport([1200.50, 890.25, 2100.00])
print(sales.generate())
print("\n---\n")
# ExecutiveReport customizes hooks
executive = ExecutiveReport("Q4 Summary", "Revenue exceeded projections by 15%.")
print(executive.generate())
The key distinction: abstract methods enforce a contract, while hooks provide extension points. Use abstract methods for operations that fundamentally differ between implementations. Use hooks for customization that most subclasses won’t need.
Real-World Application: ETL Pipeline
Let’s build a practical ETL (Extract, Transform, Load) pipeline that handles multiple data sources. This example shows how Template Method scales to real-world complexity.
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Any, Iterator
import csv
import io
@dataclass
class Record:
"""Standardized record format for the pipeline."""
id: str
data: dict[str, Any]
source: str
class ETLPipeline(ABC):
"""
ETL pipeline using Template Method pattern.
Concrete implementations handle different data sources
while sharing common validation and loading logic.
"""
def __init__(self, batch_size: int = 100):
self.batch_size = batch_size
self.stats = {"extracted": 0, "transformed": 0, "loaded": 0, "errors": 0}
def execute(self, source: str) -> dict[str, int]:
"""Template method defining the ETL workflow."""
self.before_execute()
try:
for batch in self._batch_records(self.extract(source)):
transformed = []
for record in batch:
try:
self.validate(record)
transformed.append(self.transform(record))
self.stats["transformed"] += 1
except ValueError as e:
self.on_error(record, e)
self.stats["errors"] += 1
if transformed:
self.load(transformed)
self.stats["loaded"] += len(transformed)
finally:
self.after_execute()
return self.stats
def _batch_records(
self, records: Iterator[Record]
) -> Iterator[list[Record]]:
"""Batch records for efficient loading."""
batch = []
for record in records:
self.stats["extracted"] += 1
batch.append(record)
if len(batch) >= self.batch_size:
yield batch
batch = []
if batch:
yield batch
# Required abstract methods
@abstractmethod
def extract(self, source: str) -> Iterator[Record]:
"""Extract records from the data source."""
pass
@abstractmethod
def transform(self, record: Record) -> Record:
"""Apply business transformations to a record."""
pass
@abstractmethod
def load(self, records: list[Record]) -> None:
"""Load transformed records to destination."""
pass
# Optional hooks with defaults
def validate(self, record: Record) -> None:
"""Validate a record. Override to add validation rules."""
if not record.id:
raise ValueError("Record missing ID")
def before_execute(self) -> None:
"""Hook called before pipeline execution."""
pass
def after_execute(self) -> None:
"""Hook called after pipeline execution."""
pass
def on_error(self, record: Record, error: Exception) -> None:
"""Hook called when a record fails processing."""
print(f"Error processing {record.id}: {error}")
class CSVPipeline(ETLPipeline):
"""ETL pipeline for CSV data sources."""
def __init__(self, output_file: str, **kwargs):
super().__init__(**kwargs)
self.output_file = output_file
self.output_buffer: list[Record] = []
def extract(self, source: str) -> Iterator[Record]:
reader = csv.DictReader(io.StringIO(source))
for i, row in enumerate(reader):
yield Record(
id=row.get("id", f"row_{i}"),
data=dict(row),
source="csv"
)
def transform(self, record: Record) -> Record:
# Normalize string fields
normalized = {
k: v.strip().lower() if isinstance(v, str) else v
for k, v in record.data.items()
}
return Record(id=record.id, data=normalized, source=record.source)
def load(self, records: list[Record]) -> None:
self.output_buffer.extend(records)
print(f"Loaded batch of {len(records)} records")
def after_execute(self) -> None:
print(f"Pipeline complete. Total records: {len(self.output_buffer)}")
# Usage
csv_data = """id,name,email
1,John Doe,JOHN@EXAMPLE.COM
2,Jane Smith,jane@example.com
3,,invalid@test.com"""
pipeline = CSVPipeline(output_file="output.json", batch_size=2)
stats = pipeline.execute(csv_data)
print(f"Stats: {stats}")
This implementation demonstrates several Template Method principles: the algorithm skeleton in execute(), required abstract methods for source-specific logic, and optional hooks for cross-cutting concerns like error handling and lifecycle events.
Best Practices and Pitfalls
Keep template methods focused. A template method should coordinate a single coherent workflow. If you’re cramming multiple algorithms into one method, split them into separate template methods or separate classes.
Limit inheritance depth. Template Method relies on inheritance, which becomes unwieldy beyond two or three levels. If you find yourself creating AbstractBaseProcessor → AbstractDataProcessor → AbstractJSONProcessor → ConcreteJSONProcessor, stop and reconsider your design.
Document the contract clearly. Subclass authors need to understand what each abstract method should do, what preconditions hold when it’s called, and what postconditions it must establish. Docstrings aren’t optional here.
Test at multiple levels. Test your concrete implementations directly. Also test the base class behavior by creating a minimal concrete subclass in your test suite—this catches bugs in the template method itself.
Know when to switch patterns. If you need runtime flexibility, Strategy pattern with composition is often cleaner. If your “template” has only one step that varies, a simple callback or function parameter might suffice. Don’t use Template Method just because you learned it—use it when the algorithm skeleton genuinely needs protection.
The Template Method pattern, implemented through Python’s ABC module, gives you a robust way to define extensible algorithms. Use it when you have a fixed workflow with variable steps, and you’ll get consistency without sacrificing the flexibility your concrete implementations need.