BrandGhost
Template Method Pattern Real-World Example in C#: Complete Implementation

Template Method Pattern Real-World Example in C#: Complete Implementation

Template Method Pattern Real-World Example in C#: Complete Implementation

Most template method pattern tutorials show a "make a beverage" example where Tea and Coffee override a couple of steps. That teaches the shape of the pattern, but it won't help you the next time you need a document processing pipeline that handles PDF invoices, CSV reports, and XML data feeds -- all sharing the same validation-parse-transform-output workflow but each implementing those steps differently. This article builds a complete template method pattern real world example in C# from scratch: a document processing pipeline where the algorithm skeleton lives in one place and each document type fills in its own logic.

By the end, you'll have compilable classes covering the full evolution: the base class with its sealed template method and hook methods, three concrete processors for distinct document types, dependency injection wiring, error handling with retry logic, and xUnit tests that verify step execution order. If you want to see how this pattern complements other behavioral patterns like the strategy pattern, this article gives you the practical foundation.

The Problem: A Document Processing Pipeline

You're building a system that ingests documents from external sources. The business needs to process three document types -- PDF invoices from vendors, CSV reports from internal systems, and XML data feeds from partner APIs. Every document type follows the same high-level pipeline:

  • Validate the document structure
  • Parse the raw content into an intermediate representation
  • Transform the parsed data into a domain model

After those three steps, every processor also needs to generate an audit log entry. The sequence never changes. What changes is how each step executes.

Without the template method pattern, you end up duplicating the pipeline orchestration across every processor. Each one repeats the same try/catch structure, the same audit logging call, the same hook points for pre- and post-processing. When the business decides to add a retry step or a notification on failure, you're editing three classes -- and hoping you don't miss one.

public class PdfProcessor
{
    public void Process(byte[] data)
    {
        // duplicate pipeline logic
        ValidatePdf(data);
        var parsed = ParsePdf(data);
        var result = TransformPdf(parsed);
        WriteAuditLog(result);
    }
}

public class CsvProcessor
{
    public void Process(byte[] data)
    {
        // same pipeline, different steps
        ValidateCsv(data);
        var parsed = ParseCsv(data);
        var result = TransformCsv(parsed);
        WriteAuditLog(result);
    }
}

The pipeline orchestration is identical, but it's scattered across every processor class. Adding a fourth document type means copying the same boilerplate again. This is exactly the problem the template method pattern solves -- it captures the invariant algorithm in a base class and lets subclasses supply the variant steps.

Designing the Base Class

The heart of the template method pattern is the abstract base class that defines the algorithm skeleton. Our DocumentProcessor class locks down the pipeline sequence in a sealed method and exposes abstract methods for the steps that vary by document type:

public abstract class DocumentProcessor
{
    private readonly ILogger _logger;

    protected DocumentProcessor(ILogger logger)
    {
        _logger = logger;
    }

    protected ILogger Logger => _logger;

    public sealed record ProcessingResult
    {
        public bool Success { get; init; }
        public string DocumentType { get; init; } = "";
        public string? ErrorMessage { get; init; }
        public int RecordsProcessed { get; init; }
        public DateTimeOffset Timestamp { get; init; }
            = DateTimeOffset.UtcNow;
    }

    public sealed ProcessingResult ProcessDocument(
        byte[] rawData)
    {
        _logger.LogInformation(
            "Starting document processing for {Type}",
            DocumentType);

        OnBeforeProcess(rawData);

        Validate(rawData);

        var parsed = Parse(rawData);

        var result = Transform(parsed);

        GenerateAuditLog(result);

        OnAfterProcess(result);

        _logger.LogInformation(
            "Completed processing for {Type}: " +
            "{Records} records",
            DocumentType,
            result.RecordsProcessed);

        return result;
    }

    public abstract string DocumentType { get; }

    protected abstract void Validate(byte[] rawData);

    protected abstract object Parse(byte[] rawData);

    protected abstract ProcessingResult Transform(
        object parsedData);

    protected virtual void OnBeforeProcess(
        byte[] rawData)
    {
        // Hook: subclasses can override for
        // pre-processing behavior
    }

    protected virtual void OnAfterProcess(
        ProcessingResult result)
    {
        // Hook: subclasses can override for
        // post-processing behavior
    }

