Bridge Pattern: Abstraction ile Implementation'ı Ayrı Ayrı Büyütmek
Bridge, iki bağımsız değişim eksenini (mesaj tipi ve kanal gibi) tek bir inheritance hiyerarşisine sıkıştırmak yerine iki hiyerarşiye ayırıp bir referansla bağlar; class sayısı çarpım yerine toplam olur. Bookstore bildirimlerinde 3 × 3 class patlamasından 3 + 3'e geçiş, HttpClient/HttpMessageHandler ve ILogger/ILoggerProvider köprüleri ve Adapter ile farkı.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore müşterilerine üç tür bildirim gönderiyor: sipariş kargolandı uyarısı, depo ekibine günlük stok raporu ve giriş için tek kullanımlık şifre, yani OTP (One-Time Password). Her biri üç kanaldan gidebiliyor: e-posta, SMS (Short Message Service) ve mobil push. İlk geliştirici her kombinasyonu ayrı bir class olarak yazmış. Geçen ay güvenlik ekibi OTP'nin geçerlilik süresini 5 dakikadan 3 dakikaya indirdi ve bir geliştirici değişikliği yaptı:
Notification[] otps =
[
new EmailOtp("ada@example.com", "482913"),
new SmsOtp("+905550000001", "482913"),
new PushOtp("device-7f3a", "482913")
];
foreach (Notification otp in otps)
otp.Send();
Console.WriteLine($"Notification classes: {typeof(Notification).Assembly.GetTypes().Count(t => t.IsSubclassOf(typeof(Notification)))}");
public abstract class Notification(string recipient)
{
protected string Recipient => recipient;
public abstract void Send();
}
// Mesaj tipi x kanal: her kombinasyon ayrı bir class
public sealed class EmailOrderAlert(string to, string order) : Notification(to)
{
public override void Send() => Console.WriteLine($"[email] {Recipient} | Order {order} | Your order {order} has shipped.");
}
public sealed class SmsOrderAlert(string to, string order) : Notification(to)
{
public override void Send() => Console.WriteLine($"[sms] {Recipient} | Order {order} shipped.");
}
public sealed class PushOrderAlert(string to, string order) : Notification(to)
{
public override void Send() => Console.WriteLine($"[push] {Recipient} | Order {order} | Your order has shipped.");
}
public sealed class EmailStockReport(string to, int lowStock) : Notification(to)
{
public override void Send() => Console.WriteLine($"[email] {Recipient} | Daily stock | {lowStock} titles are below the reorder level.");
}
public sealed class SmsStockReport(string to, int lowStock) : Notification(to)
{
public override void Send() => Console.WriteLine($"[sms] {Recipient} | {lowStock} titles low.");
}
public sealed class PushStockReport(string to, int lowStock) : Notification(to)
{
public override void Send() => Console.WriteLine($"[push] {Recipient} | Daily stock | {lowStock} titles low.");
}
public sealed class EmailOtp(string to, string code) : Notification(to)
{
public override void Send() => Console.WriteLine($"[email] {Recipient} | Sign-in code | Your code is {code}. It expires in 3 minutes.");
}
public sealed class SmsOtp(string to, string code) : Notification(to)
{
public override void Send() => Console.WriteLine($"[sms] {Recipient} | Code {code}, valid for 3 min.");
}
public sealed class PushOtp(string to, string code) : Notification(to)
{
public override void Send() => Console.WriteLine($"[push] {Recipient} | Sign-in code | Your code is {code}. It expires in 5 minutes.");
}
Aynı kullanıcı aynı kodu üç kanaldan isterse, her kanalda kodun kaç dakika geçerli olduğunu görür? Ürün ekibi yarın dördüncü bir kanal ve dördüncü bir mesaj tipi isterse kaç class yazılacak?
Class Patlaması: İki Boyut, Tek Hiyerarşi
Çıktı:
[email] ada@example.com | Sign-in code | Your code is 482913. It expires in 3 minutes.
[sms] +905550000001 | Code 482913, valid for 3 min.
[push] device-7f3a | Sign-in code | Your code is 482913. It expires in 5 minutes.
Notification classes: 9
E-posta ve SMS 3 dakika diyor, push hâlâ 5. Değişikliği yapan geliştirici OTP class'larından ikisini buldu, üçüncüsünü kaçırdı. Bu bir dikkatsizlik değil, yapının doğal sonucu: "OTP mesajı nedir" bilgisi üç class'a, "SMS nasıl gönderilir" bilgisi de üç class'a dağılmış. Her bilgi, diğer boyuttaki seçenek sayısı kadar kopyalanıyor.
Sorunun kökü, birbirinden bağımsız iki şeyin, mesaj tipinin ve kanalın, tek bir inheritance hiyerarşisine sıkıştırılması. Mesaj tipi sayısı m, kanal sayısı n ise class sayısı m × n. Dördüncü kanal geldiğinde 3 yeni class, dördüncü mesaj tipi geldiğinde 4 yeni class daha; ikisi birden gelirse 16 class. C# multiple inheritance'a izin vermediği için "OTP olmak" ve "SMS ile gitmek" davranışlarını iki ayrı base class'tan almanın yolu da yok; ortak kodu yalnız kopyalayabilirsin.
Bridge Pattern: Abstraction ve Implementor
GoF (Gang of Four: Erich Gamma, Richard Helm, Ralph Johnson ve John Vlissides'in 1994 tarihli Design Patterns kitabı) Bridge'in amacını, bir abstraction'ı implementation'ından ayırıp ikisinin bağımsız olarak değişebilmesini sağlamak diye tanımlar. Kitaptaki örnek bir pencere sistemi: Window abstraction'ının farklı türleri (ikon penceresi, geçici pencere) ve farklı işletim sistemlerindeki pencere implementasyonları. Bizim hikâyemizde abstraction mesaj tipi, implementation ise kanal.
Burada "abstraction" ve "implementation" kelimelerinin C#'taki abstract class ve interface anlamıyla karıştırılmaması gerekiyor. Bridge'de abstraction, client'ın konuştuğu üst seviye kavram ("OTP gönder"), implementor ise o kavramın üzerine kurulduğu alt seviye platform ("şu alıcıya şu metni ilet"). Dört rol var. Abstraction, implementor'a bir referans tutan base class. Refined abstraction'lar, abstraction'dan türeyen asıl mesaj tipleri. Implementor, kanalların ortak interface'i. Concrete implementor'lar da tek tek kanallar. İki hiyerarşiyi birbirine bağlayan şey, abstraction'ın içindeki o tek referans: köprü budur.

Bookstore'da Bridge
Önce kanalı, yani implementor'ı tanımlayalım. GoF'un önemli bir notu var: implementor'ın interface'i abstraction'ın interface'ine benzemek zorunda değil. Tipik olarak implementor yalnız primitive operation'lar sunar, abstraction da daha üst seviye işlemleri bunların üzerine kurar. Kanalımızın primitive'i "ilet", bir de kanalın yeteneklerini bildiren iki property:
INotificationChannel email = new EmailChannel();
INotificationChannel sms = new SmsChannel();
INotificationChannel push = new PushChannel();
(INotificationChannel Channel, string Recipient)[] targets =
[(email, "ada@example.com"), (sms, "+905550000001"), (push, "device-7f3a")];
foreach ((INotificationChannel channel, string recipient) in targets)
new OneTimePassword(channel, "482913").SendTo(recipient);
new StockReport(sms, ["978-0141439518", "978-0547928227", "978-0061120084", "978-0451524935", "978-0743273565",
"978-0060935467", "978-0316769488", "978-0439023481", "978-0062315007", "978-0385490818"]).SendTo("+905550000009");
new OrderAlert(push, "BK-1001").SendTo("device-7f3a");
// Implementor: kanalın sunduğu primitive operation'lar
public interface INotificationChannel
{
int? MaxBodyLength { get; }
bool SupportsSubject { get; }
void Deliver(string recipient, string? subject, string body);
}
public sealed class EmailChannel : INotificationChannel
{
public int? MaxBodyLength => null;
public bool SupportsSubject => true;
public void Deliver(string recipient, string? subject, string body) =>
Console.WriteLine($"[email] {recipient} | {subject} | {body}");
}
public sealed class SmsChannel : INotificationChannel
{
public int? MaxBodyLength => 160;
public bool SupportsSubject => false;
public void Deliver(string recipient, string? subject, string body) =>
Console.WriteLine($"[sms] {recipient} | {body} ({body.Length} chars)");
}
public sealed class PushChannel : INotificationChannel
{
public int? MaxBodyLength => 120;
public bool SupportsSubject => true;
public void Deliver(string recipient, string? subject, string body) =>
Console.WriteLine($"[push] {recipient} | {subject} | {body}");
}
// Abstraction: mesajın ne olduğu; nasıl iletileceğini channel'a bırakır
public abstract class Notification(INotificationChannel channel)
{
protected abstract string Subject { get; }
protected abstract string Body { get; }
public void SendTo(string recipient)
{
string body = channel.MaxBodyLength is int max && Body.Length > max ? Body[..(max - 3)] + "..." : Body;
channel.Deliver(recipient, channel.SupportsSubject ? Subject : null, body);
}
}
// Refined abstraction'lar: her mesaj tipi bir kez yazılır
public sealed class OneTimePassword(INotificationChannel channel, string code) : Notification(channel)
{
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(3);
protected override string Subject => "Sign-in code";
protected override string Body => $"Your code is {code}. It expires in {Lifetime.TotalMinutes:0} minutes.";
}
public sealed class OrderAlert(INotificationChannel channel, string orderNumber) : Notification(channel)
{
protected override string Subject => $"Order {orderNumber}";
protected override string Body => $"Your order {orderNumber} has shipped.";
}
public sealed class StockReport(INotificationChannel channel, IReadOnlyList<string> lowStockIsbns) : Notification(channel)
{
protected override string Subject => "Daily stock";
protected override string Body => $"{lowStockIsbns.Count} titles below reorder level: {string.Join(", ", lowStockIsbns)}";
}
[email] ada@example.com | Sign-in code | Your code is 482913. It expires in 3 minutes.
[sms] +905550000001 | Your code is 482913. It expires in 3 minutes. (45 chars)
[push] device-7f3a | Sign-in code | Your code is 482913. It expires in 3 minutes.
[sms] +905550000009 | 10 titles below reorder level: 978-0141439518, 978-0547928227, 978-0061120084, 978-0451524935, 978-0743273565, 978-0060935467, 978-0316769488, 978-0439023481... (160 chars)
[push] device-7f3a | Order BK-1001 | Your order BK-1001 has shipped.
Kanallar mesajın ne olduğunu bilmiyor; yalnız Deliver'ı ve kendi sınırlarını biliyorlar. Mesaj tipleri de kanalın nasıl çalıştığını bilmiyor; yalnız konu ve gövdelerini veriyorlar. İkisinin buluştuğu yer abstraction'daki SendTo: gövdeyi kanalın uzunluk sınırına göre kısaltıyor, kanal konu satırını desteklemiyorsa konuyu atlıyor. Stok raporu SMS'te 160 karakterde kesildi, e-postada kesilmezdi. OTP'nin süresi artık tek bir yerde, OneTimePassword.Lifetime'da; üç kanal da aynı 3 dakikayı söylüyor.
Büyüme artık toplama. Dördüncü kanal, örneğin bir webhook, INotificationChannel'ı implement eden tek bir class; mevcut üç mesaj tipi ona hiçbir değişiklik olmadan gider. Dördüncü mesaj tipi, örneğin fiyat düşüşü bildirimi, Notification'dan türeyen tek bir class; üç kanaldan da gider. 3 × 3 = 9 class yerine 3 + 3 = 6 class, 4 × 4 = 16 yerine 4 + 4 = 8.
Bir de kazanç runtime'da. Inheritance'lı yapıda bir OTP'nin hangi kanaldan gideceği, hangi class'ın new'lendiğiyle compile time'da sabitlenmişti. Bridge'de kanal constructor'dan geliyor: kullanıcının tercihine, SMS sağlayıcısının o anki durumuna ya da bir feature flag'e göre aynı OneTimePassword'a farklı bir kanal verebilirsin. GoF bunu pattern'in faydaları arasında sayar: implementation abstraction'a kalıcı olarak bağlı değildir.
.NET'teki Köprüler: HttpClient ve ILogger
.NET'te Bridge'in en temiz örneği HttpClient. HttpClient, HttpMessageInvoker'dan türer ve constructor'ında bir HttpMessageHandler alır. Abstraction tarafında zengin bir API (Application Programming Interface) var: GetStringAsync, GetFromJsonAsync, DeleteAsync, base address, varsayılan header'lar, timeout. Implementor tarafında ise tek bir primitive operation: SendAsync. Gerçek uygulamada handler SocketsHttpHandler'dır; test'te kendi handler'ını verebilirsin:
using System.Net;
using System.Net.Http.Json;
// Abstraction (HttpClient) ile implementor (HttpMessageHandler) constructor'da birleşiyor
using HttpClient client = new(new InMemoryCatalogHandler()) { BaseAddress = new Uri("https://catalog.bookstore.test/") };
string raw = await client.GetStringAsync("books/978-0141439518");
Book? book = await client.GetFromJsonAsync<Book>("books/978-0141439518");
HttpResponseMessage missing = await client.DeleteAsync("books/000");
Console.WriteLine(raw);
Console.WriteLine(book);
Console.WriteLine((int)missing.StatusCode);
public sealed record Book(string Isbn, string Title);
// Implementor: tek bir primitive operation, SendAsync
public sealed class InMemoryCatalogHandler : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Console.WriteLine($"[handler] {request.Method} {request.RequestUri!.AbsolutePath}");
HttpResponseMessage response = request.RequestUri.AbsolutePath == "/books/978-0141439518"
? new(HttpStatusCode.OK) { Content = JsonContent.Create(new Book("978-0141439518", "Emma")) }
: new(HttpStatusCode.NotFound);
return Task.FromResult(response);
}
}
[handler] GET /books/978-0141439518
[handler] GET /books/978-0141439518
[handler] DELETE /books/000
{"isbn":"978-0141439518","title":"Emma"}
Book { Isbn = 978-0141439518, Title = Emma }
404
Üç farklı abstraction method'u tek bir primitive'e indi. JSON (JavaScript Object Notation) deserialize etmek, status code'u yorumlamak, URI'yi (Uniform Resource Identifier) base address ile birleştirmek abstraction'ın işi; byte'ları gerçekten bir yere göndermek implementor'ın. Aynı HttpClient API'si, altındaki handler socket'e mi, bellekteki bir sözlüğe mi yoksa bir tarayıcının fetch API'sine mi gittiğini bilmeden çalışıyor. Handler'ların zincir hâlinde birbirine bağlanması (DelegatingHandler) ise bu serinin Decorator ve Chain of Responsibility yazılarında karşımıza çıkacak.
İkinci örnek logging. Uygulama kodu ILogger<T> ile konuşur; mesajın konsola mı, bir dosyaya mı, bir log toplama servisine mi gideceğini ILoggerProvider implementasyonları belirler:
using Microsoft.Extensions.Logging;
using ILoggerFactory factory = LoggerFactory.Create(builder => builder
.SetMinimumLevel(LogLevel.Information)
.AddProvider(new LineLoggerProvider("audit"))
.AddProvider(new LineLoggerProvider("ops")));
// Client yalnız abstraction'ı görür: ILogger<T>
ILogger<OrderService> logger = factory.CreateLogger<OrderService>();
logger.LogInformation("Order {OrderNumber} shipped", "BK-1001");
logger.LogDebug("Cache hit for {OrderNumber}", "BK-1001");
public sealed class OrderService;
// Implementor tarafı: her provider kendi ILogger'ını üretir
public sealed class LineLoggerProvider(string sink) : ILoggerProvider
{
public ILogger CreateLogger(string categoryName) => new LineLogger(sink, categoryName);
public void Dispose() { }
}
public sealed class LineLogger(string sink, string category) : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
Func<TState, Exception?, string> formatter) =>
Console.WriteLine($"[{sink}] {logLevel} {category}: {formatter(state, exception)}");
}
[audit] Information OrderService: Order BK-1001 shipped
[ops] Information OrderService: Order BK-1001 shipped
Tek bir LogInformation çağrısı iki provider'a gitti. LogDebug ise hiçbirine ulaşmadı, oysa iki logger'ın da IsEnabled'ı her zaman true dönüyor: minimum seviye filtresini abstraction tarafı uyguladı. .NET kaynak kodunda LoggerFactory'nin döndürdüğü logger, internal Logger class'ıdır; içinde her provider'dan gelen bir logger array'i tutar, filtreyi uygular ve mesajı sırayla her birine iletir, bir provider exception fırlatsa bile diğerlerini çağırmaya devam eder. Abstraction'ın üst seviye davranışı (filtreleme, kategori, mesaj template'i, birden fazla hedefe dağıtım) ile implementor'ın primitive'i (bu satırı şuraya yaz) ayrı ayrı büyüyor. ASP.NET Core Web API serisindeki "ASP.NET Core'da Logging: ILogger, Log Seviyeleri, Structured Logging ve Serilog" yazısı bu altyapının kullanımını anlatıyor.
Bridge mi, Adapter mı?
İki pattern'in kod yapısı neredeyse aynı: bir class, bir interface'e referans tutuyor ve işi ona devrediyor. Fark niyette ve zamanlamada. GoF da structural pattern'leri karşılaştırırken bunu vurgular: adapter, tasarlanmış şeyleri sonradan birlikte çalıştırır; bridge, onları baştan birlikte çalışacak şekilde kurar.
| Soru | Adapter | Bridge |
|---|---|---|
| Ne zaman devreye girer? | Tasarımdan sonra, uyumsuz parçalar ortaya çıktığında | Tasarım sırasında, iki boyut öngörüldüğünde |
| Hangi problemi çözer? | Var olan bir interface'i beklenen interface'e uydurmak | İki bağımsız değişim eksenini ayırmak |
| Interface'leri kim tasarlar? | Adaptee başkasının; sen yalnız adapter'ı yazarsın | Abstraction ve implementor'ı sen birlikte tasarlarsın |
| Kaç hiyerarşi var? | Genellikle tek adapter, tek adaptee | İki hiyerarşi, ikisi de büyür |
| Örnek | StreamReader, ödeme sağlayıcısı adapter'ları | HttpClient/HttpMessageHandler, ILogger/ILoggerProvider |
Bir önceki yazıdaki ödeme adapter'ları, SDK'lar (Software Development Kit) zaten var olduğu ve onları değiştiremediğimiz için adapter'dı. Bildirim kanalları ise baştan bizim tasarımımız; kanal interface'ini mesaj tiplerinin ihtiyacına göre biz belirledik. Pratikte ikisi bir arada da görünür: bir concrete implementor, üçüncü parti bir SMS SDK'sını sarıyorsa, o class aynı anda hem Bridge'in implementor'ı hem de bir adapter'dır.
Ne Zaman Kullanma
Tek boyutlu değişimde. Bookstore'un tek bir mesaj tipi ve üç kanalı varsa, ya da tek kanal ve üç mesaj tipi varsa, ortada köprülenecek ikinci bir hiyerarşi yok. Bir interface ve birkaç implementasyon yeter; abstraction tarafına ayrıca bir base class koymak yalnız indirection ekler.
Boyutlardan biri spekülatifse. "Belki ileride başka bir kanal gelir" diye bugün tek kanallı bir sistemde iki hiyerarşi kurmak, SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) ve Design Principles serisindeki "Open/Closed Principle: Değiştirmeden Genişletmek ve Spekülatif Genellik Tuzağı" yazısında konuştuğumuz tuzak. İkinci kanal geldiğinde refactoring ile Bridge'e geçmek, kod küçükken ucuzdur.
Boyutlar gerçekten bağımsız değilse. Her mesaj tipi her kanalda bambaşka davranıyorsa, örneğin push bildiriminin bir deep link'i, e-postanın HTML (HyperText Markup Language) template'i ve ekleri, SMS'in kısaltılmış bir metni varsa ve bunlar mesaj tipine göre değişiyorsa, implementor'ın primitive'leri her yeni ihtiyaçla şişer ve abstraction if (channel is SmsChannel) kontrolleriyle dolar. Bu durumda köprü iki boyutu ayırmıyor, yalnız birleştirme noktasını gizliyordur; kombinasyon başına bir template ya da renderer daha dürüst bir tasarım olabilir.
Sık Düşülen Tuzaklar
Implementor'ı abstraction'ın aynası yapmak. INotificationChannel'a SendOtp, SendOrderAlert, SendStockReport method'ları koyarsan her yeni mesaj tipi her kanala yeni bir method ekletir ve class patlaması bu kez method patlaması olarak geri gelir. Implementor'ın interface'i primitive kalmalı.
Kanal tipine göre dallanmak. Abstraction'da channel is SmsChannel kontrolü gördüğün an köprü çatlamıştır. Kanala özgü bir bilgiye ihtiyaç varsa onu implementor'ın sözleşmesine bir yetenek olarak ekle; yukarıdaki MaxBodyLength ve SupportsSubject bunun örneği.
Leaky abstraction. Kanalın sınırları abstraction'a hiç yansımazsa sorunlar runtime'da çıkar: 160 karakterlik SMS sınırı bilinmeden yazılan bir mesaj sağlayıcı tarafında ya kesilir ya da birden fazla SMS olarak faturalanır. Yetenekleri açıkça modellemek bu yüzden önemli.
Bridge'i yalnızca dependency injection sanmak. Her interface'i constructor'dan almak, implementor tarafını kurar; Bridge'in ayırt edici yanı, abstraction tarafının da kendi hiyerarşisiyle büyümesi. Tek bir OrderService'in IEmailSender alması Bridge değil, sıradan bir bağımlılık.
Küçük Bir Deneme
★☆☆ Bridge örneğine bir WebhookChannel ekle: konu desteklemesin, uzunluk sınırı olmasın ve mesajı JSON olarak yazsın. Kaç dosyaya dokunman gerekti? Aynı değişikliği açılıştaki inheritance'lı yapıda yapsaydın kaç class eklerdin?
★★☆ PriceDropAlert adında dördüncü bir mesaj tipi ekle (ISBN (International Standard Book Number), eski ve yeni fiyat). Sonra SMS'te gövde 160 karakteri aştığında kesmek yerine iki parçaya bölen bir kural düşün: bu kural abstraction'a mı, implementor'a mı ait?
★★★ Kullanıcının kanal tercihine göre çalışan ve tercih edilen kanal başarısız olursa e-postaya düşen bir FallbackChannel yaz; o da INotificationChannel implement etsin. Bu class Bridge'in neresinde duruyor ve bu serideki hangi pattern'e benziyor?
Bridge, iki bağımsız hiyerarşiyi tek bir referansla bağladı. Bir sonraki yazıda yine yapıyla ilgili ama bu kez ağaç şeklinde bir problem var: Composite pattern ile kitap paketleri gibi iç içe geçmiş yapıları, tek bir kitapla aynı interface üzerinden ele almak.