Template Method in C#: Inheritance Hooks vs Strategy
Template Method pattern in C# / .NET 10: when an abstract base with hook methods is right, when Strategy injection wins, and how to avoid inheritance lock-in.
Table of contents
- What problem does the Template Method pattern solve in C#?
- How does the textbook Template Method look in .NET 10?
- How does ASP.NET Core's BackgroundService use Template Method?
- When should you replace Template Method with Strategy?
- When does the Template Method pattern misfire?
- How does Template Method compare to Strategy and State?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
Three checkout flows: web, mobile, in-store kiosk. All three load the cart, validate inventory, charge a card, persist the order, and send an email — in that order. The differences are small. Web shows a confirmation page; mobile pushes a notification; the kiosk prints a receipt. The temptation is three near-identical methods, eighty per cent shared, copy-pasted.
The Template Method pattern is the answer the GoF gave for this. Put the shared skeleton in an abstract base class, define hook methods for the parts that vary, and let each subclass fill in its hook. The pattern was the bread-and-butter of 1990s object orientation. In modern C# it competes with composition (Strategy) and the choice is non-trivial — this article is about when each wins.
What problem does the Template Method pattern solve in C#?
The pattern earns its place when several variants share a fixed skeleton and differ in one or two well-named steps. Three concrete shapes:
- Lifecycle methods. ASP.NET Core's
BackgroundServicedefines start/stop/execute; you override onlyExecuteAsync. - Configurable algorithms with required hooks. A base
Importerreads files, parses each row, validates, persists — only the parser changes per file format. - Inheritable templates with optional steps. A base
ReportBuilderdefines header/body/footer; subclasses override only what they need.
What is not a Template Method problem: "I want to swap an algorithm at runtime" — that is Strategy. "I want different behaviour based on lifecycle stage" — that is State. Template Method is specifically about fixed skeleton, variable hooks, via inheritance.
How does the textbook Template Method look in .NET 10?
A public non-virtual Run() (the template), one or more
protected abstract or protected virtual hooks:
public abstract class CheckoutFlow
{
protected readonly ICartRepository Carts;
protected readonly IPaymentProcessor Payment;
protected readonly IOrderRepository Orders;
protected CheckoutFlow(ICartRepository carts, IPaymentProcessor payment, IOrderRepository orders)
=> (Carts, Payment, Orders) = (carts, payment, orders);
// The template — non-virtual, defines the algorithm.
public async Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct)
{
var cart = await Carts.GetAsync(req.CartId, ct);
var charge = await Payment.PayAsync(cart.Total, cart.CustomerId, req.CartId, ct);
if (charge.Status != "succeeded")
throw new CheckoutFailedException(charge.Error ?? "Unknown");
var order = await Orders.CreateAsync(cart, charge.TransactionId!, ct);
await NotifyCustomerAsync(order, ct); // hook
return BuildResult(order); // hook
}
protected abstract Task NotifyCustomerAsync(Order order, CancellationToken ct);
protected abstract CheckoutResult BuildResult(Order order);
}
public sealed class WebCheckoutFlow : CheckoutFlow
{
private readonly IEmailService _emails;
public WebCheckoutFlow(ICartRepository carts, IPaymentProcessor payment,
IOrderRepository orders, IEmailService emails)
: base(carts, payment, orders) => _emails = emails;
protected override Task NotifyCustomerAsync(Order order, CancellationToken ct)
=> _emails.SendOrderConfirmationAsync(order.CustomerEmail, order.Id, ct);
protected override CheckoutResult BuildResult(Order order)
=> new(order.Id, order.Total, "/checkout/done?id=" + order.Id);
}
public sealed class KioskCheckoutFlow : CheckoutFlow
{
private readonly IReceiptPrinter _printer;
public KioskCheckoutFlow(ICartRepository carts, IPaymentProcessor payment,
IOrderRepository orders, IReceiptPrinter printer)
: base(carts, payment, orders) => _printer = printer;
protected override Task NotifyCustomerAsync(Order order, CancellationToken ct)
=> _printer.PrintReceiptAsync(order, ct);
protected override CheckoutResult BuildResult(Order order)
=> new(order.Id, order.Total, ConfirmationUrl: null);
}
The structural picture:
classDiagram
class CheckoutFlow {
<<abstract>>
+CheckoutAsync(req)
#NotifyCustomerAsync(order)*
#BuildResult(order)*
}
class WebCheckoutFlow
class KioskCheckoutFlow
class MobileCheckoutFlow
CheckoutFlow <|-- WebCheckoutFlow
CheckoutFlow <|-- KioskCheckoutFlow
CheckoutFlow <|-- MobileCheckoutFlow
The * marks abstract members; the public CheckoutAsync is
the template. Adding a fourth flow is one new subclass plus two
hook implementations.
How does ASP.NET Core's BackgroundService use Template Method?
Microsoft.Extensions.Hosting.BackgroundService is the canonical
Template Method in modern .NET:
public sealed class OrderRetryService : BackgroundService
{
private readonly IOrderRepository _orders;
private readonly IPaymentProcessor _payment;
public OrderRetryService(IOrderRepository orders, IPaymentProcessor payment)
=> (_orders, _payment) = (orders, payment);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var failed = await _orders.GetFailedRecentAsync(stoppingToken);
foreach (var order in failed)
await _payment.PayAsync(order.Total, order.CustomerId, order.Id, stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
}
}
}
You override exactly one method — ExecuteAsync. The base class
handles StartAsync, StopAsync, cancellation token wiring,
and lifetime registration. The pattern is invisible because it
is so familiar; recognising it tells you what
MichelinController, DbContext.OnConfiguring, and dozens of
other framework hooks are.
When should you replace Template Method with Strategy?
Template Method is inheritance; Strategy is composition. Inheritance has known problems:
- A subclass cannot vary on two axes independently — see Bridge for the alternative.
- Cannot mix two variants at runtime; you commit at class definition time.
- Tests must construct the subclass to test the template.
- The base class's protected surface is a contract that is brittle to refactor.
Replace with Strategy when:
- The variation is "swap one algorithm" rather than "be a variant of this concept".
- You need to combine variations at runtime.
- The hook needs many dependencies the base does not have.
The composition equivalent of the checkout example:
public sealed class CheckoutFlow
{
private readonly ICartRepository _carts;
private readonly IPaymentProcessor _payment;
private readonly IOrderRepository _orders;
private readonly INotificationStrategy _notify;
private readonly IResultBuilder _builder;
public CheckoutFlow(ICartRepository carts, IPaymentProcessor payment, IOrderRepository orders,
INotificationStrategy notify, IResultBuilder builder)
=> (_carts, _payment, _orders, _notify, _builder) = (carts, payment, orders, notify, builder);
public async Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct)
{
var cart = await _carts.GetAsync(req.CartId, ct);
var charge = await _payment.PayAsync(cart.Total, cart.CustomerId, req.CartId, ct);
if (charge.Status != "succeeded") throw new CheckoutFailedException(charge.Error ?? "Unknown");
var order = await _orders.CreateAsync(cart, charge.TransactionId!, ct);
await _notify.NotifyAsync(order, ct);
return _builder.Build(order);
}
}
One concrete class, two strategies, no inheritance. Wiring is DI
registrations: web flow gets EmailNotification + WebResultBuilder,
kiosk gets PrintNotification + KioskResultBuilder.
The trade-off is real. Template Method's inheritance gives you one class per variant — readable, easy to navigate, and clearly named. Composition gives you flexibility and testability at the cost of a few more files and DI lines.
When does the Template Method pattern misfire?
Three traps:
- Subclass needs the base class's private state. The base
carves out a private field; subclasses reach for it via
reflection or
protected internalaccessors. The encapsulation has leaked. Refactor: pass the state to the hook explicitly, or move it to a Strategy. - Hook explosion. Six
protected virtualhooks because every subclass needs something different. The "skeleton" is fictional; nothing real is shared. Promote the variants to separate strategies. - Multiple inheritance temptation. Two unrelated templates both want to be base classes for the same subclass. C# does not have multiple inheritance; the answer is composition.
How does Template Method compare to Strategy and State?
| Pattern | Variation mechanism | Coupling | Modern .NET |
|---|---|---|---|
| Template Method | Inheritance (base + subclass hooks) | Tight (subclass knows base's algorithm) | BackgroundService, ControllerBase |
| Strategy | Composition (inject implementation) | Loose | DI, Func<> |
| State | Self-replacing classes per lifecycle stage | Internal | State machine |
The cleanest split: Template Method is we share a skeleton, you fill the gaps; Strategy is we share an interface, you swap implementations; State is we change which class we are based on what happened. All three vary behaviour; the axis differs.
What does a real .NET 10 example look like?
A pragmatic shape that combines Template Method (for the fixed skeleton) with strategies (for parts that need rich configuration). The base class owns the algorithm; the hooks delegate to strategies when the variant has a real abstraction worth naming:
public abstract class CheckoutFlow
{
protected ICartRepository Carts { get; }
protected IPaymentProcessor Payment { get; }
protected IOrderRepository Orders { get; }
protected CheckoutFlow(ICartRepository carts, IPaymentProcessor payment, IOrderRepository orders)
=> (Carts, Payment, Orders) = (carts, payment, orders);
public async Task<CheckoutResult> CheckoutAsync(CheckoutRequest req, CancellationToken ct)
{
var cart = await Carts.GetAsync(req.CartId, ct)
?? throw new NotFoundException();
var charge = await Payment.PayAsync(cart.Total, cart.CustomerId, req.CartId, ct);
if (charge.Status != "succeeded")
throw new CheckoutFailedException(charge.Error ?? "Unknown");
var order = await Orders.CreateAsync(cart, charge.TransactionId!, ct);
await OnPaidAsync(order, ct); // optional hook
await NotifyCustomerAsync(order, ct); // required hook
return BuildResult(order); // required hook
}
protected virtual Task OnPaidAsync(Order order, CancellationToken ct) => Task.CompletedTask;
protected abstract Task NotifyCustomerAsync(Order order, CancellationToken ct);
protected abstract CheckoutResult BuildResult(Order order);
}
public sealed class WebCheckoutFlow : CheckoutFlow
{
private readonly IEmailService _emails;
public WebCheckoutFlow(ICartRepository carts, IPaymentProcessor payment,
IOrderRepository orders, IEmailService emails)
: base(carts, payment, orders) => _emails = emails;
protected override Task NotifyCustomerAsync(Order order, CancellationToken ct)
=> _emails.SendOrderConfirmationAsync(order.CustomerEmail, order.Id, ct);
protected override CheckoutResult BuildResult(Order order)
=> new(order.Id, order.Total, $"/checkout/done?id={order.Id}");
}
public sealed class KioskCheckoutFlow : CheckoutFlow
{
private readonly IReceiptPrinter _printer;
public KioskCheckoutFlow(ICartRepository carts, IPaymentProcessor payment,
IOrderRepository orders, IReceiptPrinter printer)
: base(carts, payment, orders) => _printer = printer;
protected override async Task OnPaidAsync(Order order, CancellationToken ct)
=> await _printer.PrintReceiptAsync(order, ct); // print *before* notifying
protected override Task NotifyCustomerAsync(Order order, CancellationToken ct)
=> Task.CompletedTask; // already printed
protected override CheckoutResult BuildResult(Order order)
=> new(order.Id, order.Total, ConfirmationUrl: null);
}
// Composition root
builder.Services.AddScoped<WebCheckoutFlow>();
builder.Services.AddScoped<KioskCheckoutFlow>();
// per-context: inject the right one based on caller
// Test the template via a minimal subclass
public sealed class TestFlow : CheckoutFlow
{
public TestFlow(ICartRepository c, IPaymentProcessor p, IOrderRepository o) : base(c, p, o) {}
public string? Notified { get; private set; }
protected override Task NotifyCustomerAsync(Order order, CancellationToken ct)
{ Notified = order.Id; return Task.CompletedTask; }
protected override CheckoutResult BuildResult(Order order)
=> new(order.Id, order.Total, null);
}
[Fact]
public async Task Template_runs_hooks_in_order()
{
var sut = new TestFlow(/* mocks */);
var result = await sut.CheckoutAsync(new CheckoutRequest("c1", "stripe"), default);
Assert.NotNull(sut.Notified);
}
What is on the page: a fixed skeleton. Two real subclasses. One
test subclass to exercise the template. Adding a fourth flow is
one subclass and two methods. Not on the page: a switch over
flow type, copy-pasted skeleton, or a five-axis Strategy zoo.
Where should you read next in this series?
- Previous: Strategy — composition rather than inheritance for varying behaviour.
- Next: Visitor — the last behavioural pattern; adding operations to a closed set of types.
- Cross-reference: Bridge — when two axes vary independently and inheritance hierarchies multiply.
- Cross-reference: State — when the variation is by lifecycle, not by class identity.
- Decision tree: How to choose the right design pattern.
A practical rule: default to composition; reach for Template
Method when the skeleton genuinely is the abstraction worth
naming. ASP.NET Core uses Template Method for BackgroundService
because "thing that runs in the background" is a real concept.
Your application code mostly is not — and Strategy is the
lighter, more testable shape.
Frequently asked questions
When should I prefer Template Method over Strategy?
Is the ASP.NET Core BackgroundService class a Template Method?
BackgroundService defines StartAsync/StopAsync/ExecuteAsync lifecycle, and you override only ExecuteAsync. The base class controls the algorithm; you fill in one hook. Same shape as ControllerBase, IdentityUserStore, DbContext.OnModelCreating — the framework uses Template Method extensively.How do I avoid inheritance lock-in with Template Method?
Run() and make the hooks protected abstract. Document which hooks are required and which are optional with protected virtual. The day a subclass needs to vary something the base did not anticipate, refactor to inject a Strategy for that step rather than carving a new protected virtual method as an afterthought.Should hook methods throw if not overridden?
abstract — the compiler enforces. If the hook is optional, give it a virtual no-op default. Throwing NotImplementedException from a virtual is a sign the contract is unclear; convert to abstract or provide a real default.