GoF Dışında Bilmen Gereken Kod Seviyesi Pattern'ler: Null Object, Specification, Result ve Pipeline
GoF kataloğunda olmayan dört pattern günlük C# kodunda sürekli işe yarar: Null Object ve Special Case "yok" durumunu davranışı olan bir object'e çevirip null kontrollerini kaldırır, Specification iş kuralını EF Core'un SQL'e çevirebileceği birleştirilebilir bir Expression olarak taşır, Result adımları Then ile hata akışına bağlar, Pipeline da dönüşümleri sıralı filter'lara böler. Func ile yazılan kuralın bütün tabloyu çekmesi, ExpressionVisitor ile parametre birleştirme ve Options pattern.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un kataloğu EF (Entity Framework) Core ile bir SQLite veritabanında duruyor. Dört kitap var ve CatalogDb'ye takılı küçük bir interceptor, veritabanına giden her SELECT'i SQL> önekiyle ekrana basıyor:
public sealed class Book
{
public int Id { get; set; }
public required string Title { get; set; }
public required string Author { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
}
public sealed class CatalogDb(SqliteConnection connection) : DbContext
{
public DbSet<Book> Books => Set<Book>();
protected override void OnConfiguring(DbContextOptionsBuilder options) =>
options.UseSqlite(connection).AddInterceptors(new SqlEcho());
public static async Task<CatalogDb> CreateAsync()
{
SqliteConnection connection = new("Data Source=:memory:");
await connection.OpenAsync();
CatalogDb db = new(connection);
await db.Database.EnsureCreatedAsync();
db.Books.AddRange(
new Book { Title = "Dune", Author = "Frank Herbert", Price = 12.99m, Stock = 4 },
new Book { Title = "Emma", Author = "Jane Austen", Price = 7.50m, Stock = 0 },
new Book { Title = "Children of Dune", Author = "Frank Herbert", Price = 24.00m, Stock = 2 },
new Book { Title = "Persuasion", Author = "Jane Austen", Price = 9.90m, Stock = 1 });
await db.SaveChangesAsync();
SqlEcho.Enabled = true;
return db;
}
}
"Stokta olan kitaplar" kuralı iki ayrı yerde yazılmış. Biri onu Expression<Func<Book, bool>> olarak, diğeri Func<Book, bool> olarak tanımlamış. Lambda'lar harfi harfine aynı:
using System.Linq.Expressions;
using Bookstore.Catalog;
using Microsoft.EntityFrameworkCore;
await using CatalogDb db = await CatalogDb.CreateAsync();
Expression<Func<Book, bool>> inStockExpression = b => b.Stock > 0;
Func<Book, bool> inStockFunc = b => b.Stock > 0;
List<Book> first = await db.Books.Where(inStockExpression).ToListAsync();
Console.WriteLine($"Expression: {first.Count} books");
List<Book> second = db.Books.Where(inStockFunc).ToList();
Console.WriteLine($"Func: {second.Count} books");
İki sorgu da 3 kitap döndürecek. Ama veritabanına giden iki SQL (Structured Query Language) cümlesi aynı mı?
Specification: İş Kuralını Expression Olarak Taşımak
Çıktı:
SQL> SELECT "b"."Id", "b"."Author", "b"."Price", "b"."Stock", "b"."Title" FROM "Books" AS "b" WHERE "b"."Stock" > 0
Expression: 3 books
SQL> SELECT "b"."Id", "b"."Author", "b"."Price", "b"."Stock", "b"."Title" FROM "Books" AS "b"
Func: 3 books
Sonuç aynı, sorgular değil. Expression ile yazılan kural SQL'e WHERE "b"."Stock" > 0 olarak çevrildi. Func ile yazılan kural ise derleyicinin Queryable.Where yerine Enumerable.Where overload'unu seçmesine yol açtı: EF Core bütün tabloyu çekti ve filtre bellekte çalıştı. Dört satırda fark yok; dört yüz bin satırda bir sayfa yüklenmez. EF Core serisinin "EF Core ile LINQ Sorguları: SQL Çevirisi, Client Evaluation ve IQueryable Tuzağı" yazısı bu tuzağı LINQ (Language Integrated Query) tarafından anlatıyor. Bu yazının sorusu başka: bir iş kuralını hem veritabanında hem bellekte, tek bir yerde tanımlayıp birleştirebileceğimiz bir şekilde nasıl tutarız?
Bu sorunun bilinen adı Specification pattern. Eric Evans ve Martin Fowler'ın 1997'de PLoP'ta (Pattern Languages of Programs konferansı) sundukları "Specifications" makalesi fikri şöyle kurar: bir object'in belirli bir kritere uyup uymadığını söyleyen kuralı, o kuralın nasıl uygulandığından ayır ve kuralları and, or, not ile birleştirilebilir kıl. Evans 2003 tarihli DDD (Domain-Driven Design) kitabında pattern'i üç kullanım için anlatır: doğrulama (bu object kurala uyuyor mu), seçim (kurala uyanları getir) ve oluşturma (kurala uyan bir object üret). C#'ta expression tree'ler ilk ikisini tek bir tanımla vermemizi sağlıyor:
using System.Linq.Expressions;
namespace Bookstore.Catalog;
public sealed class Specification<T>(Expression<Func<T, bool>> predicate)
{
private Func<T, bool>? compiled;
public Expression<Func<T, bool>> Predicate => predicate;
// Bellekteki tek bir object için: doğrulama, UI kuralı, test
public bool IsSatisfiedBy(T candidate) => (compiled ??= predicate.Compile())(candidate);
public Specification<T> And(Specification<T> other) => Combine(other, Expression.AndAlso);
public Specification<T> Or(Specification<T> other) => Combine(other, Expression.OrElse);
public Specification<T> Not() =>
new(Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters));
private Specification<T> Combine(Specification<T> other, Func<Expression, Expression, BinaryExpression> merge)
{
ParameterExpression parameter = predicate.Parameters[0];
// İki lambda'nın parametresi farklı object'ler; sağdakini soldakine çevir
Expression right = new ParameterReplacer(other.Predicate.Parameters[0], parameter).Visit(other.Predicate.Body);
return new(Expression.Lambda<Func<T, bool>>(merge(predicate.Body, right), parameter));
}
private sealed class ParameterReplacer(ParameterExpression from, ParameterExpression to) : ExpressionVisitor
{
protected override Expression VisitParameter(ParameterExpression node) => node == from ? to : node;
}
}
public static class BookSpecs
{
public static Specification<Book> InStock { get; } = new(b => b.Stock > 0);
public static Specification<Book> CheaperThan(decimal max) => new(b => b.Price < max);
public static Specification<Book> By(string author) => new(b => b.Author == author);
}
Birleştirmenin tek zor kısmı ParameterReplacer. b => b.Stock > 0 ile b => b.Price < max lambda'larındaki iki b, aynı isimde olsalar da farklı ParameterExpression object'leri. İki gövdeyi AndAlso ile yan yana koyup birinin parametresini kullanırsan diğer gövdedeki b hiçbir parametreye bağlı olmaz ve EF Core sorguyu çeviremez. Serinin Visitor yazısında gördüğümüz ExpressionVisitor, sağdaki gövdeyi gezip parametreyi soldakiyle değiştiriyor. Kullanımı:
using Bookstore.Catalog;
using Microsoft.EntityFrameworkCore;
await using CatalogDb db = await CatalogDb.CreateAsync();
// Kampanya kuralı: stokta olan ve 15'ten ucuz kitaplar, Jane Austen hariç
Specification<Book> campaign = BookSpecs.InStock
.And(BookSpecs.CheaperThan(15m))
.And(BookSpecs.By("Jane Austen").Not());
List<string> titles = await db.Books.Where(campaign.Predicate).Select(b => b.Title).ToListAsync();
Console.WriteLine(string.Join(", ", titles));
// Aynı kural, veritabanına gitmeden tek bir kitap için
Book draft = new() { Title = "Dune Messiah", Author = "Frank Herbert", Price = 11.00m, Stock = 3 };
Console.WriteLine($"{draft.Title} eligible: {campaign.IsSatisfiedBy(draft)}");
SQL> SELECT "b"."Title" FROM "Books" AS "b" WHERE "b"."Stock" > 0 AND ef_compare("b"."Price", @max) < 0 AND "b"."Author" <> @author
Dune
Dune Messiah eligible: True
Üç küçük kural tek bir WHERE'e çevrildi; max ve author parametre olarak gitti. ef_compare, SQLite'ın decimal tipi olmadığı için EF Core'un SQLite provider'ının kullandığı karşılaştırma fonksiyonu; SQL Server'da aynı kural düz bir < olur. Son satırda aynı kampanya kuralı, veritabanına hiç gitmeden, henüz kaydedilmemiş bir kitap için çalıştı. Kampanya kuralı değiştiğinde değişecek tek yer campaign'in tanımı.
Null Object ve Special Case
Sıradaki problem çok daha sık: "bulunamadı" durumu. Ödeme ekranı müşteriyi e-postasından buluyor, ama misafir olarak alışveriş yapan kullanıcının bir kaydı yok:
CustomerDirectory directory = new();
foreach (string email in new[] { "ada@example.com", "unknown@example.com" })
{
Customer? customer = directory.Find(email);
// Her çağıran aynı null kontrollerini kendi yazıyor
string name = customer?.Name ?? "Guest";
decimal discount = customer is null ? 0m : customer.LoyaltyRate;
int points = customer?.Points ?? 0;
Console.WriteLine($"{name}: discount {discount:P0}, points {points}");
}
public class Customer(string name, decimal loyaltyRate, int points)
{
public string Name => name;
public decimal LoyaltyRate => loyaltyRate;
public int Points => points;
}
public sealed class CustomerDirectory
{
private readonly Dictionary<string, Customer> customers = new()
{
["ada@example.com"] = new Customer("Ada Lovelace", 0.05m, 120),
};
public Customer? Find(string email) => customers.GetValueOrDefault(email);
}
Ada Lovelace: discount 5%, points 120
Guest: discount 0%, points 0
Kod çalışıyor, ama "müşteri yok"un anlamı (adı Guest, indirimi sıfır, puanı sıfır) çağıran yerin içine yazılmış. Sepet sayfası, fatura ve sadakat puanı job'u aynı üç kontrolü ayrı ayrı yazacak ve kontrolü unutan ilk yer, misafir için bir NullReferenceException fırlatacak. Bu kontrolleri yok etmenin yolu, "yok" durumunu bir object'e çevirmek:
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
CustomerDirectory directory = new();
// Test'te ya da log istemeyen bir aracın içinde: hiçbir şey yapmayan logger
CheckoutService checkout = new(directory, NullLogger<CheckoutService>.Instance);
Console.WriteLine(checkout.Summary("ada@example.com", 40m));
Console.WriteLine(checkout.Summary("unknown@example.com", 40m));
public abstract class Customer
{
public abstract string Name { get; }
public abstract decimal LoyaltyRate { get; }
public abstract int Points { get; }
public abstract Customer EarnPoints(decimal amount);
}
public sealed class RegisteredCustomer(string name, decimal loyaltyRate, int points) : Customer
{
public override string Name => name;
public override decimal LoyaltyRate => loyaltyRate;
public override int Points => points;
public override Customer EarnPoints(decimal amount) =>
new RegisteredCustomer(name, loyaltyRate, points + (int)amount);
}
// Special Case: "müşteri yok" durumu kendi davranışını taşıyor
public sealed class GuestCustomer : Customer
{
public static GuestCustomer Instance { get; } = new();
private GuestCustomer() { }
public override string Name => "Guest";
public override decimal LoyaltyRate => 0m;
public override int Points => 0;
public override Customer EarnPoints(decimal amount) => this;
}
public sealed class CustomerDirectory
{
private readonly Dictionary<string, Customer> customers = new()
{
["ada@example.com"] = new RegisteredCustomer("Ada Lovelace", 0.05m, 120),
};
public Customer Find(string email) => customers.GetValueOrDefault(email) ?? GuestCustomer.Instance;
}
public sealed class CheckoutService(CustomerDirectory directory, ILogger<CheckoutService> logger)
{
public string Summary(string email, decimal total)
{
Customer customer = directory.Find(email);
decimal payable = total * (1 - customer.LoyaltyRate);
Customer after = customer.EarnPoints(payable);
logger.LogInformation("Checkout for {Customer}", customer.Name);
return $"{customer.Name}: pays {payable:0.00}, points {customer.Points} -> {after.Points}";
}
}
Ada Lovelace: pays 38.00, points 120 -> 158
Guest: pays 40.00, points 0 -> 0
Find artık null döndürmüyor ve imzası Customer? değil Customer. Misafir, kendi adı, sıfır indirimi ve puan kazanmayan EarnPoints'iyle bir müşteri gibi davranıyor; CheckoutService'te tek bir null kontrolü yok.
Burada iki yakın pattern iç içe geçiyor. Null Object, Bobby Woolf'un 1998'de PLoPD 3 (Pattern Languages of Program Design 3) kitabında yayımlanan yazısıyla tanınır: bir collaborator yokken "hiçbir şey yapmayan" bir implementation koyarak çağıranı null kontrolünden kurtarır. Martin Fowler PoEAA (Patterns of Enterprise Application Architecture, 2002) kitabında bunu Special Case adıyla genelleştirir: belirli bir durum için özel davranış sağlayan bir alt class. Fark davranışta. NullLogger<CheckoutService>.Instance saf bir Null Object: hiçbir şey yazmıyor, testlerde ya da log'a ihtiyaç duymayan bir araçta gerçek logger'ın yerini tutuyor. GuestCustomer ise bir Special Case: "hiçbir şey yapmamak" değil, misafire özgü kuralları uyguluyor. Fowler'ın Refactoring kitabının 2018 tarihli ikinci baskısındaki "Introduce Special Case" refactoring'i, ilk baskıda "Introduce Null Object" adını taşıyordu; isim değişikliği de bu genellemeyi yansıtıyor.
.NET'te Null Object her yerde: Stream.Null ve TextWriter.Null yazılan her şeyi yutar, Array.Empty<T>() ve Enumerable.Empty<T>() "eleman yok"u null yerine boş bir collection ile söyler, CancellationToken.None asla iptal edilmeyen bir token'dır, NullLogger ve NullLoggerFactory da Microsoft.Extensions.Logging'in hazır null object'leri. Hepsinin ortak noktası, tek bir paylaşılan instance olmaları: davranışları state'e bağlı olmadığı için yeniden oluşturmaya gerek yok.
Special Case'in uygulama verisindeki hâlleri Çözüm Desenleri serisinin iki yazısında karşımıza çıktı. "Gölge Kayıt: Veritabanında Olmayan Virtual Root — Departmanların Üstündeki 'Şirket = 0'" yazısında, ağacın tepesindeki sanal şirket düğümü bir sayı olarak dolaşmak yerine DepartmentScope.Company diye bir Special Case'e dönüşüyordu. "Sentinel Value'lar: 0, -1, '1900-01-01' ve 9999-12-31'in Gizli Maliyeti" yazısında da süresiz izin, 9999-12-31 gibi bir sentinel tarih yerine kendi Covers davranışını taşıyan bir LeaveEnd.OpenEnded object'i olarak modellenmişti. İki yazıda da problem aynıydı: davranışı olmayan bir değer (0, bir tarih, null), anlamını her kullanıldığı yerde yeniden hatırlatmak zorunda. Special Case o anlamı bir kez, bir tipin içine yazar.
Result: Hatayı Akışın Parçası Yapmak
Clean Code serisinin "Clean Code'da Error Handling: Exception, Try Pattern ve Result Pattern" yazısı, beklenen iş hatalarını (boş sepet, stok yok, ödeme reddedildi) exception yerine bir Result<T> ile döndürmeyi ve hangi hatanın hangi aracı hak ettiğini ayrıntısıyla anlattı. Burada yalnızca pattern olarak eksik kalan tarafa bakalım: birden fazla adımın sonucunu birbirine bağlamak. Her adım bir Result döndürüyorsa, adımları if (!result.IsSuccess) return ... zincirleriyle bağlamak yerine bir Then ile bağlayabilirsin:
Checkout checkout = new(stock: new() { ["9780441013593"] = 4, ["9780141439518"] = 0 });
Console.WriteLine(checkout.Place(new Cart("ada", ["9780441013593"]), cardLimit: 50m));
Console.WriteLine(checkout.Place(new Cart("ada", []), cardLimit: 50m));
Console.WriteLine(checkout.Place(new Cart("ada", ["9780141439518"]), cardLimit: 50m));
Console.WriteLine(checkout.Place(new Cart("ada", ["9780441013593", "9780441013593"]), cardLimit: 20m));
public sealed record Cart(string CustomerId, string[] Isbns);
public sealed record Error(string Code, string Message);
public sealed record Result<T>
{
private Result(T? value, Error? error) => (Value, Error) = (value, error);
public T? Value { get; }
public Error? Error { get; }
public bool IsSuccess => Error is null;
public static Result<T> Ok(T value) => new(value, null);
public static Result<T> Fail(string code, string message) => new(default, new Error(code, message));
// Başarılıysa sıradaki adımı çalıştır, değilse hatayı olduğu gibi taşı
public Result<TNext> Then<TNext>(Func<T, Result<TNext>> next) =>
IsSuccess ? next(Value!) : Result<TNext>.Fail(Error!.Code, Error.Message);
public TOut Match<TOut>(Func<T, TOut> ok, Func<Error, TOut> fail) => IsSuccess ? ok(Value!) : fail(Error!);
}
public sealed class Checkout(Dictionary<string, int> stock)
{
private const decimal UnitPrice = 12.99m;
public string Place(Cart cart, decimal cardLimit) =>
Validate(cart)
.Then(ReserveStock)
.Then(c => Charge(c, cardLimit))
.Match(
ok: receipt => $"OK {receipt}",
fail: error => $"FAIL {error.Code}: {error.Message}");
private static Result<Cart> Validate(Cart cart) =>
cart.Isbns.Length == 0 ? Result<Cart>.Fail("cart.empty", "The cart has no items.") : Result<Cart>.Ok(cart);
private Result<Cart> ReserveStock(Cart cart)
{
string? missing = cart.Isbns.FirstOrDefault(isbn => stock.GetValueOrDefault(isbn) <= 0);
return missing is null
? Result<Cart>.Ok(cart)
: Result<Cart>.Fail("stock.out", $"{missing} is out of stock.");
}
private static Result<string> Charge(Cart cart, decimal cardLimit)
{
decimal total = cart.Isbns.Length * UnitPrice;
return total <= cardLimit
? Result<string>.Ok($"charged {total:0.00} for {cart.Isbns.Length} item(s)")
: Result<string>.Fail("payment.declined", $"{total:0.00} exceeds the card limit.");
}
}
OK charged 12.99 for 1 item(s)
FAIL cart.empty: The cart has no items.
FAIL stock.out: 9780141439518 is out of stock.
FAIL payment.declined: 25.98 exceeds the card limit.
Place method'u akışı tek bir ifade olarak okutuyor: doğrula, stoğu ayır, ödemeyi çek, sonucu metne çevir. Bir adım başarısız olduğunda Then sonraki adımları çalıştırmadan hatayı taşıyor; boş sepette stok kontrolü hiç yapılmadı, stoksuz kitapta karttan para çekilmedi. Scott Wlaschin bu yapıyı 2014'te "Railway Oriented Programming" adıyla anlattı: başarı ve hata iki paralel ray, her adım bir makas. Then, fonksiyonel programlamada bind ya da flatMap diye bilinen işlemin C# hâli.
Bu küçük Result<T> bilinçli olarak eksik: async adımlar için ThenAsync, birden fazla hatayı toplamak için bir liste yok. Gerçek bir projede ya bunları ekleyen kendi tipini yazarsın ya da hazır bir library kullanırsın; ikisinde de sınır aynı. Result beklenen iş sonuçları içindir, veritabanı bağlantısının kopması gibi beklenmeyen hatalar exception olarak kalmalı.
Pipeline: Pipes and Filters
Bir sipariş tutarının hesaplanması bir dizi dönüşümden oluşur: kampanya indirimi, kupon, KDV (katma değer vergisi), yuvarlama. Her adım aynı tipte bir girdi alıp aynı tipte bir çıktı verirse, adımlar bir listeye dizilip sırayla uygulanabilir:
// Her filter aynı tipi alır ve aynı tipi döndürür; sırayla hepsi çalışır
Func<Quote, Quote>[] pipeline =
[
Filters.ApplyCampaign(0.10m),
Filters.ApplyCoupon("WELCOME5", 5m),
Filters.AddVat(0.20m),
Filters.RoundToCents,
];
Quote start = new(Amount: 49.90m, Coupon: "WELCOME5", Log: []);
Quote final = pipeline.Aggregate(start, (quote, filter) => filter(quote));
Console.WriteLine(string.Join(Environment.NewLine, final.Log));
Console.WriteLine($"Total: {final.Amount:0.00}");
public sealed record Quote(decimal Amount, string? Coupon, IReadOnlyList<string> Log)
{
public Quote With(decimal amount, string step) =>
this with { Amount = amount, Log = [.. Log, $"{step,-10} {Amount,8:0.0000} -> {amount,8:0.0000}"] };
}
public static class Filters
{
public static Func<Quote, Quote> ApplyCampaign(decimal rate) =>
q => q.With(q.Amount * (1 - rate), "campaign");
public static Func<Quote, Quote> ApplyCoupon(string code, decimal off) =>
q => q.Coupon == code ? q.With(Math.Max(0m, q.Amount - off), "coupon") : q;
public static Func<Quote, Quote> AddVat(decimal rate) =>
q => q.With(q.Amount * (1 + rate), "vat");
public static Quote RoundToCents(Quote q) =>
q.With(Math.Round(q.Amount, 2, MidpointRounding.ToEven), "round");
}
campaign 49.9000 -> 44.9100
coupon 44.9100 -> 39.9100
vat 39.9100 -> 47.8920
round 47.8920 -> 47.8900
Total: 47.89
Bu yapının adı Pipes and Filters. Pattern, Buschmann, Meunier, Rohnert, Sommerlad ve Stal'ın 1996 tarihli POSA (Pattern-Oriented Software Architecture, Volume 1) kitabında mimari bir pattern olarak, Hohpe ve Woolf'un 2003 tarihli EIP (Enterprise Integration Patterns) kitabında da mesajlaşma sistemleri için anlatılır; burada aynı fikrin kod seviyesindeki hâlini kullanıyoruz. Her filter yalnızca kendi işini biliyor, sıra tek bir array'de görünüyor ve yeni bir adım (ör. hediye paketi ücreti) eklemek bir satır. Aggregate filter'ları zincirleyen pipe'ın kendisi. Her adımın girdi ve çıktısının log'a düşmesi de pipeline'ın test edilmesini kolaylaştırıyor: yuvarlamanın en sonda yapıldığını çıktıda görüyorsun.
Chain of Responsibility ile farkı ince ama önemli. Chain of Responsibility'de her handler isteği ele alıp zinciri durdurabilir; amaç doğru handler'ı bulmak. Pipeline'da her filter her zaman çalışır; amaç veriyi adım adım dönüştürmek. ASP.NET Core middleware pipeline'ı ikisinin karışımı: her middleware isteği bir sonrakine geçirir ama isterse kısa devre yaptırabilir. Adımların async ve eşzamanlı çalışması gerektiğinde aynı fikir System.Threading.Channels ile kurulur; C# Async serisinin "C# Channels ile Producer/Consumer: Backpressure, Bounded Channel ve Pipeline" yazısı bunu anlatıyor.
Options Pattern
Listenin son elemanı .NET'in kendi pattern'i. Options pattern, configuration'ı string key'lerle her yerden okumak yerine, bir bölümü tipli bir class'a bağlayıp DI (Dependency Injection) ile enjekte eder:
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Checkout:FreeShippingThreshold"] = "50",
["Checkout:MaxItemsPerOrder"] = "20",
});
builder.Services.AddOptions<CheckoutOptions>()
.BindConfiguration("Checkout")
.Validate(o => o.MaxItemsPerOrder > 0, "MaxItemsPerOrder must be positive.")
.ValidateOnStart();
builder.Services.AddSingleton<CartPolicy>();
using IHost host = builder.Build();
CartPolicy policy = host.Services.GetRequiredService<CartPolicy>();
Console.WriteLine(policy.Describe(35m));
Console.WriteLine(policy.Describe(64m));
public sealed class CheckoutOptions
{
public decimal FreeShippingThreshold { get; set; }
public int MaxItemsPerOrder { get; set; }
}
// Class configuration'ın nereden geldiğini bilmiyor, yalnız tipli bir object görüyor
public sealed class CartPolicy(IOptions<CheckoutOptions> options)
{
private readonly CheckoutOptions settings = options.Value;
public string Describe(decimal subtotal) =>
$"{subtotal:0.00}: free shipping {(subtotal >= settings.FreeShippingThreshold ? "yes" : "no")}, max {settings.MaxItemsPerOrder} items";
}
35.00: free shipping no, max 20 items
64.00: free shipping yes, max 20 items
CartPolicy configuration'ın JSON (JavaScript Object Notation) dosyasından mı, environment variable'dan mı yoksa bellekten mi geldiğini bilmiyor; yalnızca bir CheckoutOptions görüyor. Validate ve ValidateOnStart ise yanlış bir değerin uygulamanın ilk isteğinde değil, açılışında yakalanmasını sağlıyor; bu da bir önceki yazıdaki Service Locator hatasının configuration tarafındaki karşılığını önlüyor. Pattern'in IOptions, IOptionsSnapshot ve IOptionsMonitor ile üç lifetime'ı, provider sırası ve validation ayrıntıları ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core'da Configuration ve Options Pattern: Provider Sırası, User Secrets ve Validation" yazısında.
Sık Düşülen Tuzaklar
Null Object ile gerçek bir hatayı saklamak. Ödeme ekranında bilinmeyen bir müşteri misafir olabilir; ama yönetici panelinde id'si verilen bir müşterinin bulunamaması bir hatadır. Orada GuestCustomer döndürmek, yanlış id ile çağrılan bir ekranı sessizce "Guest" gösteren bir ekrana çevirir. Special Case, "yok" durumunun iş açısından anlamlı bir davranışı olduğunda kullanılır; "olmaması gereken" bir durumu gizlemek için değil.
Special Case'i is GuestCustomer kontrolleriyle sarmak. Çağıranlar if (customer is GuestCustomer) yazmaya başladıysa davranış yine dışarıya sızıyor demektir. Eksik olan şey muhtemelen Customer'daki yeni bir member: CanEarnPoints, RequiresEmailForReceipt gibi.
Specification'ı Func ile yazmak. Önce Tahmin Et'teki hatanın specification'lı hâli: IsSatisfiedBy'ı db.Books.Where(spec.IsSatisfiedBy) diye kullanmak derlenir ve çalışır, ama bütün tabloyu belleğe çeker. Veritabanına giden her kullanım Predicate'i, yani expression'ı vermeli.
EF Core'un çeviremeyeceği bir specification yazmak. b => IsBestseller(b) gibi kendi method'unu çağıran bir expression bellekte çalışır ama EF Core onu SQL'e çeviremez ve sorgu runtime'da exception fırlatır. Specification'ın gövdesi, provider'ın tanıdığı ifadelerle sınırlı.
Pipeline'da sıraya gizli bağımlılık. KDV'yi kupondan önce eklemek farklı bir toplam üretir ve ikisi de "doğru" görünür. Sıranın iş kuralı olduğu yerde sırayı bir test ile sabitle.
Ne Zaman Kullanma
Specification'ı tek kullanımlık bir filtre için. Kural tek bir sorguda kullanılıyorsa ve birleştirilmiyorsa, db.Books.Where(b => b.Stock > 0) her specification sınıfından daha açık. Specification, aynı kural birden fazla yerde (sorgu, doğrulama, kullanıcı arayüzü) kullanıldığında ya da kurallar runtime'da birleştirildiğinde (kullanıcının seçtiği filtreler, kampanya tanımları) kendini öder. Repository'nin her method'unu specification alacak şekilde soyutlamak ise çoğu zaman IQueryable'ın zaten verdiği şeyi yeniden yazmak demek; EF Core serisinin "EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş" yazısı (CQRS: Command Query Responsibility Segregation) bu tartışmayı yapıyor.
Nullable reference types zaten yetiyorsa Null Object. Yalnızca ekranda "—" gösterilecek bir alan için Special Case tipi kurma; string? ve derleyicinin uyarıları yeterli. Special Case, "yok" durumunun davranışı olduğunda ve bu davranış birçok yerde kullanıldığında anlamlı.
Result'ı her method için. Tek bir adımın sonucu ve tek bir hata türü varsa Try pattern (bool TryX(out T value)) daha az törenle aynı işi görür; beklenmeyen hatalar için ise exception zaten doğru araç.
Küçük Bir Deneme
★☆☆ BookSpecs'e TitleContains(string text) ekle ve kampanya kuralını "başlığında Dune geçenler" ile Or kullanarak genişlet. Üretilen SQL'de parantezler nereye kondu?
★★☆ CustomerDirectory'ye FindById(int id) ekle; bu method bulunamayan müşteri için GuestCustomer değil Result<Customer> ile bir customer.not_found hatası döndürsün. Aynı directory'de iki "bulunamadı" davranışının neden farklı olduğunu bir yorum satırıyla açıkla.
★★★ Fiyat pipeline'ını Func<Quote, Quote> yerine Func<Quote, Result<Quote>> filter'larıyla yeniden kur: süresi dolmuş bir kupon pipeline'ı bir hata ile durdursun. Pipeline, Result'ın Then'i ile birleşince Chain of Responsibility'ye ne kadar yaklaştı?
Bu yazıyla serinin pattern kataloğu tamamlandı: GoF'un (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) 23 pattern'i, anti-pattern'ler ve kod seviyesinde her gün kullandığın Null Object, Specification, Result, Pipeline ve Options. Son bir soru kaldı: elindeki problem için hangisini, ne zaman seçmeli ve dilin kendisi bu cevapları nasıl değiştiriyor? Seçim rehberini ve C# 15'in union type'larıyla closed hiyerarşilerinin Visitor, State ve Result'ı nasıl yeniden şekillendirdiğini anlatacak son yazı, C# 15 ve .NET 11'in 10 Kasım 2026'da beklenen GA (General Availability) sürümünden sonra, örnekleri kararlı sürümle derlenip denenmiş olarak gelecek.