Open/Closed Principle: Değiştirmeden Genişletmek ve Spekülatif Genellik Tuzağı
OCP, yeni bir davranışı mevcut ve çalışan kodu açmadan, yeni bir implementation olarak ekleyebilmektir; ama her kod yalnızca belirli bir değişim eksenine karşı kapatılabilir. Büyüyen bildirim switch'ini INotificationChannel ve .NET 8 keyed services ile kapatmak, HttpClient'ın DelegatingHandler zinciri, Func ile hafif extension point'ler ve spekülatif genelliğin bedeli.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Serinin ilk yazısındaki bildirim kodu büyüdü. Pazarlama push bildirimi istedi; geliştirici enum'a Push'u ekledi, switch'e yeni bir case yazdı, Grace'in telefonuna bildirimin düştüğünü gördü ve işi kapattı. Build başarılı. Aynı class'ın alt tarafında, aylar önce faturalama ekibinin eklediği bir method daha var. Bu program sonuna kadar çalışır mı?
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Customer[] customers =
[
new(1, "Ada", NotificationChannel.Email),
new(2, "Alan", NotificationChannel.Sms),
new(3, "Grace", NotificationChannel.Push)
];
NotificationService notifications = new();
foreach (Customer customer in customers)
notifications.OrderConfirmed(customer, orderId: 1000 + customer.Id);
Console.WriteLine($"Cost: {notifications.TotalCost} EUR");
// Push dün eklendi
public enum NotificationChannel { Email, Sms, Push }
public sealed record Customer(int Id, string Name, NotificationChannel PreferredChannel);
public sealed class NotificationService
{
public decimal TotalCost { get; private set; }
public void OrderConfirmed(Customer customer, int orderId)
{
string message = $"Order {orderId} confirmed";
switch (customer.PreferredChannel)
{
case NotificationChannel.Email:
Console.WriteLine($"[email] {customer.Name}: {message}");
break;
case NotificationChannel.Sms:
Console.WriteLine($"[sms] {customer.Name}: {message}");
break;
case NotificationChannel.Push:
Console.WriteLine($"[push] {customer.Name}: {message}");
break;
}
TotalCost += CostOf(customer.PreferredChannel);
}
// Faturalama için aylar önce eklenmiş ikinci bir switch
private static decimal CostOf(NotificationChannel channel) => channel switch
{
NotificationChannel.Email => 0.001m,
NotificationChannel.Sms => 0.05m
};
}
Derleyici Uyardı, Build Kırılmadı
Çıktı:
[email] Ada: Order 1001 confirmed
[sms] Alan: Order 1002 confirmed
[push] Grace: Order 1003 confirmed
Unhandled exception. System.Runtime.CompilerServices.SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
Unmatched value was Push.
Üç bildirim de gitti, ardından program faturalama method'unda patladı. Derleyici aslında haber vermişti; build çıktısında şu uyarı vardı:
Program.cs(47,75): warning CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). For example, the pattern 'NotificationChannel.Push' is not covered.
Ama bir uyarıydı, hata değil; yüzlerce uyarının arasında kayboldu. Asıl sorun daha derinde: "yeni bir kanal" kavramı tek bir yerde değil, kanal türüne göre dallanan her switch'in içinde yaşıyor. Bu dosyada iki tane var; gerçek bir projede şablon seçimi, karakter sınırı, gönderim saati kısıtı, raporlama gibi yerlerde onlarcası olabilir. Her yeni kanal, hepsini bulup değiştirmeyi gerektiriyor ve birini unutmak bu çıktıyı üretiyor.
Open/Closed Principle (OCP) tam olarak bu durumu hedefler: yeni bir kanal eklemek, mevcut ve çalışan kodu açıp değiştirmeyi gerektirmemeli. Bu yazıda OCP'nin iki farklı tanımını, .NET'te extension point kurmanın yollarını (interface, keyed services, Func<>, plugin) ve OCP'nin en pahalı yan etkisini, değişmeyecek yerlere extension point koymayı konuşacağız.
İki Tanım: Meyer ve Martin
OCP'nin ilk hâli Bertrand Meyer'in (Eiffel dilinin tasarımcısı ve Design by Contract fikrinin sahibi) 1988 tarihli Object-Oriented Software Construction kitabından gelir. Meyer'e göre bir module hem açık hem kapalı olmalıdır. Açık: hâlâ genişletilebilir, yeni alan ya da yeni işlem eklenebilir. Kapalı: başka module'ler tarafından kullanılabilir durumdadır, interface'i sabit ve iyi tanımlıdır, derlenip bir kütüphaneye konmuştur. Meyer bu çelişkiyi implementation inheritance ile çözer: kapalı class'a dokunmadan ondan türeyen yeni bir class yazarsın, eski client'lar eski class'ı kullanmaya devam eder.
Robert C. Martin ("Uncle Bob" olarak da bilinen, Clean Code ve Clean Architecture kitaplarının yazarı) 1996'da C++ Report dergisinde yayımlanan makalesinde prensibi yeniden yorumladı. Onun versiyonunda kapalı olan şey bir abstraction'dır (abstract class ya da interface); açık olan, o abstraction'ın yeni implementation'larıdır. Client abstraction'a bağlıdır, yeni davranış yeni bir implementation olarak gelir. Bu versiyona "polymorphic OCP" denir ve bugün OCP denince kastedilen genellikle budur. Martin 2014'teki yazısında prensibi daha da sade söyler: bir sistemin davranışını, o sistemi değiştirmeden genişletebilmelisin. Aynı yazıda plugin mimarilerini (Visual Studio, Eclipse, IntelliJ) OCP'nin en uç ve en başarılı uygulaması olarak gösterir.
İki tanım arasındaki fark pratikte önemli. Meyer'in inheritance tabanlı yaklaşımı, base class'ın iç ayrıntılarına bağlanan alt class'lar üretir; bunun fragile base class problemine nasıl yol açtığını ilerideki composition over inheritance yazısında göreceğiz. Martin'in yaklaşımı ise client ile implementation'ı yalnızca bir interface üzerinden buluşturur; LSP'nin (Liskov Substitution Principle) neden bu kadar önemli olduğu da buradan çıkar: yeni implementation abstraction'ın sözünü tutmazsa, "değiştirmeden genişletmek" bir yanılsamaya döner.
Switch'i Kapatmak: INotificationChannel ve Keyed Services
Kanala göre değişen her şeyi (gönderim, maliyet) tek bir abstraction'ın arkasında topluyoruz. Notifications.cs:
using Microsoft.Extensions.DependencyInjection;
namespace Bookstore.Notifications;
public sealed record Customer(int Id, string Name, string PreferredChannel);
// Extension point: yeni kanal = bu interface'i implement eden yeni bir class
public interface INotificationChannel
{
decimal CostPerMessage { get; }
void Send(Customer customer, string message);
}
public sealed class EmailChannel : INotificationChannel
{
public decimal CostPerMessage => 0.001m;
public void Send(Customer customer, string message) => Console.WriteLine($"[email] {customer.Name}: {message}");
}
public sealed class SmsChannel : INotificationChannel
{
public decimal CostPerMessage => 0.05m;
public void Send(Customer customer, string message) => Console.WriteLine($"[sms] {customer.Name}: {message}");
}
public sealed class PushChannel : INotificationChannel
{
public decimal CostPerMessage => 0.0001m;
public void Send(Customer customer, string message) => Console.WriteLine($"[push] {customer.Name}: {message}");
}
// Kanal eklendiğinde bu class değişmez
public sealed class NotificationService(Func<string, INotificationChannel> channelFor)
{
public decimal TotalCost { get; private set; }
public void OrderConfirmed(Customer customer, int orderId)
{
INotificationChannel channel = channelFor(customer.PreferredChannel);
channel.Send(customer, $"Order {orderId} confirmed");
TotalCost += channel.CostPerMessage;
}
}
// Kanalı sabit olan bir client: anahtarı constructor'da belirtir
public sealed class PasswordResetService([FromKeyedServices("sms")] INotificationChannel sms)
{
public void SendCode(Customer customer) => sms.Send(customer, "Your reset code is 482913");
}
Kanalları hangi anahtarla bulacağımıza ise uygulamanın girişinde, composition root'ta karar veriyoruz. .NET 8 ile gelen keyed services burada işe yarıyor: aynı interface'in birden fazla implementation'ını bir anahtarla kaydedip anahtarla çözebiliyorsun. Program.cs:
using System.Globalization;
using Bookstore.Notifications;
using Microsoft.Extensions.DependencyInjection;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
ServiceCollection services = new();
// Yeni kanal = yeni class + burada tek satır
services.AddKeyedSingleton<INotificationChannel, EmailChannel>("email");
services.AddKeyedSingleton<INotificationChannel, SmsChannel>("sms");
services.AddKeyedSingleton<INotificationChannel, PushChannel>("push");
// Anahtara göre seçim yalnızca composition root'ta
services.AddSingleton<Func<string, INotificationChannel>>(
sp => key => sp.GetRequiredKeyedService<INotificationChannel>(key));
services.AddSingleton<NotificationService>();
services.AddSingleton<PasswordResetService>();
using ServiceProvider provider = services.BuildServiceProvider();
NotificationService notifications = provider.GetRequiredService<NotificationService>();
Customer[] customers = [new(1, "Ada", "email"), new(2, "Alan", "sms"), new(3, "Grace", "push")];
foreach (Customer customer in customers)
notifications.OrderConfirmed(customer, orderId: 1000 + customer.Id);
Console.WriteLine($"Cost: {notifications.TotalCost} EUR");
provider.GetRequiredService<PasswordResetService>().SendCode(customers[0]);
try
{
notifications.OrderConfirmed(new Customer(4, "Linus", "fax"), orderId: 1004);
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
[email] Ada: Order 1001 confirmed
[sms] Alan: Order 1002 confirmed
[push] Grace: Order 1003 confirmed
Cost: 0.0511 EUR
[sms] Ada: Your reset code is 482913
No keyed service for type 'Bookstore.Notifications.INotificationChannel' using key type 'System.String' has been registered.
Artık NotificationService kanal türlerini bilmiyor; yalnızca "bu anahtar için bir kanal ver" diyor. WhatsApp kanalı geldiğinde yapılacak iş: INotificationChannel'ı implement eden yeni bir class ve Program.cs'te tek bir AddKeyedSingleton satırı. NotificationService, PasswordResetService ve mevcut kanallar açılmıyor. Unutulan bir kanal da sessizce yanlış maliyet hesaplamıyor; ilk denemede açık bir exception fırlatıyor.
Burada üç tasarım kararı var ve her birinin gerekçesi önemli. Birincisi, maliyet artık kanalın kendi property'si. İlk koddaki ikinci switch, "kanala özgü bilginin" kanalın dışında durmasıydı; onu kanalın içine taşıyınca unutulabilecek ikinci bir yer kalmıyor. İkincisi, anahtara göre çözümleme yalnızca composition root'ta, bir Func<string, INotificationChannel> kaydının içinde yapılıyor. NotificationService'e IServiceProvider verip içeride GetRequiredKeyedService çağırmak da çalışırdı, ama bu service locator olurdu: class'ın gerçek bağımlılıkları constructor'da görünmez hâle gelir. Bunun neden kötü olduğunu ilerideki Dependency Inversion yazısında ayrıntılı konuşacağız. Üçüncüsü, şifre sıfırlama kodu her zaman SMS (Short Message Service) ile gider; PasswordResetService gibi kanalı sabit olan client'lar anahtarı [FromKeyedServices("sms")] attribute'u ile constructor'da açıkça yazıyor.
Keyed services ile ilgili bilmen gereken bir ayrıntı: keyed kayıtlar, normal IEnumerable<INotificationChannel> çözümlemesinde gelmez. İki kanal kaydettiğim küçük bir denemede GetServices<INotificationChannel>() sıfır sonuç döndü, GetKeyedServices<INotificationChannel>(KeyedService.AnyKey) ise iki kayıtlı kanalın ikisini de döndü. Bütün kanallar üzerinde dolaşman gereken bir senaryo varsa (örneğin "tüm kanallara bakım duyurusu") bunu bilerek tasarlamalısın. ASP.NET Core tarafındaki ayrıntılar için ASP.NET Core Web API (Application Programming Interface) serisindeki ".NET 8 ile ASP.NET Core Yenilikleri" yazısına, container'ın genel mekaniği için C# İleri serisindeki "Dependency Injection Temelleri" yazısına bakabilirsin.
.NET'te OCP: HttpClient'ın Handler Pipeline'ı
.NET'in kendi kütüphanesinde OCP'nin belki de en çok kullanılan örneği HttpClient'ın message handler zinciri. HttpClient isteği kendisi göndermez; bir HttpMessageHandler'a verir. DelegatingHandler ise isteği bir sonraki handler'a ileten bir ara halka. Bu tasarım sayesinde log, retry, authentication header'ı ya da önbellekleme gibi davranışları HttpClient'ın koduna dokunmadan araya ekleyebiliyorsun:
using System.Net;
using System.Text;
// HttpClient'ın koduna dokunmadan araya davranış ekliyoruz
HttpMessageHandler pipeline = new LoggingHandler { InnerHandler = new FakeCatalogHandler() };
using HttpClient client = new(pipeline) { BaseAddress = new Uri("https://catalog.example.com/") };
string json = await client.GetStringAsync("books/978-0441013593");
Console.WriteLine(json);
public sealed class LoggingHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
Console.WriteLine($"--> {request.Method} {request.RequestUri}");
HttpResponseMessage response = await base.SendAsync(request, cancellationToken);
Console.WriteLine($"<-- {(int)response.StatusCode} {response.Content.Headers.ContentType}");
return response;
}
}
// Ağa çıkmayan sahte bir son halka
public sealed class FakeCatalogHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("""{"isbn":"978-0441013593","title":"Dune"}""", Encoding.UTF8, "application/json")
});
}
--> GET https://catalog.example.com/books/978-0441013593
<-- 200 application/json; charset=utf-8
{"isbn":"978-0441013593","title":"Dune"}
HttpClient ve DelegatingHandler .NET'in içinde, kapalı; senin LoggingHandler'ın ise yeni bir implementation, açık uç. ASP.NET Core'da IHttpClientFactory ile AddHttpMessageHandler bu zinciri DI (dependency injection) üzerinden kurar; Polly tabanlı resilience handler'ları da aynı noktaya takılır. Aynı fikri System.Text.Json'daki JsonConverter<T>'de (serializer'a yeni bir tip öğretmek), ASP.NET Core middleware pipeline'ında ve List<T>.Sort'a verilen IComparer<T>'de de görürsün. Hepsinde ortak nokta: framework, kendi kaynak kodunu senin ihtiyacına göre değiştirmeni beklemiyor; senin kodunu çağırabileceği bir yer bırakıyor.
Hafif Extension Point'ler: Func ve Plugin
Her extension point bir interface gerektirmez. Değişen şey tek bir fonksiyonsa ve durumu yoksa, bir delegate yeterli:
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// Kural tek bir fonksiyon: interface + class yerine bir lambda yeter
Func<decimal, decimal> standard = subtotal => subtotal >= 50m ? 0m : 4.99m;
Func<decimal, decimal> blackFriday = _ => 0m;
ShippingQuote regular = new(standard);
ShippingQuote campaign = new(blackFriday);
Console.WriteLine($"{regular.For(32.48m)} / {regular.For(64.00m)} / {campaign.For(32.48m)}");
public sealed class ShippingQuote(Func<decimal, decimal> rule)
{
public decimal For(decimal subtotal)
{
ArgumentOutOfRangeException.ThrowIfNegative(subtotal);
return rule(subtotal);
}
}
4.99 / 0 / 0
ShippingQuote kampanya kurallarından habersiz; yeni bir kampanya yeni bir lambda demek. LINQ'in (Language Integrated Query) kendisi de böyle çalışır: Where'in davranışını değiştirmek için onun kaynağını değil, ona verdiğin predicate'i değiştirirsin. Func<>'in sınırı, birden fazla ilişkili işlem (gönderim ve maliyet gibi) bir arada değiştiğinde ortaya çıkar; o noktada birbirinden bağımsız iki delegate taşımak yerine bir interface daha okunur ve tutarlıdır.
Spektrumun diğer ucunda plugin'ler var: ana uygulamanın derlendiği anda bilinmeyen assembly'lerin çalışma anında yüklenmesi. .NET'te bunun aracı AssemblyLoadContext; Microsoft Learn'de "Create a .NET Core application with plugins" başlıklı bir rehber var. Plugin mimarisi OCP'yi en uca taşır ama bedeli de en yüksektir: versiyonlama, bağımlılık çakışmaları, güvenlik ve hata ayıklama. Bir Bookstore uygulaması için kanal başına bir class çoğu zaman yeterlidir; plugin, üçüncü taraf ekiplerin uygulamanı genişlettiği durumlar için.
Hiçbir Kod Her Değişikliğe Kapalı Değildir
Martin 1996 makalesinde önemli bir uyarı yapar: bir module ne kadar kapalı olursa olsun, ona karşı kapalı olmadığı bir değişiklik türü her zaman vardır; o yüzden kapalılık stratejik olmalıdır. Bizim tasarımımız yeni kanal eklemeye karşı kapalı. Peki pazarlama "bildirimlere resim eklenebilsin" derse? INotificationChannel.Send'in imzası değişecek ve bütün kanallar açılacak. "Bildirim, müşterinin saat dilimine göre gece gönderilmesin" derse? Bu kural kanala değil zamanlamaya ait ve tasarımımızda onun için bir yer yok.
Bu bir tasarım hatası değil, OCP'nin doğası. Bir abstraction, belirli bir değişim eksenini kapatır. Hangi ekseni kapatacağına karar vermek, hangi değişikliğin gerçekten geleceğini bilmeyi gerektirir. İlk koddaki switch'in ilginç bir tersi de var: kanallar sabit ama kanallar üzerinde yapılan işlemler sürekli artıyorsa (rapor, şablon, kota, arşiv), her yeni işlem için interface'e yeni bir method eklemek bütün kanalları açar; aynı enum üzerinde exhaustive bir switch ise yeni işlemi tek bir yerde ekler ve derleyici unutulan dalı CS8509 ile gösterir (bu uyarıyı projede hata seviyesine çıkarmak bu durumda iyi bir fikirdir). Bu, programlama dilleri literatüründe expression problem olarak bilinen gerilim: yeni tür eklemeyi mi, yeni işlem eklemeyi mi ucuzlatacağını seçmek zorundasın.
Spekülatif Genellik Tuzağı
OCP'nin en pahalı yanlış kullanımı, değişmesi beklenmeyen yerlere extension point koymak. Kod incelemelerinde şu tür yapılar görürsün: tek bir implementation'ı olan ITaxCalculator, onu üreten bir ITaxCalculatorFactory, factory'yi seçen bir strategy; gerekçe "yarın başka ülkeler gelirse". Martin Fowler ve Kent Beck (Extreme Programming'in yaratıcısı) bunu Fowler'ın Refactoring (1999) kitabında speculative generality adıyla bir code smell olarak tanımlar. Fowler'ın YAGNI (You Aren't Gonna Need It) yazısı bu tür önceden yapılmış esnekliğin bedelini kalemlere ayırır: onu yazmanın maliyeti, bu sırada yapılamayan işin gecikmesi, kod tabanında taşımanın maliyeti ve tahmin yanlış çıktığında düzeltmenin maliyeti. Bunları DRY (Don't Repeat Yourself), KISS (Keep It Simple, Stupid) ve YAGNI yazısında ayrıntılı konuşacağız.
Tahmin çoğu zaman yanlış çıkar. Bookstore'da yıllarca tek ülkeye satış yapılır ve vergi tarafında gelen ilk gerçek değişiklik ülke değil, yuvarlama kuralı olur; o kural da factory'nin kapattığı eksende değildir. Geriye üç dosyalık bir dolaylılık ve her okuyana "burada neden bu kadar katman var?" sorusu kalır.
Martin'in Agile Software Development kitabındaki önerisi pratik bir kural: "fool me once". Kodu önce değişmeyecekmiş gibi, en basit hâliyle yaz. Belirli bir türden ilk değişiklik geldiğinde, o tür değişikliklere karşı seni koruyacak abstraction'ı o zaman kur. Bu yazının başındaki kod için doğru an, ikinci kanalın (SMS) geldiği an değil, belki üçüncüsünün geldiği ve switch'lerin çoğalmaya başladığı andı. Sık duyulan "ikinci kez değişince soyutla" kuralı da aynı fikrin bir versiyonu.
Sık Düşülen Tuzaklar
OCP'yi "eski koda asla dokunma" diye okumak. Bug fix, refactoring ve yanlış bir abstraction'ı düzeltmek her zaman mevcut kodu değiştirir ve değiştirmelidir. OCP, yeni bir davranışı eklemek için çalışan kodun açılmamasını hedefler; kodun dondurulmasını değil.
Abstraction'ı tek bir implementation'a göre tasarlamak. İlk ve tek kanal e-postayken yazılan bir INotificationChannel genelde Send(string to, string subject, string htmlBody) gibi e-postaya özgü bir imza taşır; SMS geldiğinde "subject"ın anlamı kalmaz. İkinci gerçek örnek gelmeden yazılan interface, ilk örneğin şeklini kopyalar.
Extension point'i keşfedilemez yapmak. Yeni bir kanal eklemek için hangi interface'i implement edeceğini, nereye kaydedeceğini ve hangi anahtarı kullanacağını bulmak bir saat sürüyorsa, OCP kağıt üstünde sağlanmış ama pratikte değil. Kayıtların tek bir yerde (composition root) durması ve anahtarların sabit olarak tanımlanması bunu kolaylaştırır.
Anahtarları string olarak dağıtmak. "sms" string'i hem kayıtta hem [FromKeyedServices]'te hem veritabanında geçiyorsa, bu önceki yazıda gördüğümüz connascence of meaning'in ta kendisi. Anahtarları bir static class NotificationChannels içinde const olarak tanımlamak, typo'yu derleme zamanına taşır.
Küçük Bir Deneme
★☆☆ İlk örnekteki projede CS8509'u hataya çevir (<WarningsAsErrors>CS8509</WarningsAsErrors>) ve build'i tekrar çalıştır. Hangi satır kırıldı? Bu ayar hangi senaryoda OCP'ye iyi bir alternatif olur?
★★☆ Keyed services örneğine bir WhatsAppChannel ekle. Kaç dosyaya dokunman gerekti? Ardından "fax" müşterisinin exception fırlatmak yerine e-postaya düşmesini sağla; bu kuralı nereye koydun ve neden?
★★★ "Gece 22:00 ile 08:00 arasında push gönderme" kuralını, mevcut kanalların hiçbirini değiştirmeden ekle. İpucu: INotificationChannel'ı saran bir class. Bu çözüm hangi design pattern'e benziyor ve OCP'nin hangi eksenini kullanıyor?
Bir sonraki yazıda OCP'nin dayandığı varsayımı sınıyoruz: yeni implementation abstraction'ın sözünü gerçekten tutuyor mu? Liskov Substitution Principle'ı, behavioral subtyping'in dört kuralını ve .NET'in kendi koleksiyonlarındaki NotSupportedException'ları konuşacağız.