Template Method

Template Method

Making tea and making coffee follow the same recipe: boil water, brew the drink, pour into a cup, add condiments. The steps are identical; only the brewing and condiment details differ — tea steeps leaves and adds lemon, coffee uses grounds and adds sugar. The recipe template is fixed; specific steps are customized.

The Template Method pattern defines the skeleton of an algorithm in a base class, letting subclasses override specific steps without changing the overall structure. The base class declares a template method that calls a fixed sequence of steps — some concrete (shared by all subclasses), some abstract or virtual (customized by each subclass). In an e-commerce system, ReportGenerator.Generate() always follows fetch data → validate → format → write. The PDF, CSV, and Excel subclasses override only FormatReport() and WriteOutput() while sharing the orchestration logic.

sequenceDiagram
    participant Client
    participant Base as ReportGenerator
    participant Sub as PdfReportGenerator
    Client->>Base: Generate
    Base->>Base: FetchData - shared
    Base->>Base: ValidateData - shared
    Base->>Sub: FormatReport - overridden
    Base->>Sub: WriteOutput - overridden
    Sub-->>Client: Report complete

Problem

PdfReportGenerator, CsvReportGenerator, and ExcelReportGenerator each independently implement the same fetch → validate → format → write lifecycle, duplicating orchestration logic:

public class PdfReportGenerator
{
    public async Task<byte[]> GenerateAsync(Guid orderId)
    {
        // ⚠️ Fetch, validate, and audit logic duplicated in every generator
        var order = await _repository.GetAsync(orderId);
        if (order is null) throw new NotFoundException(orderId);
        await _auditLog.RecordAsync($"Report generated for order {orderId}");

        // Format-specific logic
        var pdf = new PdfDocument();
        pdf.AddPage().AddTable(order.Items.Select(i => new[] { i.ProductId.ToString(), i.Quantity.ToString() }));
        return pdf.Save();
    }
}

public class CsvReportGenerator
{
    public async Task<byte[]> GenerateAsync(Guid orderId)
    {
        // ⚠️ Same fetch/validate/audit — copy-pasted
        var order = await _repository.GetAsync(orderId);
        if (order is null) throw new NotFoundException(orderId);
        await _auditLog.RecordAsync($"Report generated for order {orderId}");

        // Format-specific logic
        var sb = new StringBuilder("ProductId,Quantity,UnitPrice\n");
        foreach (var item in order.Items)
            sb.AppendLine($"{item.ProductId},{item.Quantity},{item.UnitPrice}");
        return Encoding.UTF8.GetBytes(sb.ToString());
    }
}
// ⚠️ Adding ExcelReportGenerator = copy-paste the fetch/validate/audit block again

Here's what breaks when requirements change: adding a new audit field to the report generation lifecycle requires editing every generator class.

Solution

ReportGenerator base class defines the algorithm skeleton; subclasses override only the format-specific steps:

// Abstract base — defines the template method
public abstract class ReportGenerator
{
    // ✅ Template method — sealed, defines the algorithm skeleton
    public async Task<Report> GenerateAsync(Guid orderId)
    {
        var order = await FetchDataAsync(orderId);    // step 1: always the same
        ValidateData(order);                           // step 2: always the same
        await RecordAuditAsync(orderId);               // step 3: always the same
        var content = await FormatReportAsync(order);  // step 4: subclass-specific
        return new Report(GetContentType(), content);  // step 5: uses subclass value
    }

    // Fixed steps — shared implementation
    protected virtual async Task<Order> FetchDataAsync(Guid orderId)
    {
        var order = await Repository.GetAsync(orderId);
        return order ?? throw new NotFoundException(orderId);
    }

    protected virtual void ValidateData(Order order)
    {
        if (order.Items.Count == 0)
            throw new InvalidOperationException("Cannot generate report for empty order");
    }

    private Task RecordAuditAsync(Guid orderId) =>
        AuditLog.RecordAsync($"Report generated for order {orderId} as {GetContentType()}");

    // Abstract steps — subclasses must implement
    protected abstract Task<byte[]> FormatReportAsync(Order order);
    protected abstract string GetContentType();

    protected IOrderRepository Repository { get; init; } = null!;
    protected IAuditLog AuditLog { get; init; } = null!;
}

// Concrete implementations — override only format-specific steps
public class PdfReportGenerator(IOrderRepository repository, IAuditLog auditLog) : ReportGenerator
{
    protected override Task<byte[]> FormatReportAsync(Order order)
    {
        var pdf = new PdfDocument();
        var page = pdf.AddPage();
        page.AddHeading($"Order #{order.Id}");
        page.AddTable(order.Items.Select(i => new[] { i.ProductId.ToString(), i.Quantity.ToString(), i.UnitPrice.ToString("C") }));
        return Task.FromResult(pdf.Save());
    }

    protected override string GetContentType() => "application/pdf";
}

public class CsvReportGenerator(IOrderRepository repository, IAuditLog auditLog) : ReportGenerator
{
    protected override Task<byte[]> FormatReportAsync(Order order)
    {
        var sb = new StringBuilder("ProductId,Quantity,UnitPrice\n");
        foreach (var item in order.Items)
            sb.AppendLine($"{item.ProductId},{item.Quantity},{item.UnitPrice:F2}");
        return Task.FromResult(Encoding.UTF8.GetBytes(sb.ToString()));
    }

    protected override string GetContentType() => "text/csv";
}

// ✅ Adding Excel = new subclass, zero changes to base class or other generators
public class ExcelReportGenerator(IOrderRepository repository, IAuditLog auditLog) : ReportGenerator
{
    protected override Task<byte[]> FormatReportAsync(Order order)
    {
        using var workbook = new XLWorkbook();
        var sheet = workbook.AddWorksheet("Order");
        // ... populate Excel
        using var stream = new MemoryStream();
        workbook.SaveAs(stream);
        return Task.FromResult(stream.ToArray());
    }

    protected override string GetContentType() =>
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}

Adding an Excel generator now means one new subclass — the fetch, validate, and audit logic is inherited automatically.

You Already Use This

BackgroundService.ExecuteAsync() — the template method for hosted services. BackgroundService defines the lifecycle: start → execute → stop. ExecuteAsync(CancellationToken) is the abstract step you override. The base class handles registration, cancellation, and error handling.

Stream abstract classRead(), Write(), Seek() are abstract steps. CopyToAsync() is a template method that calls ReadAsync() and WriteAsync() in a loop — the algorithm is fixed; the I/O implementation varies per stream type.

DbContext.OnModelCreating() — EF Core's template method for model configuration. The base DbContext calls OnModelCreating() during model building; you override it to configure entities. The overall model-building algorithm is fixed; your configuration is the variable step.

AuthenticationHandler<T>.HandleAuthenticateAsync()ASP.NET Core authentication handlers use Template Method. The base class handles scheme registration, result caching, and challenge/forbid responses. HandleAuthenticateAsync() is the abstract step you implement.

Questions

References


Whats next