    protected void GenerateAuditLog(
        ProcessingResult result)
    {
        _logger.LogInformation(
            "AUDIT: {Type} processed at {Time} - " +
            "Success: {Success}, Records: {Records}",
            result.DocumentType,
            result.Timestamp,
            result.Success,
            result.RecordsProcessed);
    }
}

Let's break down the design decisions. The ProcessDocument method is sealed, which prevents subclasses from overriding the pipeline sequence. This is the defining characteristic of the template method pattern -- the algorithm structure is fixed in the base class.

The three abstract methods -- Validate, Parse, and Transform -- are the required extension points. Every concrete processor must implement these because each document type handles them differently. The OnBeforeProcess and OnAfterProcess methods are virtual hooks with empty default implementations. Subclasses can override them for optional behavior like metrics collection or cache warming, but they don't have to.

The GenerateAuditLog method is a concrete method that all processors share. It writes a structured log entry regardless of document type. This is common in the template method pattern -- not every step in the algorithm needs to vary. Some steps are shared behavior that belongs in the base class. If you're familiar with how inversion of control works, the template method pattern is a textbook example: the base class controls the flow and calls down into subclass implementations rather than the subclass calling up into the base.

Implementing Concrete Processors

With the base class in place, each document type provides its own implementations of the abstract steps. The pipeline orchestration stays in DocumentProcessor -- the concrete classes focus exclusively on document-specific logic.

PdfInvoiceProcessor

The PDF processor validates document structure, parses invoice fields from the raw bytes, and transforms them into a processing result. In a production system, you'd use a library like iTextSharp or PdfPig for actual PDF parsing, but the template method pattern structure remains identical:

