Trade-off Düşüncesi: Mimaride Doğru Cevap Yok, En Az Kötü Seçenek Var
Mimaride her seçenek bir characteristic'i başka birinin pahasına kazanır; iş, bedeli görünür kılıp bilinçli kabul etmektir. Bookstore'da sipariş e-postasını senkron göndermek checkout'u SMTP'ye bağlayıp çift sipariş üretirken, Channel<T> ile queue'ya almak checkout'u 1 ms'ye indiriyor ama process kapanınca 3 e-postayı kaybediyor; one-way/two-way door, last responsible moment ve kararın neden'ini yazmak.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da sipariş verildiğinde müşteriye bir onay e-postası gidiyor. Bugünkü kod en doğal halinde: sipariş kaydediliyor, ardından aynı request içinde e-posta gönderiliyor ve sipariş numarası dönüyor. E-posta sağlayıcısını bir fake ile simüle edip dört durumu deniyoruz: SMTP (Simple Mail Transfer Protocol) sunucusu normal (150 ms), yavaş (2 saniye), çökmüş, ve çökmüşken müşterinin "tekrar dene" butonuna basması.
using System.Diagnostics;
OrderStore store = new();
FakeEmailSender email = new();
OrderService orders = new(store, email);
email.Delay = TimeSpan.FromMilliseconds(150);
await Measure("smtp normal", () => orders.PlaceOrderAsync("Ada", 44.47m));
email.Delay = TimeSpan.FromSeconds(2);
await Measure("smtp slow", () => orders.PlaceOrderAsync("Alan", 12.99m));
email.Delay = TimeSpan.FromMilliseconds(150);
email.IsDown = true;
await Measure("smtp down", () => orders.PlaceOrderAsync("Grace", 18.49m));
await Measure("retry", () => orders.PlaceOrderAsync("Grace", 18.49m));
Console.WriteLine($"orders in database: {store.Count} (Grace: {store.CountFor("Grace")})");
static async Task Measure(string label, Func<Task<int>> placeOrder)
{
Stopwatch watch = Stopwatch.StartNew();
try
{
int id = await placeOrder();
Console.WriteLine($"{label,-12} order {id} placed in {watch.ElapsedMilliseconds} ms");
}
catch (Exception ex)
{
Console.WriteLine($"{label,-12} FAILED after {watch.ElapsedMilliseconds} ms: {ex.Message}");
}
}
public sealed record OrderConfirmation(int OrderId, string CustomerName);
public interface IEmailSender
{
Task SendAsync(OrderConfirmation message, CancellationToken ct = default);
}
public sealed class OrderService(OrderStore store, IEmailSender email)
{
public async Task<int> PlaceOrderAsync(string customerName, decimal total, CancellationToken ct = default)
{
int id = store.Save(customerName, total);
// Sipariş kaydedildi; e-posta aynı request içinde, senkron gidiyor
await email.SendAsync(new OrderConfirmation(id, customerName), ct);
return id;
}
}
public sealed class OrderStore
{
private readonly List<(int Id, string CustomerName, decimal Total)> _rows = [];
public int Count => _rows.Count;
public int Save(string customerName, decimal total)
{
int id = 1042 + _rows.Count;
_rows.Add((id, customerName, total));
return id;
}
public int CountFor(string customerName) => _rows.Count(r => r.CustomerName == customerName);
}
public sealed class FakeEmailSender : IEmailSender
{
public TimeSpan Delay { get; set; }
public bool IsDown { get; set; }
public async Task SendAsync(OrderConfirmation message, CancellationToken ct = default)
{
await Task.Delay(Delay, ct);
if (IsDown)
throw new IOException("SMTP server unavailable");
}
}
Her satır kaç milisaniye sürer? Grace'in iki denemesinin sonunda müşteri ne görür ve veritabanında kaç sipariş vardır?
Senkron E-postanın Gerçek Bedeli
smtp normal order 1042 placed in 155 ms
smtp slow order 1043 placed in 2002 ms
smtp down FAILED after 152 ms: SMTP server unavailable
retry FAILED after 152 ms: SMTP server unavailable
orders in database: 4 (Grace: 2)
Üç ayrı sorun var ve hiçbiri e-postanın kendisiyle ilgili değil. Birincisi latency: checkout'un süresi artık e-posta sağlayıcısının süresi. Sağlayıcı yavaşladığında Alan'ın siparişi iki saniye sürdü; bir önceki yazıdaki "checkout p95 < 800 ms" senaryosu, kontrolümüzde olmayan bir sistemin keyfine kaldı. İkincisi availability: sağlayıcı çöktüğünde müşteri hata görüyor. Checkout'un availability'si artık kabaca veritabanının availability'si çarpı e-posta sağlayıcısının availability'si; ikisi de yüzde 99,9 ise çarpım yaklaşık yüzde 99,8, yani ayda 43 değil 86 dakikaya yakın kesinti. Üçüncüsü ve en sinsisi consistency: sipariş kaydedildikten sonra exception fırladı. Müşteri "başarısız" gördü, tekrar denedi, veritabanında Grace'in iki siparişi oldu. Bu, Çözüm Desenleri serisinin "Idempotency Key" yazısının anlattığı çift sipariş sorununun ta kendisi; bu sefer çift tıklamadan değil, mimariden geliyor.
Bu tür bağımlılığa temporal coupling denir: iki parça aynı anda ayakta olmak zorunda. Sipariş vermek, e-posta göndermekten çok daha önemli bir iş, ama ikisi aynı request'e bağlandığı için önemsiz olan önemliyi düşürebiliyor.
Queue'ya Bırakmak
Klasik çözüm, e-posta işini bir queue'ya bırakıp request'i beklemeden döndürmek ve e-postayı arka planda bir worker'ın göndermesi. .NET'te process içi en basit queue System.Threading.Channels. Aynı senaryoyu bu sefer SMTP çökmüşken başlatıyoruz, bir süre sonra sunucuyu geri getiriyoruz, en sonda da e-postalar gönderilirken process'i kapatıyoruz:
using System.Diagnostics;
using System.Threading.Channels;
OrderStore store = new();
FakeEmailSender email = new() { Delay = TimeSpan.FromMilliseconds(150), IsDown = true };
Channel<OrderConfirmation> queue = Channel.CreateBounded<OrderConfirmation>(1_000);
OrderService orders = new(store, queue.Writer);
EmailWorker worker = new(queue.Reader, email);
using CancellationTokenSource shutdown = new();
Task running = worker.RunAsync(shutdown.Token);
await Measure("smtp down", () => orders.PlaceOrderAsync("Grace", 18.49m));
await Measure("smtp down", () => orders.PlaceOrderAsync("Linus", 9.99m));
await Task.Delay(1_000);
Console.WriteLine($"after 1 s: sent={email.SentCount}, attempts={worker.Attempts}, queued={queue.Reader.Count}");
email.IsDown = false;
await Task.Delay(1_000);
Console.WriteLine($"smtp back: sent={email.SentCount}, queued={queue.Reader.Count}");
email.Delay = TimeSpan.FromSeconds(2);
for (int i = 0; i < 3; i++)
await orders.PlaceOrderAsync("Barbara", 7.50m);
await Task.Delay(100);
// Deploy ya da çökme: process kapanıyor, bellekteki queue ile birlikte
shutdown.Cancel();
await running;
Console.WriteLine($"process stopped: orders={store.Count}, sent={email.SentCount}, " +
$"lost={queue.Reader.Count + worker.InFlight}");
static async Task Measure(string label, Func<Task<int>> placeOrder)
{
Stopwatch watch = Stopwatch.StartNew();
int id = await placeOrder();
Console.WriteLine($"{label,-10} order {id} placed in {watch.ElapsedMilliseconds} ms");
}
public sealed record OrderConfirmation(int OrderId, string CustomerName);
public interface IEmailSender
{
Task SendAsync(OrderConfirmation message, CancellationToken ct = default);
}
public sealed class OrderService(OrderStore store, ChannelWriter<OrderConfirmation> emails)
{
public async Task<int> PlaceOrderAsync(string customerName, decimal total, CancellationToken ct = default)
{
int id = store.Save(customerName, total);
// E-posta işi queue'ya bırakılıyor; request beklemiyor
await emails.WriteAsync(new OrderConfirmation(id, customerName), ct);
return id;
}
}
public sealed class EmailWorker(ChannelReader<OrderConfirmation> queue, IEmailSender email)
{
public int Attempts { get; private set; }
public int InFlight { get; private set; }
public async Task RunAsync(CancellationToken ct)
{
try
{
await foreach (OrderConfirmation message in queue.ReadAllAsync(ct))
{
InFlight = 1;
while (true)
{
try
{
Attempts++;
await email.SendAsync(message, ct);
break;
}
catch (IOException)
{
// SMTP yoksa bekle ve aynı mesajı tekrar dene
await Task.Delay(300, ct);
}
}
InFlight = 0;
}
}
catch (OperationCanceledException)
{
// Kapanış: elde kalan mesaj ve queue'daki her şey bellekle birlikte gider
}
}
}
public sealed class OrderStore
{
private readonly List<(int Id, string CustomerName, decimal Total)> _rows = [];
public int Count => _rows.Count;
public int Save(string customerName, decimal total)
{
int id = 1042 + _rows.Count;
_rows.Add((id, customerName, total));
return id;
}
}
public sealed class FakeEmailSender : IEmailSender
{
private int _sent;
public TimeSpan Delay { get; set; }
public bool IsDown { get; set; }
public int SentCount => _sent;
public async Task SendAsync(OrderConfirmation message, CancellationToken ct = default)
{
await Task.Delay(Delay, ct);
if (IsDown)
throw new IOException("SMTP server unavailable");
Interlocked.Increment(ref _sent);
}
}
smtp down order 1042 placed in 1 ms
smtp down order 1043 placed in 0 ms
after 1 s: sent=0, attempts=3, queued=1
smtp back: sent=2, queued=0
process stopped: orders=5, sent=2, lost=3
Checkout artık bir milisaniye; SMTP çökmüşken bile siparişler alınıyor. Worker başarısız gönderimi 300 milisaniyede bir tekrar deniyor ve sunucu geri gelince bekleyen iki e-posta gidiyor. Müşteri tarafında hiçbir hata yok, çift sipariş yok.
Son satır ise bedeli gösteriyor. Barbara'nın üç siparişi veritabanında, ama e-postaları bellekteki queue'daydı. Process kapanınca (bir deploy, bir çökme, bir container restart'ı) üç e-posta sessizce kayboldu. Hiçbir log, hiçbir hata yok. Ayrıca sistem artık daha karmaşık: bir worker, bir retry politikası, queue'nun ne kadar dolduğunu izleme ihtiyacı ve "sipariş verildi ama e-postası gitmedi" diye yeni bir ara durum.
İki Seçenek, Bir Tablo
İki seçeneği yan yana koyalım. Ölçümler yukarıdaki iki programdan:
| Kriter | Senkron gönderim | Process içi queue (Channel<T>) |
|---|---|---|
| Checkout latency | SMTP süresi kadar (155 ms, yavaşken 2002 ms) | SMTP'den bağımsız (0–2 ms) |
| SMTP çökmüşken sipariş | Müşteri hata görür, tekrar deneme çift sipariş üretir | Sipariş alınır, e-posta sonra gider |
| E-posta garantisi | Request başarılıysa e-posta gitmiştir | Process kapanırsa queue'daki e-postalar kaybolur |
| Karmaşıklık | Tek method, tek akış | Worker, retry, kapanış davranışı, izleme |
| Hata ayıklama | Hata request'in içinde, stack trace'te | Hata ayrı bir akışta, ayrıca loglanmalı |
| Ne zaman uygun | Düşük trafik, güvenilir sağlayıcı, kaybın kabul edilemediği iş | Latency ve availability öncelikli, nadir kaybın tolere edildiği iş |
Tabloda "doğru" kolon yok. Bookstore için karar şu: sipariş onay e-postası kritik ama kaybı telafi edilebilir (müşteri sipariş sayfasını görebiliyor, müşteri hizmetleri yeniden gönderebiliyor), checkout'un availability'si ve latency'si ise ilk üç characteristic arasında. Queue kazanıyor ve kayıp riski bilinçli olarak kabul ediliyor. Aynı karar, ödeme capture'ı gibi kaybın kabul edilemeyeceği bir iş için ters çıkabilirdi.
Kaybı ortadan kaldırmanın da bir yolu var: e-posta isteğini siparişle aynı veritabanı transaction'ında bir tabloya yazmak ve worker'ın oradan okuması. Adı transactional outbox; kaybı çözer ama veritabanına yazma, temizleme ve tekrar gönderimde çift e-posta ihtimali gibi kendi trade-off'larını getirir. Ayrıntısı ilerideki Messaging ve Event-Driven Architecture serisinde.
Characteristic'ler Neden Çatışır?
Bu örnekte availability ve performance ile consistency (e-postanın siparişle birlikte gerçekten gitmesi) çatıştı. Bu çatışmalar istisna değil, kural. Birkaç tanıdık çift:
Security ile performance. Parolaları yavaş bir hash ile saklamak, saldırganın brute force maliyetini artırır ama her login'i de yavaşlatır; OWASP (Open Worldwide Application Security Project) Top 10 serisinin parola saklama yazısındaki iterasyon sayısı tam bu trade-off'un ayarıdır. Her request'te yapılan authorization kontrolü, şifreleme ve audit log'u da latency'ye eklenir.
Flexibility ile simplicity. Her ödeme sağlayıcısını bir plugin olarak yüklenebilir yapmak, yeni sağlayıcı eklemeyi kolaylaştırır ama bugün tek sağlayıcı varken kodu okumayı zorlaştırır. SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) ve Design Principles serisindeki "spekülatif genellik" tartışması bunun class seviyesindeki karşılığı.
Consistency ile availability. Bir önceki yazıdaki "PaymentPending" kararı bunun küçük bir örneğiydi: ödeme sağlayıcısı yanıt vermediğinde siparişi reddetmek tutarlıdır ama satış kaybettirir; kabul etmek satışı kurtarır ama sistemde bir süreliğine "ödemesi belirsiz sipariş" diye bir durum yaratır. Dağıtık sistemlerde bu gerilimin resmî adı CAP (Consistency, Availability, Partition tolerance) teoremi; ayrıntısı ilerideki System Design ve Distributed Systems Temelleri serilerinde.
.NET'in kendi kütüphanesi de aynı kararı vermek zorunda kalmış. Console logger, log mesajlarını doğrudan yazmaz; bellekteki bir queue'ya koyar ve arka plandaki bir thread yazar. Queue dolarsa ne olacağı bir ayar:
using Microsoft.Extensions.Logging.Console;
ConsoleLoggerOptions defaults = new();
Console.WriteLine($"MaxQueueLength={defaults.MaxQueueLength}, QueueFullMode={defaults.QueueFullMode}");
MaxQueueLength=2500, QueueFullMode=Wait
Varsayılan Wait: queue doluysa log yazan thread bekler, yani log kaybolmaz ama uygulama yavaşlar. DropWrite seçersen uygulama hiç beklemez, ama fazla mesajlar atılır. .NET ekibi varsayılanda kaybı değil latency'yi feda etmeyi seçmiş ve bunu bir ayar olarak sana bırakmış. İyi bir trade-off kararı tam olarak böyle görünür: iki seçeneğin bedeli belli, varsayılan gerekçeli, değiştirmek mümkün.
Geri Alınabilir ve Geri Alınamaz Kararlar
Her trade-off kararına aynı ciddiyetle yaklaşmak da bir hata. Jeff Bezos, Amazon'un 2015 yılı hissedar mektubunda kararları ikiye ayırır. Type 1 kararlar tek yönlü kapılardır: geçtikten sonra geri dönmek imkânsız ya da çok pahalıdır; bunlar yavaş, dikkatli ve danışarak verilmelidir. Type 2 kararlar iki yönlü kapılardır: yanlış çıkarsa geri dönersin; bunlar küçük ekipler ya da tek kişiler tarafından hızlı verilmelidir. Mektubun uyarısı, kurumlar büyüdükçe Type 2 kararları da Type 1 gibi ağır bir süreçle vermeye başlaması ve bunun yavaşlık ve deneme korkusu getirmesi.
Bookstore'un e-posta kararına bu gözle bakalım. Process içi Channel<T> kullanmak iki yönlü bir kapı: OrderService yalnız bir ChannelWriter<OrderConfirmation> görüyor, yarın bunu kalıcı bir queue'ya yazan bir implementation ile değiştirmek tek bir class'ı ilgilendirir. Buna karşılık bir message broker ürünü seçip mesaj formatını başka ekiplerin tükettiği bir contract haline getirmek tek yönlü bir kapıya çok daha yakın; o format bir kez yayıldığında, ilk yazıdaki sipariş numarası gibi, sistemin sınırını geçmiş olur.
Pratik sonuç: iki yönlü kapıları hızlı geç, tek yönlü kapıların önünde yavaşla. Daha da iyisi, tek yönlü bir kapıyı iki yönlüye çevirmenin yolunu ara: bir interface, bir adapter, bir feature flag, dışarıya açılan contract'ı içerideki modelden ayırmak.
Last Responsible Moment ve YAGNI
Tek yönlü bir kararı ne zaman vermeli? Mary ve Tom Poppendieck'in Lean Software Development (2003) kitabındaki araçlardan biri last responsible moment: kararı, vermemenin önemli bir alternatifi ortadan kaldıracağı ana kadar ertelemek. Ertelemenin değeri şu: o ana kadar daha fazla bilgi toplarsın. Bookstore bugün günde 800 sipariş alıyor; hangi broker'ın gerekeceğini, mesaj hacmini, hangi ekiplerin bu event'leri tüketeceğini bugün bilmiyoruz. Bugün broker seçmek, en az bilgiyle en pahalı kararı vermek demek.
Ama "last responsible" kelimesindeki "responsible" önemli. Kararı o anın da ötesine ertelersen, karar senin yerine verilir: sistem büyür, senkron e-posta gönderimi on yerde kopyalanır ve geri dönüş pahalılaşır. YAGNI (You Aren't Gonna Need It) ile denge burada kuruluyor; SOLID ve Design Principles serisinin DRY (Don't Repeat Yourself), KISS (Keep It Simple, Stupid) ve YAGNI yazısında gördüğümüz gibi YAGNI, bugün ihtiyaç olmayan özelliği inşa etme der, kodu değiştirilebilir tutmaktan vazgeç demez. Bookstore'un seçimi bu dengeye bir örnek: bugün process içi bir queue (ucuz, yeterli), ama OrderService'in queue'nun nasıl implement edildiğini bilmediği bir sınır (ileride broker'a geçişi iki yönlü kapı olarak tutan küçük bir yatırım).
Kural olarak: seçeneği açık tutmanın bedeli düşükse (bir interface, bir method imzası) tut; yüksekse (bugünden dağıtık bir altyapı kurup işletmek) bekle ve ne zaman yeniden bakacağını yaz.
Kararın "Neden"ini Yazmak
İlk yazıda FoSA'nın (Fundamentals of Software Architecture, Richards ve Ford) ikinci yasasını görmüştük: neden, nasıldan daha önemli. Bu karar altı ay sonra sorgulanacak; biri "e-postalar neden bazen kayboluyor?" diye soracak. Cevabın kodda değil, yazılı bir notta olması gerekiyor. En küçük haliyle bir karar notu şunları içerir:
- Bağlam: checkout latency'si ve availability'si ilk üç characteristic arasında; e-posta sağlayıcısı haftada bir-iki kez yavaşlıyor.
- Karar: sipariş onay e-postası process içi bir queue üzerinden, arka planda gönderilecek.
- Alternatifler: senkron gönderim; transactional outbox; harici bir message broker.
- Kabul edilen bedel: deploy ya da çökme anında queue'daki e-postalar kaybolabilir; müşteri sipariş sayfasından durumu görebilir.
- Yeniden bakma koşulu: kayıp ayda 10 e-postayı aşarsa ya da başka bir ekip sipariş event'lerini tüketmek isterse.
Son madde en çok atlanan ve en değerli olanı: kararın hangi koşulda geçerliliğini yitireceğini önceden söylüyor. Bu notun standart hali ADR (Architecture Decision Record); formatı, saklanma yeri ve ekipte nasıl yaşatılacağı ilerideki Architecture Decision'lar serisinin konusu.
Sık Düşülen Tuzaklar
Trade-off'u görmemek. "Queue'ya alalım, daha hızlı olur" cümlesinde bedel yok. Bedeli söylenmemiş bir öneri, yarısı söylenmiş bir öneridir; FoSA'nın birinci yasasının uyarısı tam bu.
Her kararı tek yönlü kapı gibi ele almak. Process içi bir queue'yu denemek için üç haftalık bir mimari kurul süreci, ekibi yavaşlatır ve deneme kültürünü öldürür. Geri alınabilir kararları hızlı ver.
Tek yönlü kapıyı fark etmeden geçmek. Bir mesaj formatını, bir public URL'i (Uniform Resource Locator), bir veritabanı şemasını başka ekiplerin kullanımına açmak çoğu zaman sessizce olur. Bir şeyi sistem sınırının dışına açmadan önce dur ve geri dönüş maliyetini sor.
YAGNI'yi değiştirilebilirlikten vazgeçmek sanmak. Bugün broker kurmamak doğru olabilir; OrderService'i doğrudan SMTP client'a bağlamak ise ileride kapıyı kapatır. Ucuz seçenekleri açık tut.
Kayıp ihtimalini ölçmemek. In-memory queue'nun "nadiren" mesaj kaybettiğini varsayma; kapanışta queue'da kalan mesaj sayısını logla. Kabul ettiğin bedelin gerçekte ne kadar olduğunu bilmiyorsan, onu kabul etmiş sayılmazsın.
Küçük Bir Deneme
★☆☆ Queue'lu programda Channel.CreateBounded'ın kapasitesini 2 yap ve SMTP çökmüşken art arda beş sipariş ver. PlaceOrderAsync ne kadar sürüyor? Bounded queue'nun dolması checkout'u hangi seçeneğe geri döndürüyor?
★★☆ Worker'a bir kapanış davranışı ekle: shutdown iptal edildiğinde queue'da kalan mesajları ve elindeki mesajı bir dosyaya yaz, process yeniden başlayınca dosyadan okuyup queue'ya geri koy. Bu kaybı ne kadar azaltıyor, hangi durumda hâlâ kayıp var?
★★★ Kendi sisteminde son bir yılda verilen bir kararı bul ve iki yönlü mü tek yönlü mü olduğunu tartış. Tek yönlüyse, onu iki yönlüye çevirecek en küçük değişiklik ne olurdu? Kararı yukarıdaki beş maddelik notla yeniden yaz.
Bu yazıyla Blok A bitiyor: mimarinin ne olduğunu, characteristic'leri, onları ölçmeyi ve aralarında seçim yapmayı konuştuk. Bir sonraki yazıda ilk mimari stile geçiyoruz: Bookstore'un bugünkü haline en yakın olan Layered Architecture ve presentation, business ve data layer'larının gerçek sorumlulukları.