Template Method Pattern: Algorithm Skeletons, Hooks and the Delegate-Based Alternative
Template Method fixes an algorithm's skeleton in a non-overridable base class method and leaves the varying steps to abstract methods or empty hooks. The hook silently skipped by a forgotten override (CS0114), Stream.CopyTo and BackgroundService.ExecuteAsync whose behavior changed in .NET 10, the virtual-call-in-constructor trap (CA2214), and building the same flow with delegates through composition.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Every night Bookstore receives catalog files from publishers. One publisher sends CSV (Comma-Separated Values), another JSON (JavaScript Object Notation), a third fixed-width text. The format differs but the job is always the same: read the records, parse each one, validate, transform, write to the catalog, report the rejects. The team put that flow into a base class:
namespace Bookstore.Import;
public sealed record BookRow(string Isbn, string Title, decimal Price);
public sealed record ImportReport(int Imported, int Rejected);
public abstract class CatalogImport
{
// Template method: the flow is fixed here, subclasses can't change the order
public ImportReport Run(string source)
{
int imported = 0;
int rejected = 0;
foreach (string raw in ReadRecords(source))
{
BookRow? row = Parse(raw);
if (row is null || !IsValid(row))
{
rejected++;
OnRowRejected(raw);
continue;
}
Write(Transform(row));
imported++;
}
OnCompleted(imported, rejected);
return new ImportReport(imported, rejected);
}
// Primitive operations: each format writes its own
protected abstract IEnumerable<string> ReadRecords(string source);
protected abstract BookRow? Parse(string raw);
protected abstract void Write(BookRow row);
// Steps with a default
protected virtual bool IsValid(BookRow row) => row.Isbn.Length == 13 && row.Price > 0m;
protected virtual BookRow Transform(BookRow row) => row with { Title = row.Title.Trim() };
// Hooks: empty default, override if you want
protected virtual void OnRowRejected(string raw) { }
protected virtual void OnCompleted(int imported, int rejected) { }
}
A new developer writes the CSV import and wants to print rejected rows to the console:
using Bookstore.Import;
const string csv = """
isbn;title;price
9780441013593; Dune ;12.99
978044101;Short Isbn;9.50
9780141439518;Emma;7.50
""";
CsvCatalogImport import = new();
ImportReport report = import.Run(csv);
Console.WriteLine(report);
Console.WriteLine(string.Join(", ", import.Catalog.Select(b => $"[{b.Title}]")));
public class CsvCatalogImport : CatalogImport
{
public List<BookRow> Catalog { get; } = [];
protected override IEnumerable<string> ReadRecords(string source) =>
source.Split('\n', StringSplitOptions.RemoveEmptyEntries).Skip(1);
protected override BookRow? Parse(string raw)
{
string[] parts = raw.Split(';');
return parts.Length == 3 && decimal.TryParse(parts[2], System.Globalization.CultureInfo.InvariantCulture, out decimal price)
? new BookRow(parts[0], parts[1], price)
: null;
}
protected override void Write(BookRow row) => Catalog.Add(row);
protected void OnRowRejected(string raw) => Console.WriteLine($"rejected: {raw}");
}
The file has three books and the second one's ISBN (International Standard Book Number) isn't 13 digits. Which lines does the program print? Will we see a line starting with rejected:?
The Skeleton of an Algorithm and Primitive Operations
The build ends with a warning, and the output is:
Program.cs(32,20): warning CS0114: 'CsvCatalogImport.OnRowRejected(string)' hides inherited member 'CatalogImport.OnRowRejected(string)'. To make the current member override that implementation, add the override keyword. Otherwise add the new keyword.
ImportReport { Imported = 2, Rejected = 1 }
[Dune], [Emma]
The report says one row was rejected, but the code that prints which row never ran. There is no override in front of OnRowRejected; C# treats the method not as an implementation that replaces the base class's, but as a new method that hides it. Run, on the other hand, is written in the base class and calls the base class's virtual method, the empty hook. The compiler says exactly that with CS0114. In a project that hasn't turned warnings into errors with TreatWarningsAsErrors, this line gets lost in CI (Continuous Integration) logs and the report runs incomplete for months. Add override and the output becomes what you expected:
rejected: 978044101;Short Isbn;9.50
ImportReport { Imported = 2, Rejected = 1 }
[Dune], [Emma]
The anatomy of this small accident is the Template Method pattern itself. GoF (the Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book) defines its intent like this: define the skeleton of an algorithm in an operation and defer some steps to subclasses, letting subclasses redefine certain steps without changing the algorithm's structure. Run is the template method; the order is written there and no subclass can change it. ReadRecords, Parse and Write are primitive operations: being abstract, every format has to write them. IsValid and Transform are steps with a default; most formats use them as they are and override them when needed.
In Java you have to write final to keep a template method from being overridden. In C#, methods aren't virtual by default, so Run is already locked. That's a good default: the value of a template method comes from the flow living in one place, in one order. Make Run virtual and a subclass can override it and skip validation, and the guarantee the pattern gives is gone.
Hooks and the Hollywood Principle
OnRowRejected and OnCompleted are what GoF calls hooks: steps that come with an empty or sensible default in the base class and don't have to be overridden. An abstract method says "you must write this"; a hook says "you may plug something in here". The distinction is a design decision and it belongs to whoever writes the base class: make every step abstract and every subclass writes pointless empty methods; make every step a hook and it becomes unclear what a subclass must fill in.
GoF notes that this structure inverts the flow of control and calls it the "Hollywood principle": "Don't call us, we'll call you." The subclass doesn't call the base class; the base class calls the subclass's methods at a moment of its own choosing. CsvCatalogImport doesn't know when it will Parse, only how to parse. Almost every framework is built on this idea: ASP.NET Core calls your controller action, xUnit calls your test method, the host calls your BackgroundService.
The root of the bug in Guess First is here too. Since in the Hollywood principle the caller is the base class, you can't tell from the subclass alone whether its method actually gets called. The override keyword is the only visible sign of that link.
Template Method in .NET: Stream
.NET's oldest template method lives in System.IO.Stream. Stream is an abstract class and the basic reading step, Read(byte[], int, int), is abstract. The default implementations of methods like CopyTo, ReadByte, Read(Span<byte>) and ReadAsync are written on top of that single step. Looking at Stream.cs in the dotnet/runtime repository, you'll see CopyTo rent a buffer from ArrayPool<byte>.Shared and call Read in a loop until it returns zero. Let's write a small stream that delivers a book feed in chunks:
using System.Text;
BookFeedStream feed = new("9780441013593;9780141439518"u8.ToArray(), chunkSize: 10);
using MemoryStream copy = new();
feed.CopyTo(copy);
Console.WriteLine(Encoding.UTF8.GetString(copy.ToArray()));
Console.WriteLine($"Read calls: {feed.ReadCalls}");
// We only wrote Read; CopyTo, ReadByte and Read(Span) come from the base class
public sealed class BookFeedStream(byte[] data, int chunkSize) : Stream
{
private int position;
public int ReadCalls { get; private set; }
public override int Read(byte[] buffer, int offset, int count)
{
ReadCalls++;
int n = Math.Min(Math.Min(count, chunkSize), data.Length - position);
Array.Copy(data, position, buffer, offset, n);
position += n;
return n;
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position { get => position; set => throw new NotSupportedException(); }
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
9780441013593;9780141439518
Read calls: 4
We only wrote Read and CopyTo worked. The 27 bytes arrived in chunks of 10: three full reads and a fourth that returns zero to signal the end. The base class managed the loop, the buffer and the termination condition; we just filled in the primitive operation.
The example also shows the pattern's cost: Stream has ten abstract members, and in a read-only, non-seekable stream half of them throw NotSupportedException. When a base class promises a lot, subclasses have to refuse some of those promises. The SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post "Liskov Substitution Principle: Behavioral Subtyping and NotSupportedException in .NET" walks through that tension using Stream; the CanRead, CanSeek and CanWrite properties exist so callers can ask which promises hold.
BackgroundService and the Change in .NET 10
A newer example, and one you'll meet more often day to day, is Microsoft.Extensions.Hosting.BackgroundService. The host calls an IHostedService's StartAsync and StopAsync; BackgroundService implements both itself and leaves you a single abstract step: ExecuteAsync. Wiring the cancellation token, keeping the running task, cancelling the token and awaiting the task on shutdown are the base class's job.
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Services.AddHostedService<CatalogSyncWorker>();
using IHost host = builder.Build();
Stopwatch clock = Stopwatch.StartNew();
await host.StartAsync();
Console.WriteLine($"StartAsync returned after ~{clock.ElapsedMilliseconds / 100 * 100} ms");
await Task.Delay(1200);
await host.StopAsync();
Console.WriteLine("Host stopped");
public sealed class CatalogSyncWorker : BackgroundService
{
// BackgroundService sets up the flow; we only fill in this step
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Thread.Sleep(500);
Console.WriteLine("Worker: synchronous warm-up done");
using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(300));
try
{
while (await timer.WaitForNextTickAsync(stoppingToken))
{
Console.WriteLine("Worker: syncing catalog");
}
}
catch (OperationCanceledException)
{
Console.WriteLine("Worker: stopping");
}
}
}
StartAsync returned after ~0 ms
Worker: synchronous warm-up done
Worker: syncing catalog
Worker: syncing catalog
Worker: stopping
Host stopped
The worker's first half second is a deliberately synchronous Thread.Sleep. Even so, StartAsync returned immediately. This is a live example of how changing the inside of a template method can change the behavior of every subclass at once: according to the "BackgroundService runs all of ExecuteAsync as a Task" breaking change page on Microsoft Learn, before .NET 10 the synchronous part of ExecuteAsync up to the first await ran on the application's startup thread and blocked other hosted services from starting. Since .NET 10, StartAsync launches all of ExecuteAsync through Task.Run; you can see that line in BackgroundService.cs in the runtime source. No worker's code changed, yet all of their startup behavior did.
It's a double-edged sword. For whoever writes the base class it means fixing a bug in one place; for whoever writes the subclass it means a change somewhere outside their control affects their code. If you have a preparation step that must run synchronously at startup, the page suggests moving it to the constructor or to an overridden StartAsync before base.StartAsync.
The Delegate-Based Alternative
Template Method works through inheritance, which means a new class and a hierarchy for every new format. You can get the same result with composition: the flow still lives in one class and the varying steps arrive through the constructor as delegates.
using System.Globalization;
using Bookstore.Import;
const string csv = """
isbn;title;price
9780441013593; Dune ;12.99
978044101;Short Isbn;9.50
9780141439518;Emma;7.50
""";
List<BookRow> catalog = [];
CatalogImporter importer = new(
read: source => source.Split('\n', StringSplitOptions.RemoveEmptyEntries).Skip(1),
parse: CsvRow.Parse,
write: catalog.Add,
onRejected: raw => Console.WriteLine($"rejected: {raw}"));
Console.WriteLine(importer.Run(csv));
Console.WriteLine(string.Join(", ", catalog.Select(b => $"[{b.Title}]")));
// No inheritance: the steps come through the constructor, the flow is still in one place
public sealed class CatalogImporter(
Func<string, IEnumerable<string>> read,
Func<string, BookRow?> parse,
Action<BookRow> write,
Action<string>? onRejected = null)
{
public ImportReport Run(string source)
{
int imported = 0;
int rejected = 0;
foreach (string raw in read(source))
{
BookRow? row = parse(raw);
if (row is null || row.Isbn.Length != 13 || row.Price <= 0m)
{
rejected++;
onRejected?.Invoke(raw);
continue;
}
write(row with { Title = row.Title.Trim() });
imported++;
}
return new ImportReport(imported, rejected);
}
}
public static class CsvRow
{
public static BookRow? Parse(string raw)
{
string[] parts = raw.Split(';');
return parts.Length == 3 && decimal.TryParse(parts[2], CultureInfo.InvariantCulture, out decimal price)
? new BookRow(parts[0], parts[1], price)
: null;
}
}
rejected: 978044101;Short Isbn;9.50
ImportReport { Imported = 2, Rejected = 1 }
[Dune], [Emma]
The output is identical to the inheritance version. The difference is structural: CatalogImporter is sealed, none of its steps can be overridden, yet every step can be swapped from outside. Hooks here are optional parameters; if onRejected isn't passed, it isn't called. The forgotten-override bug from Guess First can't happen in this shape, because there is nothing to override. If one step grows and wants its own dependencies, you replace the Func with an interface and what you have is the previous post's Strategy. Template Method and Strategy are two solutions to the same problem: one plugs in the varying step through inheritance, the other through composition. The SOLID and Design Principles series post "Composition over Inheritance: Fragile Base Class, Delegation and Pluggable Behavior" discusses that choice in general.
| Question | Template Method (inheritance) | Delegate or Strategy (composition) |
|---|---|---|
| How do steps vary? | A subclass overrides | A different delegate or object is passed to the constructor |
| How is a required step expressed? | An abstract method, enforced by the compiler | A non-nullable parameter |
| State shared between steps | The base class's protected members | Passed explicitly as parameters |
| Changing a step at runtime | Not possible, a class is chosen | Possible |
| When reading | Flow in the base class, steps in subclasses | Flow and steps visible at the call site |
Common Pitfalls
Calling a virtual method from a constructor. If a base class constructor behaves like a template method and calls an abstract step, that step runs before the subclass's constructor has run:
Console.WriteLine(new PriceListImport("EUR").Describe());
Console.WriteLine(new PrimaryPriceListImport("EUR").Describe());
public abstract class ImportJob
{
private readonly string description;
protected ImportJob()
{
description = BuildDescription();
}
protected abstract string BuildDescription();
public string Describe() => description;
}
public sealed class PriceListImport : ImportJob
{
private readonly string? currency;
public PriceListImport(string currency)
{
this.currency = currency;
}
protected override string BuildDescription() => $"Price list in {currency ?? "<null>"}";
}
public sealed class PrimaryPriceListImport(string currency) : ImportJob
{
protected override string BuildDescription() => $"Price list in {currency}";
}
Program.cs(10,23): warning CA2214: Do not call overridable methods in constructors (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2214)
Price list in <null>
Price list in EUR
PriceListImport's currency field is assigned in the constructor body, but the base constructor runs first, so BuildDescription saw null. Even with nullable reference types enabled, the compiler didn't catch it; had we not declared the field as string?, we'd have read null from a "non-nullable" field. That the second class works is an interesting C# detail: when a primary constructor parameter is captured into a field, that assignment happens before the base constructor, just like field initializers. Rather than relying on that detail, follow the rule: don't call overridable methods in a constructor. CA2214 catches it, but it's off by default; here we turned it on with dotnet_diagnostic.CA2214.severity = warning in .editorconfig.
Deep hierarchies. In a chain like CatalogImport → DelimitedCatalogImport → CsvCatalogImport → PublisherXCsvImport, each level overrides a few hooks, some call base. and some don't. To find out why a row was rejected you need to read four files together. A template method hierarchy deeper than two levels usually says it's time to go back to composition.
Leaving it unclear whether a hook must call base. If a hook's default implementation isn't empty (say, it writes a log), should an override call base.OnCompleted(...)? If the code doesn't say, everyone decides differently. Either always keep hooks empty and put mandatory work in the template method itself, or state it plainly in the XML (Extensible Markup Language) documentation comment.
Making the template method virtual. Making Run overridable gives subclasses permission to rewrite the flow. A subclass that does so and skips validation ends the base class's "every row is validated" guarantee.
When Not to Use It
When there is only one implementation. An abstract base class and a single subclass for one import format are just one class split across two files. A plain class is enough until the second format arrives.
When steps change at runtime. If the parser to use comes from configuration or from the user's choice, inheritance is the wrong tool; you can't swap a subclass at runtime. That's what the delegate or strategy version is for.
When the steps aren't independent. Template Method works well when the order of steps is fixed and each step's input and output are clear. If subclasses keep peeking at the base class's protected state to manipulate the flow, the skeleton isn't really fixed.
A Small Experiment
★☆☆ Add <TreatWarningsAsErrors>true</TreatWarningsAsErrors> to the project and rebuild the Guess First code. What does the error say? Then write new instead of override: does the warning go away, and does the hook get called?
★★☆ Add a hook named ShouldSkip(string raw) to CatalogImport: it defaults to false, and when it returns true the row is neither imported nor rejected. Write a CSV import that skips comment lines starting with # and check the numbers in the report.
★★★ Write the same JSON Lines (one JSON object per line) import twice: once by deriving from CatalogImport, once by passing delegates to CatalogImporter. Let the parse step use System.Text.Json. In which version was writing unit tests easier, and in which was reading the order of steps easier?
With Template Method we fixed the skeleton in a base class and left the steps to subclasses; adding a new format was cheap, adding a new step was expensive. The next post looks at the opposite world: the type hierarchy is fixed and new operations keep arriving. The Visitor pattern, double dispatch, ExpressionVisitor and how it all compares to pattern matching.