public sealed class PdfInvoiceProcessor
    : DocumentProcessor
{
    public PdfInvoiceProcessor(
        ILogger<PdfInvoiceProcessor> logger)
        : base(logger)
    {
    }

    public override string DocumentType => "PDF Invoice";

    protected override void Validate(byte[] rawData)
    {
        if (rawData is null || rawData.Length < 4)
        {
            throw new InvalidOperationException(
                "PDF data is empty or too short.");
        }

        // PDF files start with %PDF
        if (rawData[0] != 0x25 ||
            rawData[1] != 0x50 ||
            rawData[2] != 0x44 ||
            rawData[3] != 0x46)
        {
            throw new InvalidOperationException(
                "Invalid PDF header. Expected %PDF " +
                "magic bytes.");
        }

        Logger.LogInformation(
            "PDF structure validated: {Size} bytes",
            rawData.Length);
    }

    protected override object Parse(byte[] rawData)
    {
        // In production, use a PDF library to extract
        // invoice fields like vendor, amount, date
        var content = System.Text.Encoding.UTF8
            .GetString(rawData);

        var invoice = new Dictionary<string, string>
        {
            ["vendor"] = ExtractField(
                content, "Vendor:"),
            ["amount"] = ExtractField(
                content, "Amount:"),
            ["invoiceNumber"] = ExtractField(
                content, "Invoice:"),
            ["date"] = ExtractField(
                content, "Date:")
        };

        Logger.LogInformation(
            "Parsed PDF invoice: {InvoiceNumber}",
            invoice["invoiceNumber"]);

        return invoice;
    }

    protected override ProcessingResult Transform(
        object parsedData)
    {
        var invoice =
            (Dictionary<string, string>)parsedData;

        return new ProcessingResult
        {
            Success = true,
            DocumentType = DocumentType,
            RecordsProcessed = 1
        };
    }

    protected override void OnBeforeProcess(
        byte[] rawData)
    {
        Logger.LogInformation(
            "Pre-processing PDF: checking for " +
            "encryption");
    }

    private static string ExtractField(
        string content, string fieldName)
    {
        var index = content.IndexOf(
            fieldName, StringComparison.Ordinal);
        if (index < 0) return "Unknown";

        var start = index + fieldName.Length;
        var end = content.IndexOf('
', start);
        if (end < 0) end = content.Length;

        return content[start..end].Trim();
    }
}

Notice how PdfInvoiceProcessor also overrides the OnBeforeProcess hook to log an encryption check. The CSV and XML processors won't need that hook, so they'll inherit the empty default from the base class.

CsvReportProcessor

The CSV processor validates that required headers exist, parses rows into a list of dictionaries, and transforms the collection into a result with a record count:

public sealed class CsvReportProcessor
    : DocumentProcessor
{
    private static readonly string[] RequiredHeaders =
        ["Date", "Category", "Amount", "Description"];

    public CsvReportProcessor(
        ILogger<CsvReportProcessor> logger)
        : base(logger)
    {
    }

    public override string DocumentType => "CSV Report";

    protected override void Validate(byte[] rawData)
    {
        if (rawData is null || rawData.Length == 0)
        {
            throw new InvalidOperationException(
                "CSV data is empty.");
        }

        var content = System.Text.Encoding.UTF8
            .GetString(rawData);
        var firstLine = content.Split('
')[0];
        var headers = firstLine
            .Split(',')
            .Select(h => h.Trim())
            .ToArray();

        var missing = RequiredHeaders
            .Where(r => !headers.Contains(r))
            .ToArray();

        if (missing.Length > 0)
        {
            throw new InvalidOperationException(
                "CSV is missing required headers: " +
                string.Join(", ", missing));
        }

        Logger.LogInformation(
            "CSV headers validated: {Headers}",
            string.Join(", ", headers));
    }

    protected override object Parse(byte[] rawData)
    {
        var content = System.Text.Encoding.UTF8
            .GetString(rawData);
        var lines = content
            .Split('
', StringSplitOptions
                .RemoveEmptyEntries);
        var headers = lines[0]
            .Split(',')
            .Select(h => h.Trim())
            .ToArray();

        var rows = new List<Dictionary<string, string>>();

        for (int i = 1; i < lines.Length; i++)
        {
            var values = lines[i]
                .Split(',')
                .Select(v => v.Trim())
                .ToArray();
            var row = new Dictionary<string, string>();

            for (int j = 0;
                j < headers.Length && j < values.Length;
                j++)
            {
                row[headers[j]] = values[j];
            }

            rows.Add(row);
        }

        Logger.LogInformation(
            "Parsed {Count} CSV rows", rows.Count);

        return rows;
    }

    protected override ProcessingResult Transform(
        object parsedData)
    {
        var rows = (List<Dictionary<string, string>>)
            parsedData;

        return new ProcessingResult
        {
            Success = rows.Count > 0,
            DocumentType = DocumentType,
            RecordsProcessed = rows.Count
        };
    }
}

XmlDataFeedProcessor

The XML processor validates against an expected root element, parses elements into a structured list, and transforms them into a feed result:

public sealed class XmlDataFeedProcessor
    : DocumentProcessor
{
    private readonly string _expectedRootElement;

    public XmlDataFeedProcessor(
        ILogger<XmlDataFeedProcessor> logger,
        string expectedRootElement = "DataFeed")
        : base(logger)
    {
        _expectedRootElement = expectedRootElement;
    }

    public override string DocumentType =>
        "XML Data Feed";

    protected override void Validate(byte[] rawData)
    {
        if (rawData is null || rawData.Length == 0)
        {
            throw new InvalidOperationException(
                "XML data is empty.");
        }

        using var stream = new MemoryStream(rawData);
        var doc = System.Xml.Linq.XDocument
            .Load(stream);

        if (doc.Root?.Name.LocalName
            != _expectedRootElement)
        {
            throw new InvalidOperationException(
                $"Expected root element " +
                $"'{_expectedRootElement}' but " +
                $"found '{doc.Root?.Name.LocalName}'.");
        }

        Logger.LogInformation(
            "XML schema validated: root element " +
            "{Root} with {Count} children",
            doc.Root.Name.LocalName,
            doc.Root.Elements().Count());
    }

    protected override object Parse(byte[] rawData)
    {
        using var stream = new MemoryStream(rawData);
        var doc = System.Xml.Linq.XDocument
            .Load(stream);

        var items = doc.Root!
            .Elements()
            .Select(e => e.Elements()
                .ToDictionary(
                    child => child.Name.LocalName,
                    child => child.Value))
            .ToList();

        Logger.LogInformation(
            "Parsed {Count} XML feed items",
            items.Count);

        return items;
    }

    protected override ProcessingResult Transform(
        object parsedData)
    {
        var items =
            (List<Dictionary<string, string>>)
            parsedData;

        return new ProcessingResult
        {
            Success = items.Count > 0,
            DocumentType = DocumentType,
            RecordsProcessed = items.Count
        };
    }

    protected override void OnAfterProcess(
        ProcessingResult result)
    {
        Logger.LogInformation(
            "Post-processing XML feed: scheduling " +
            "downstream sync for {Records} items",
            result.RecordsProcessed);
    }
}

Notice that XmlDataFeedProcessor overrides OnAfterProcess to schedule a downstream sync -- something the PDF and CSV processors don't need. The hook methods let each concrete processor opt into optional behavior without polluting the others. This is a key difference between the template method pattern and the strategy pattern: with the strategy pattern, you'd compose the varying steps at runtime. With the template method pattern, the steps are fixed at compile time through inheritance, and the base class controls the sequencing.

Wiring Up with Dependency Injection

With three concrete processors, you need a clean way to select the right one at runtime based on the incoming document type. The factory/dictionary pattern paired with IServiceCollection makes this straightforward:

public static class DocumentProcessingExtensions
{
    public static IServiceCollection
        AddDocumentProcessing(
            this IServiceCollection services)
    {
        services
            .AddSingleton<PdfInvoiceProcessor>();
        services
            .AddSingleton<CsvReportProcessor>();
        services
            .AddSingleton<XmlDataFeedProcessor>();

        services.AddSingleton<
            DocumentProcessorFactory>();

        return services;
    }
}

public sealed class DocumentProcessorFactory
{
    private readonly Dictionary<string,
        DocumentProcessor> _processors;

    public DocumentProcessorFactory(
        PdfInvoiceProcessor pdfProcessor,
        CsvReportProcessor csvProcessor,
        XmlDataFeedProcessor xmlProcessor)
    {
        _processors = new Dictionary<string,
            DocumentProcessor>(
            StringComparer.OrdinalIgnoreCase)
        {
            ["pdf"] = pdfProcessor,
            ["csv"] = csvProcessor,
            ["xml"] = xmlProcessor
        };
    }

    public DocumentProcessor GetProcessor(
        string documentType)
    {
        if (!_processors.TryGetValue(
            documentType, out var processor))
        {
            throw new ArgumentException(
                $"No processor registered for " +
                $"document type '{documentType}'.",
                nameof(documentType));
        }

        return processor;
    }

    public IReadOnlyCollection<string>
        SupportedTypes => _processors.Keys;
}

The factory takes all three concrete processors through constructor injection and indexes them by document type string. Calling code doesn't need to know which subclass handles which document type -- it asks the factory for a processor, gets back a DocumentProcessor reference, and calls ProcessDocument. If you're building a pipeline that handles mixed document batches, the factory lets you route each document to the right processor:

public sealed class DocumentIngestionService
{
    private readonly DocumentProcessorFactory _factory;
    private readonly ILogger<DocumentIngestionService>
        _logger;

    public DocumentIngestionService(
        DocumentProcessorFactory factory,
        ILogger<DocumentIngestionService> logger)
    {
        _factory = factory;
        _logger = logger;
    }

    public List<DocumentProcessor.ProcessingResult>
        ProcessBatch(
            IEnumerable<(string Type, byte[] Data)>
                documents)
    {
        var results = new List<DocumentProcessor
            .ProcessingResult>();

        foreach (var (type, data) in documents)
        {
            var processor = _factory
                .GetProcessor(type);
            var result = processor
                .ProcessDocument(data);
            results.Add(result);
        }

        _logger.LogInformation(
            "Batch complete: {Total} documents, " +
            "{Succeeded} succeeded",
            results.Count,
            results.Count(r => r.Success));

        return results;
    }
}

This approach follows inversion of control -- the DocumentIngestionService depends on the factory abstraction, not on concrete processor types. Adding a new document type means creating a new subclass, registering it in the factory, and adding the DI registration. No existing code changes.

Adding Error Handling and Resilience

The base class is the perfect place to centralize error handling. Instead of requiring every concrete processor to wrap its steps in try/catch blocks, we enhance DocumentProcessor to protect subclasses from that boilerplate:

public abstract class ResilientDocumentProcessor
    : DocumentProcessor
{
    private readonly int _maxRetries;

    protected ResilientDocumentProcessor(
        ILogger logger,
        int maxRetries = 3)
        : base(logger)
    {
        _maxRetries = maxRetries;
    }

    public new ProcessingResult ProcessDocument(
        byte[] rawData)
    {
        for (int attempt = 1;
            attempt <= _maxRetries;
            attempt++)
        {
            try
            {
                return base.ProcessDocument(rawData);
            }
            catch (Exception ex)
                when (attempt < _maxRetries
                    && IsRetryable(ex))
            {
                Logger.LogWarning(
                    ex,
                    "Attempt {Attempt}/{Max} failed " +
                    "for {Type}. Retrying...",
                    attempt,
                    _maxRetries,
                    DocumentType);

                OnRetry(attempt, ex);

                Thread.Sleep(
                    TimeSpan.FromSeconds(
                        Math.Pow(2, attempt)));
            }
            catch (Exception ex)
            {
                Logger.LogError(
                    ex,
                    "Processing failed for {Type} " +
                    "after {Attempts} attempt(s)",
                    DocumentType,
                    attempt);

                OnProcessingFailed(ex);

                return new ProcessingResult
                {
                    Success = false,
                    DocumentType = DocumentType,
                    ErrorMessage = ex.Message
                };
            }
        }

        return new ProcessingResult
        {
            Success = false,
            DocumentType = DocumentType,
            ErrorMessage = "Max retries exceeded."
        };
    }

    protected virtual bool IsRetryable(
        Exception ex) =>
        ex is TimeoutException
        || ex is IOException;

    protected virtual void OnRetry(
        int attempt, Exception ex)
    {
        // Hook: subclasses can override for
        // custom retry behavior like clearing caches
    }

    protected virtual void OnProcessingFailed(
        Exception ex)
    {
        // Hook: subclasses can override to send
        // notifications on failure
    }
}

The ResilientDocumentProcessor layer adds three capabilities without touching any concrete processor. First, retry logic with exponential backoff wraps the entire pipeline. Second, the IsRetryable method lets subclasses control which exceptions trigger retries -- transient failures like TimeoutException are retryable by default, but a validation error is not. Third, the OnProcessingFailed hook gives subclasses a place to send alerts or update monitoring dashboards when processing fails permanently.

This layered approach is one of the template method pattern's strengths. You can build up behavior in inheritance layers without forcing every subclass to implement error handling. A concrete processor that extends ResilientDocumentProcessor instead of DocumentProcessor gains retry logic automatically. Compare this to the decorator pattern, where you'd wrap the processor at runtime. Both are valid approaches, but the template method pattern bakes the behavior into the inheritance hierarchy rather than composing it externally.

Testing the Pipeline

Testing a template method pattern implementation means verifying both the individual steps and the sequencing. Here's a test suite using xUnit that covers the concrete processors, a spy subclass for execution order verification, and an integration test with the DI container.

Testing Concrete Processors

Each concrete processor can be tested independently by feeding it valid and invalid input:

public class PdfInvoiceProcessorTests
{
    private readonly PdfInvoiceProcessor _processor;

    public PdfInvoiceProcessorTests()
    {
        var logger = NullLogger<PdfInvoiceProcessor>
            .Instance;
        _processor = new PdfInvoiceProcessor(logger);
    }

    [Fact]
    public void ProcessDocument_ValidPdf_Succeeds()
    {
        // %PDF header + invoice content
        var data = System.Text.Encoding.UTF8.GetBytes(
            "%PDF
Vendor: Acme Corp
" +
            "Amount: 1500.00
" +
            "Invoice: INV-001
Date: 2026-01-15");

        var result = _processor
            .ProcessDocument(data);

        Assert.True(result.Success);
        Assert.Equal(
            "PDF Invoice", result.DocumentType);
        Assert.Equal(1, result.RecordsProcessed);
    }

    [Fact]
    public void ProcessDocument_InvalidHeader_Throws()
    {
        var data = System.Text.Encoding.UTF8
            .GetBytes("NOT A PDF");

        Assert.Throws<InvalidOperationException>(
            () => _processor.ProcessDocument(data));
    }
}

public class CsvReportProcessorTests
{
    private readonly CsvReportProcessor _processor;

    public CsvReportProcessorTests()
    {
        var logger = NullLogger<CsvReportProcessor>
            .Instance;
        _processor = new CsvReportProcessor(logger);
    }

    [Fact]
    public void ProcessDocument_ValidCsv_Succeeds()
    {
        var csv = "Date,Category,Amount,Description
" +
            "2026-01-15,Office,250.00,Supplies
" +
            "2026-01-16,Travel,180.50,Taxi";
        var data = System.Text.Encoding.UTF8
            .GetBytes(csv);

        var result = _processor
            .ProcessDocument(data);

        Assert.True(result.Success);
        Assert.Equal(2, result.RecordsProcessed);
    }

    [Fact]
    public void ProcessDocument_MissingHeaders_Throws()
    {
        var csv = "Date,Category
2026-01-15,Office";
        var data = System.Text.Encoding.UTF8
            .GetBytes(csv);

        Assert.Throws<InvalidOperationException>(
            () => _processor.ProcessDocument(data));
    }
}

Spy Subclass for Execution Order

A spy subclass records which methods were called and in what order. This verifies that the template method enforces the correct sequencing:

public sealed class SpyDocumentProcessor
    : DocumentProcessor
{
    public List<string> CallLog { get; } = [];

    public SpyDocumentProcessor()
        : base(NullLogger.Instance)
    {
    }

    public override string DocumentType => "Spy";

    protected override void Validate(byte[] rawData)
    {
        CallLog.Add("Validate");
    }

    protected override object Parse(byte[] rawData)
    {
        CallLog.Add("Parse");
        return new object();
    }

    protected override ProcessingResult Transform(
        object parsedData)
    {
        CallLog.Add("Transform");
        return new ProcessingResult
        {
            Success = true,
            DocumentType = DocumentType,
            RecordsProcessed = 1
        };
    }

    protected override void OnBeforeProcess(
        byte[] rawData)
    {
        CallLog.Add("OnBeforeProcess");
    }

    protected override void OnAfterProcess(
        ProcessingResult result)
    {
        CallLog.Add("OnAfterProcess");
    }
}

public class TemplateMethodOrderTests
{
    [Fact]
    public void ProcessDocument_ExecutesStepsInOrder()
    {
        var spy = new SpyDocumentProcessor();

        spy.ProcessDocument(
            System.Text.Encoding.UTF8
                .GetBytes("test"));

        Assert.Equal(
        [
            "OnBeforeProcess",
            "Validate",
            "Parse",
            "Transform",
            "OnAfterProcess"
        ],
            spy.CallLog);
    }

    [Fact]
    public void ProcessDocument_AlwaysCallsHooks()
    {
        var spy = new SpyDocumentProcessor();

        spy.ProcessDocument(new byte[] { 1, 2, 3 });

        Assert.Contains(
            "OnBeforeProcess", spy.CallLog);
        Assert.Contains(
            "OnAfterProcess", spy.CallLog);
    }
}

Integration Test with DI Container

An integration test wires everything through the DI container to verify that the factory correctly resolves processors:

public class DocumentProcessingIntegrationTests
{
    private readonly ServiceProvider _provider;

    public DocumentProcessingIntegrationTests()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddDocumentProcessing();
        _provider = services.BuildServiceProvider();
    }

    [Theory]
    [InlineData("pdf")]
    [InlineData("csv")]
    [InlineData("xml")]
    public void Factory_ResolvesProcessor_ForEachType(
        string documentType)
    {
        var factory = _provider
            .GetRequiredService<
                DocumentProcessorFactory>();

        var processor = factory
            .GetProcessor(documentType);

        Assert.NotNull(processor);
    }

    [Fact]
    public void Factory_ThrowsForUnknownType()
    {
        var factory = _provider
            .GetRequiredService<
                DocumentProcessorFactory>();

        Assert.Throws<ArgumentException>(
            () => factory.GetProcessor("docx"));
    }

    [Fact]
    public void BatchProcessing_HandlesMultipleTypes()
    {
        var service = _provider
            .GetRequiredService<
                DocumentIngestionService>();

        var documents = new List<(string, byte[])>
        {
            ("csv",
                System.Text.Encoding.UTF8.GetBytes(
                    "Date,Category,Amount," +
                    "Description
" +
                    "2026-01-15,Office,100,Pens"))
        };

        var results = service
            .ProcessBatch(documents);

        Assert.Single(results);
        Assert.True(results[0].Success);
    }
}

The spy subclass test is the most important one for the template method pattern specifically. It proves that the sealed ProcessDocument method calls steps in the exact order defined by the base class -- regardless of what the concrete processor does in each step. If someone accidentally reorders calls in the base class, this test catches it immediately.

Frequently Asked Questions

What is the template method pattern in C#?

The template method pattern is a behavioral design pattern where an abstract base class defines the skeleton of an algorithm in a method -- often sealed to prevent overriding -- and defers specific steps to subclasses. In C#, you implement it with an abstract class containing a sealed method that calls abstract and virtual methods. Subclasses override those methods to provide step-specific behavior while the base class controls the overall sequence. Our document processing pipeline is a practical example: the pipeline order never changes, but each document type implements validation, parsing, and transformation differently.

How is the template method pattern different from the strategy pattern?

The strategy pattern uses composition -- you inject an algorithm implementation at runtime through an interface. The template method pattern uses inheritance -- the algorithm skeleton lives in a base class and subclasses fill in the varying steps. With the strategy pattern, you can swap behavior at runtime. With the template method pattern, the behavior is fixed at compile time through the class hierarchy. Use the strategy pattern when you need runtime flexibility. Use the template method pattern when the algorithm sequence is invariant and only individual steps change.

When should I use virtual hooks versus abstract methods?

Use abstract methods for steps that every subclass must implement because there's no sensible default. Use virtual hooks for optional extension points where most subclasses won't need custom behavior. In our document processor, Validate, Parse, and Transform are abstract because every document type handles them differently. OnBeforeProcess and OnAfterProcess are virtual hooks because most processors don't need pre- or post-processing logic -- only the PDF processor overrides OnBeforeProcess and only the XML processor overrides OnAfterProcess.

Can I use the template method pattern with dependency injection?

Absolutely. Register each concrete processor in IServiceCollection and use a factory to resolve the right one at runtime. The factory pattern pairs naturally with the template method pattern because the factory returns the base type (DocumentProcessor) while the DI container constructs the concrete type with all its dependencies injected. This keeps the calling code decoupled from specific processor implementations.

How do I test the template method pattern?

Test at two levels. First, test each concrete processor independently with valid and invalid inputs to verify step-specific logic. Second, use a spy subclass that records which methods were called and in what order. The spy test is critical because it verifies that the base class enforces the correct algorithm sequence. If someone modifies the template method, the spy test catches the sequencing change immediately.

Does the template method pattern violate the open/closed principle?

The template method pattern supports the open/closed principle when designed correctly. The sealed template method is closed for modification -- no subclass can change the algorithm sequence. But the pattern is open for extension through abstract methods and virtual hooks. You can add new document types by creating new subclasses without modifying the base class or any existing processor. The key is sealing the template method so the invariant sequence can't be accidentally altered.

How does the template method pattern compare to the composite pattern?

The composite pattern structures objects into tree hierarchies and lets clients treat individual objects and compositions uniformly. The template method pattern structures an algorithm into fixed and variable steps across an inheritance hierarchy. They solve fundamentally different problems. You might combine both -- for example, a composite structure where each node uses a template method to process its children in a defined sequence -- but each pattern addresses a distinct design concern.

Wrapping Up This Template Method Pattern Real-World Example

This implementation demonstrates the template method pattern solving a real pipeline problem -- a document processing system where PDF invoices, CSV reports, and XML data feeds all follow the same validate-parse-transform-audit sequence but each step varies by document type. We started with duplicated pipeline logic across three processor classes and ended with a clean inheritance hierarchy: one base class controlling the sequence, three concrete processors implementing the steps, a factory for runtime selection, and a resilience layer for error handling.

The template method pattern shines whenever your domain has an invariant algorithm with varying steps. Document processing pipelines are one example. ETL workflows are another. Request handling middleware that always authenticates, authorizes, and logs but does each step differently per endpoint is a third. In each case, the pattern gives you the same benefit: the algorithm sequence lives in one place, and adding a new variant means creating a single subclass with zero modifications to existing code.

Take this document processing system, swap the simulated parsing with real PDF and XML libraries, and you've got a production-ready ingestion pipeline. The template method pattern keeps your pipeline logic centralized, your processor implementations focused, your tests verifiable, and your architecture open to whatever document type the business sends next.

Factory Method Pattern Real-World Example in C#: Complete Implementation

See Factory Method pattern in action with a complete real-world C# example. Step-by-step implementation of a payment processing system using factory methods.

Template Method Design Pattern in C#: Complete Guide with Examples

Master the template method design pattern in C# with practical examples showing inheritance-based algorithm customization and real-world .NET implementations.

When to Use Template Method Pattern in C#: Decision Guide with Examples

Discover when to use the template method pattern in C# with decision criteria, practical scenarios, and examples showing where it fits best in your codebase.

An error has occurred. This application may no longer respond until reloaded. Reload