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 abc module 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 AbstractBaseProcessorAbstractDataProcessorAbstractJSONProcessorConcreteJSONProcessor, 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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.