Template Method Pattern: Algoritma İskeleti, Hook'lar ve Delegate'li Alternatifi
Template Method, algoritmanın iskeletini base class'taki override edilemeyen bir method'a sabitler ve değişen adımları abstract method'lara ya da boş hook'lara bırakır. Unutulan override'ın sessizce yutulan hook'u (CS0114), Stream.CopyTo ve .NET 10'da davranışı değişen BackgroundService.ExecuteAsync, constructor'dan virtual çağrı tuzağı (CA2214) ve aynı akışı delegate'lerle composition ile kurmak.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore her gece yayınevlerinden katalog dosyası alıyor. Bir yayınevi CSV (Comma-Separated Values) gönderiyor, bir başkası JSON (JavaScript Object Notation), bir üçüncüsü sabit genişlikli metin. Format farklı ama iş hep aynı: kayıtları oku, her birini parse et, doğrula, dönüştür, kataloğa yaz, reddedilenleri raporla. Ekip bu akışı bir base class'a koymuş:
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: akış burada sabit, alt class'lar sırayı değiştiremez
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 operation'lar: her format kendisi yazar
protected abstract IEnumerable<string> ReadRecords(string source);
protected abstract BookRow? Parse(string raw);
protected abstract void Write(BookRow row);
// Varsayılanı olan adımlar
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() };
// Hook'lar: boş varsayılan, isteyen override eder
protected virtual void OnRowRejected(string raw) { }
protected virtual void OnCompleted(int imported, int rejected) { }
}
Yeni gelen bir geliştirici CSV import'unu yazıyor ve reddedilen satırları ekrana basmak istiyor:
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}");
}
Dosyada üç kitap var ve ikincisinin ISBN'i (International Standard Book Number) 13 hane değil. Program hangi satırları basar? rejected: ile başlayan bir satır görecek miyiz?
Algoritmanın İskeleti ve Primitive Operation'lar
Derleme bir uyarıyla bitiyor, çıktı da şöyle:
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]
Rapor bir satırın reddedildiğini söylüyor ama hangi satır olduğunu basan kod hiç çalışmadı. OnRowRejected'ın önünde override yok; C# bu method'u base class'takinin yerine geçen bir implementation değil, onu gizleyen yeni bir method olarak görüyor. Run ise base class'ta yazılmış ve base class'ın virtual method'unu çağırıyor, yani boş hook'u. Derleyici CS0114 ile tam olarak bunu söylüyor. Uyarıyı TreatWarningsAsErrors ile hataya çevirmemiş bir projede bu satır CI (Continuous Integration) loglarında kaybolur ve rapor aylarca eksik çalışır. override eklediğinde çıktı beklenen hâline geliyor:
rejected: 978044101;Short Isbn;9.50
ImportReport { Imported = 2, Rejected = 1 }
[Dune], [Emma]
Bu küçük kazanın anatomisi Template Method pattern'inin ta kendisi. GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) amacını şöyle tanımlar: bir işlemdeki algoritmanın iskeletini tanımla, bazı adımlarını alt class'lara bırak; alt class'lar algoritmanın yapısını değiştirmeden belirli adımlarını yeniden tanımlayabilsin. Run template method; sıra onda yazılı ve hiçbir alt class onu değiştiremiyor. ReadRecords, Parse ve Write primitive operation'lar: abstract oldukları için her format onları yazmak zorunda. IsValid ve Transform varsayılanı olan adımlar; çoğu format olduğu gibi kullanır, gerekirse override eder.
Java'da template method'un değiştirilmemesi için final yazmak gerekir. C#'ta method'lar varsayılan olarak virtual değildir, o yüzden Run zaten kilitli. Bu iyi bir varsayılan: template method'un değeri, akışın tek yerde ve tek sırada olmasından geliyor. Run'ı virtual yaparsan bir alt class onu override edip doğrulamayı atlayabilir ve pattern'in verdiği garanti ortadan kalkar.
Hook'lar ve Hollywood Principle
OnRowRejected ve OnCompleted GoF'un hook dediği şey: base class'ta boş ya da makul bir varsayılanla gelen, override edilmesi zorunlu olmayan adımlar. Abstract method "bunu sen yazmak zorundasın" der, hook ise "istersen buraya bir şey takabilirsin". Ayrım bir tasarım kararıdır ve base class'ı yazanın sorumluluğundadır: her adımı abstract yaparsan her alt class gereksiz boş method'lar yazar, her adımı hook yaparsan alt class'ın neyi mutlaka doldurması gerektiği belirsizleşir.
GoF bu yapının kontrol akışını tersine çevirdiğini söyler ve bunu "Hollywood principle" diye anar: "Don't call us, we'll call you." Alt class base class'ı çağırmıyor; base class alt class'ın method'larını, kendi seçtiği anda çağırıyor. CsvCatalogImport ne zaman Parse edileceğini bilmiyor, yalnızca nasıl parse edileceğini biliyor. Framework'lerin neredeyse tamamı bu fikir üzerine kurulu: ASP.NET Core bir controller action'ını, xUnit bir test method'unu, host bir BackgroundService'i senin yerine çağırır.
Önce Tahmin Et'teki hatanın kökü de burada. Hollywood principle'da çağrıyı yapan taraf base class olduğu için, alt class'ın method'unun gerçekten çağrılıp çağrılmadığını alt class'ın kendisinden okuyamazsın. override anahtar kelimesi bu bağın tek görünür işareti.
.NET'te Template Method: Stream
.NET'in en eski template method'u System.IO.Stream'de. Stream abstract bir class ve okumanın temel adımı Read(byte[], int, int) abstract. CopyTo, ReadByte, Read(Span<byte>) ve ReadAsync gibi method'ların varsayılan implementation'ları bu tek adımın üzerine yazılmış. dotnet/runtime deposundaki Stream.cs'e bakınca CopyTo'nun ArrayPool<byte>.Shared'den bir buffer kiralayıp Read sıfır dönene kadar döngüde çağırdığını görürsün. Bir kitap feed'ini parça parça veren küçük bir stream yazalım:
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}");
// Yalnız Read'i yazdık; CopyTo, ReadByte ve Read(Span) base class'tan geliyor
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
Yalnızca Read'i yazdık ve CopyTo çalıştı. 27 byte'lık veri 10'ar byte'lık parçalarla geldi: üç dolu okuma ve akışın bittiğini söyleyen, sıfır dönen bir dördüncü. Döngüyü, buffer'ı ve bitiş koşulunu base class yönetti; biz yalnızca primitive operation'ı doldurduk.
Örnek pattern'in bedelini de gösteriyor: Stream'in on abstract member'ı var ve yalnızca okunabilen, seek edilemeyen bir stream'de bunların yarısı NotSupportedException fırlatıyor. Base class çok şey vaat ettiğinde alt class'lar o vaatlerin bir kısmını reddetmek zorunda kalır. SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) ve Design Principles serisinin "Liskov Substitution Principle: Behavioral Subtyping ve .NET'teki NotSupportedException'lar" yazısı bu gerilimi Stream üzerinden ayrıntısıyla anlatıyor; CanRead, CanSeek ve CanWrite property'leri, çağıranın hangi vaadin geçerli olduğunu sorabilmesi için var.
BackgroundService ve .NET 10'daki Değişiklik
Daha yeni ve günlük hayatta daha sık karşılaştığın örnek Microsoft.Extensions.Hosting.BackgroundService. Host bir IHostedService'in StartAsync ve StopAsync'ini çağırır; BackgroundService bu iki method'u kendisi implement eder ve sana tek bir abstract adım bırakır: ExecuteAsync. Cancellation token'ı bağlamak, çalışan task'ı saklamak, durdurulurken token'ı iptal edip task'ı beklemek base class'ın işi.
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
{
// Akışı BackgroundService kuruyor; biz yalnız bu adımı dolduruyoruz
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
Worker'ın ilk yarım saniyesi bilerek senkron bir Thread.Sleep. Buna rağmen StartAsync hemen döndü. Bu, template method'un içini değiştirmenin bütün alt class'ların davranışını tek seferde değiştirebileceğinin canlı bir örneği: Microsoft Learn'deki "BackgroundService runs all of ExecuteAsync as a Task" breaking change sayfasına göre .NET 10'dan önce ExecuteAsync'in ilk await'e kadarki senkron kısmı uygulamanın başlangıç thread'inde çalışıyor ve diğer hosted service'lerin başlamasını bloke ediyordu. .NET 10 ile StartAsync, ExecuteAsync'in tamamını Task.Run ile başlatıyor; runtime kaynak kodundaki BackgroundService.cs'te bu satırı görebilirsin. Hiçbir worker'ın kodu değişmedi ama hepsinin başlangıç davranışı değişti.
Bu iki yönlü bir kılıç. Base class'ı yazan için bir hatayı tek yerde düzeltmek demek; alt class'ı yazan için ise kontrolünde olmayan bir yerdeki değişikliğin kendi kodunu etkilemesi. Başlangıçta senkron çalışması gereken bir hazırlık adımın varsa, sayfa onu constructor'a ya da base.StartAsync'ten önce override edilmiş bir StartAsync'e taşımayı öneriyor.
Delegate'li Alternatif
Template Method inheritance ile çalışır, bu da her yeni format için yeni bir class ve bir hiyerarşi demek. Aynı sonucu composition ile de alabilirsin: akış yine tek bir class'ta durur, değişen adımlar constructor'dan delegate olarak gelir.
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}]")));
// Inheritance yok: adımlar constructor'dan geliyor, akış yine tek yerde
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]
Çıktı inheritance'lı sürümle birebir aynı. Fark yapısal: CatalogImporter sealed, hiçbir adımı override edilemiyor, ama her adımı dışarıdan değiştirilebiliyor. Hook'lar burada opsiyonel parametreler; onRejected verilmezse çağrılmıyor. Önce Tahmin Et'teki override unutma hatası bu yapıda yapılamaz, çünkü ortada override edilecek bir şey yok. Adımlardan biri büyür ve kendi bağımlılıklarını isterse Func yerine bir interface koyarsın ve elindeki şey bir önceki yazıdaki Strategy olur. Template Method ile Strategy aynı problemin iki çözümü: biri değişen adımı inheritance ile, diğeri composition ile takar. SOLID ve Design Principles serisinin "Composition over Inheritance: Fragile Base Class, Delegation ve Davranışı Takmak" yazısı bu seçimin genel hâlini konuşuyor.
| Soru | Template Method (inheritance) | Delegate ya da Strategy (composition) |
|---|---|---|
| Adımlar nasıl değişir? | Alt class override eder | Constructor'a başka bir delegate ya da object verilir |
| Zorunlu adım nasıl ifade edilir? | abstract method, derleyici zorlar | Nullable olmayan parametre |
| Adımlar arası paylaşılan state | Base class'ın protected member'ları | Açıkça parametre olarak geçer |
| Runtime'da adım değiştirmek | Mümkün değil, class seçilir | Mümkün |
| Okurken | Akış base class'ta, adımlar alt class'larda | Akış ve adımlar çağıran yerde görünür |
Sık Düşülen Tuzaklar
Constructor'dan virtual method çağırmak. Base class'ın constructor'ı bir template method gibi davranıp abstract bir adımı çağırırsa, o adım alt class'ın constructor'ı henüz çalışmadan yürür:
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'un currency field'ı constructor gövdesinde atanıyor, ama base constructor ondan önce çalıştığı için BuildDescription null gördü. Nullable reference types açık olmasına rağmen derleyici bunu yakalamadı; field'ı string? yapmasaydık "non-nullable" bir field'dan null okuyacaktık. İkinci sınıfın doğru çalışması ilginç bir C# ayrıntısı: primary constructor parametresi bir field'a yakalandığında bu atama field initializer'lar gibi base constructor'dan önce yapılır. Bu ayrıntıya güvenmek yerine kuralı uygula: constructor'da override edilebilir method çağırma. CA2214 bunu yakalar ama varsayılan olarak kapalı; burada .editorconfig'de dotnet_diagnostic.CA2214.severity = warning ile açtık.
Derin hiyerarşi. CatalogImport → DelimitedCatalogImport → CsvCatalogImport → PublisherXCsvImport zincirinde her seviye birkaç hook'u override eder ve bazıları base. çağrısı yapar, bazıları yapmaz. Bir satırın neden reddedildiğini bulmak için dört dosyayı birlikte okuman gerekir. İki seviyeyi geçen bir template method hiyerarşisi genellikle composition'a dönmenin vaktinin geldiğini söyler.
Hook'ta base çağrısının zorunlu olup olmadığını belirsiz bırakmak. Bir hook'un varsayılan implementation'ı boş değilse (ör. log yazıyorsa), override eden base.OnCompleted(...)'ı çağırmalı mı? Kodda yazmıyorsa herkes farklı karar verir. Ya hook'ları her zaman boş bırak ve zorunlu işi template method'un kendisine koy, ya da XML (Extensible Markup Language) dokümantasyon yorumunda açıkça yaz.
Template method'u virtual yapmak. Run'ı override edilebilir kılmak, alt class'a akışı yeniden yazma izni vermek demek. Bunu yapan bir alt class doğrulama adımını atlarsa base class'ın verdiği "her satır doğrulanır" garantisi biter.
Ne Zaman Kullanma
Tek bir implementation varsa. Bir import formatı için abstract base class ve tek bir alt class yazmak, iki dosyaya bölünmüş tek bir class'tır. İkinci format gelene kadar düz bir class yeterli.
Adımlar runtime'da değişecekse. Hangi parser'ın kullanılacağı configuration'dan ya da kullanıcının seçiminden geliyorsa inheritance yanlış araç; bir alt class'ı runtime'da değiştiremezsin. Delegate'li ya da strategy'li sürüm bu iş için var.
Adımlar birbirinden bağımsız değilse. Template Method adımların sırası sabit ve her birinin girdisi-çıktısı belli olduğunda iyi çalışır. Alt class'lar sürekli base class'ın protected state'ine bakıp akışı manipüle etmeye başladıysa iskelet aslında sabit değil demektir.
Küçük Bir Deneme
★☆☆ Projeye <TreatWarningsAsErrors>true</TreatWarningsAsErrors> ekle ve Önce Tahmin Et'teki kodu yeniden derle. Hata mesajı ne söylüyor? Sonra override yerine new yaz: uyarı kayboluyor mu, hook çağrılıyor mu?
★★☆ CatalogImport'a ShouldSkip(string raw) adında bir hook ekle: varsayılanı false, true dönerse satır ne import edilir ne reddedilir. # ile başlayan yorum satırlarını atlayan bir CSV import'u yaz ve raporun sayılarını kontrol et.
★★★ Aynı JSON Lines (her satırda bir JSON object) import'unu iki kez yaz: bir kez CatalogImport'tan türeyerek, bir kez CatalogImporter'a delegate vererek. Parse adımı System.Text.Json kullansın. Hangi sürümde birim test yazmak daha kolaydı ve hangisinde adımların sırasını okumak?
Template Method'da iskeleti base class'a sabitleyip adımları alt class'lara bıraktık; yeni bir format eklemek ucuz, yeni bir adım eklemek pahalıydı. Bir sonraki yazı bunun tam tersi bir dünyaya bakıyor: tip hiyerarşisi sabit, sürekli yeni işlemler ekleniyor. Visitor pattern, double dispatch, ExpressionVisitor ve bütün bunların pattern matching ile karşılaştırması.