Bir kuralı “unutma” diye hatırlatmak zorunda kalıyorsam, o kural henüz tasarlanmamıştır. Tasarlanmış kural hatırlanmaz; unutulamaz.


1. Yorum satırına yazılan kural

Kendi kodumu incelerken sorduğum tek bir soru var, ve bu soru neredeyse her tasarım tartışmasını açıyor:

Bu kuralı unutan ne yaşar?

Cevap üç türlü olabilir. “Derlenmez” ise mesele yok, kural zaten yapıya gömülmüş. “Anında patlar, stack trace yüzüne bakar” ise idare eder — geç ama net bir geri bildirim var. Ama cevap “sessizce yanlış davranır” ise, orada bir kural değil bir tuzak vardır.

Bu tuzakların ortak dış görünüşü de aynı: bir yerlerde, bir yorum satırında ya da bir README maddesinde şöyle bir cümle bulunur.

// Not: bu metodu çağırmadan önce X'i set etmeyi unutma
// Yeni handler eklediğinde Startup'a kaydını eklemeyi unutma
// Her komut Result dönmeli

Bu cümlelerin hepsi aynı şeyi itiraf ediyor: kuralın bekçisi insandır. İnsan bekçiliği şu üç noktada mutlaka çöker — yorgun bir cuma akşamı, kod tabanına yeni giren biri, ve altı ay sonraki ben. Üçünün de ortak özelliği “kötü mühendis” olmamaları; sadece kuralı o an akıllarında tutmuyor olmaları.

Alternatif olarak elimde ne var? Sırayla eledim:

  • Doküman yazmak. Kuralın nerede yazdığını bilmek de bir kuraldır; aynı problemi bir seviye yukarı taşır. (Doküman yazıyorum — ama kuralın bekçisi olarak değil, gerekçesinin kaydı olarak. İkisi farklı iş.)
  • Code review’de yakalamak. Tek kişilik bir gözden geçirme sürecinin yakalama oranı benim dikkatimle sınırlı. Üstelik incelemede aranan şey “eksik olan"dır; eksik olanı görmek, var olanı görmekten çok daha zordur.
  • Runtime kontrolü / assertion. Fena değil, bazı yerlerde tek seçenek. Ama geri bildirim çalışma anına, yani en pahalı ana ertelenmiş oluyor.
  • Kuralı imkânsızlaştırmak. Yanlış kullanım derlenmesin; ya da doğru kullanım için ekstra bir adım gerekmesin.

Sonuncusunu seçtim ve prensip olarak yazdım: kural hatırlanmaz, yapıya gömülür. Bu yazı, aynı prensibin bende çıktığı üç ayrı katmanı anlatıyor — type system, contract, composition root — ve sonunda bedelini.


2. Tip sistemine gömmek: eksik yapılandırma derlenmez

Somut problem: dosya depolamada disk yolu üretimi. Bir dosyayı yazmadan önce hangi klasör zincirinin altına gideceği belirlenmeli. Bu zincir iki farklı yoldan kurulabiliyor — ya veritabanındaki hazır klasör zincirinden, ya da ilk oluşturmada semantik parçalardan (ilişki tipi, sürüm vb.).

Klasik çözüm bir builder olurdu: Build() çağır, gerekli şeyler set edilmemişse InvalidOperationException at. Yani “içerik vermeyi unutma” kuralı, yine bir çalışma anı hatası. Üstelik bu hatanın çıkması için o kod yolunun çalışması gerekir; nadir bir dalsa aylarca gizlenir.

Bunun yerine builder’ı iki tipe böldüm. Hazırlık tipinde çıkış kapısı yok; çıkış kapısı yalnızca içerik verilmiş ara tipte var:

// Hazırlık aşaması — IPathBuilder
_pathBuilder.Scope();                          // DERLENMEZ: bu tipte Scope() yok

// İçerik verilmiş aşama — IScopedPath
var scopePath = _pathBuilder.ForStored(withParents);
var scope     = scopePath.Scope();             // tek geçerli yol

Gerçek kullanım, dosya ekleme akışından (isimler nötrleştirildi, yapı birebir):

var withParents = _folderRepository.GetWithParents(message.FolderId).ToArray(x => x.Name);
var scopePath   = _pathBuilder.ForStored(withParents);
var scope       = scopePath.Scope();
...
await _fileUploader.Upload(stream, entity.Name, scope);

Semantik kurulum yolu da aynı kapıya çıkıyor — zincirleme, ama yine ancak içerik verildikten sonra:

var scope = _pathBuilder.WithRelated(new RelatedItem
    {
        Type = message.Relation.Type,
        Code = message.Relation.Code,
        Id   = relationId
    })
    .WithVersion(message.FileVersion)
    .Scope();

Bu, literatürde type-state denen şeyin küçük ve ucuz hali: nesnenin durumu tipinde görünür, geçersiz durumda geçersiz metot yoktur. Ben buna “yanlış kullanımın adı yok” diyorum — IntelliSense listesinde çıkmayan metot, unutulabilecek bir çağrı değildir.

İkinci yarısı da önemli: Scope()‘un döndürdüğü şey immutable. Yazma tarafı (Upload, UploadChunk, Move) yalnızca bu dondurulmuş kesiti kabul ediyor, builder’ı değil. Böylece “uploader’a verdikten sonra path’i değiştirme” diye ikinci bir kural yazmama gerek kalmıyor; verilebilecek tek şey zaten değiştirilemez olan.

Bunun ucuz olmadığı yer de var, dördüncü bölümde geleceğim: tip sayısı arttı, ve “neden Scope() göremiyorum” sorusunun cevabı IntelliSense’te yazmıyor. Doküman borcunu kurala değil, keşfe ödedim — bence doğru takas.


3. Sözleşmeye gömmek: “her komut sonuç döner” bir imzadır

İkinci katman, uygulama katmanının komut/sorgu pipeline’ı. Kural şu: bir komut hata fırlatarak değil, sonuç taşıyarak biter. Validasyon, yetki, bulunamadı — bunlar normal akışın çıktısıdır, exception değil.

Bu kuralı konvansiyon olarak yazsaydım, yorum satırı şöyle olurdu: “handler’lar Result dönmeli, throw etmemeli”. Herkesin uyduğu gün güzel; birinin throw ettiği gün ise bu, HTTP katmanında 500’e dönüşür ve kullanıcı düzeltilebilir bir yazım hatasında “beklenmeyen hata” görür. Sessiz değil ama yanlış davranış.

Bunun yerine sarmalayıcı sözleşmenin kendisine kondu:

ICommand<T> : IRequest<ErrorOr<T>>
IQuery<T>   : IRequest<ErrorOr<T>>

Burada dikkat edilecek incelik, T‘nin her zaman iç değer olması. Servis kodu imzalarında ErrorOr yazmaz; sargı sözleşmede sabittir:

public class AddOrderCommand : ICommand<int>
{
    public int Id { get; set; }
    public string? Name { get; set; }
    ...
}

public class AddOrderValidator : CommandValidator<AddOrderCommand>
{
    public AddOrderValidator()
    {
        RuleFor(c => c.Name).NotEmpty();
    }
}

Handler tarafında hata dönmek, return cümlesinin kendisi:

if (message.Content == null || message.Content.Length == 0)
{
    return Error.Validation("File.Empty", "The provided file is empty.");
}

Kazanç sadece “hata akıyor” değil. Dönüş tipi sabitlendiği için pipeline’daki tüm behavior’lar (loglama, doğrulama, idempotency) derleme anında kurulabiliyor — dynamic yok, cast yok. Ve bu şekil, tip sisteminde bir ayrımı daha görünür kılıyor: dönüşü olmayan iç olaylar (domain event’ler) ErrorOr taşımıyor. “Bu olay bir cevap üretmez” kuralı da yorumda değil, base tipte.

Handler’ın MediatR girişi ise explicit implementation: dışarıdan çağrılamıyor. Yani “handler’ı doğrudan çağırıp pipeline’ı atlama” da bir disiplin meselesi olmaktan çıkıp erişilemezliğe dönüşmüş oluyor.

Eledim: throw + global exception filter ile aynı sonucu üretmek. Çalışır, ama iki sorunu var — beklenen hatanın maliyeti exception maliyetine bağlanır, ve daha önemlisi imzaya bakan hiç kimse bir komutun hangi hataları üretebileceğini göremez. ErrorOr bunu imzanın parçası yapıyor.


4. Bağlama noktasına gömmek: handler yazmak = kayıt

Üçüncü katman, en klasik “unutma” hatası: yeni bir handler ya da validator yazdın, Startup‘a kaydını eklemedin. Sonuç? Validator sessizce hiç çalışmaz. Doğrulanmamış komut kabul edilir. Bu, yazının başındaki üç cevaptan en kötüsü — sessizce yanlış davranış, ve bunu fark etmenin tek yolu prod’da bir veri bozulması.

Kayıt işini assembly taramasına verdim. Bir servisin tüm bağlanması şu kadar:

services.AddApplication(configuration.GetConnectionString("Default"), typeof(IRequestService).Assembly);

services.AddScoped<IRequestService, RequestService>();

services.AddCommandHandlers<DecideRequestCommandHandler>();
services.AddQueryHandlers<GetRequestListQueryHandler>();
services.AddRepositories<RequestRecordRepository>();

Buradaki tip parametreleri tek tek kayıt değil, assembly işaretçisi — “şu tipin bulunduğu yerdeki her şeyi tara” demenin okunur hali. Yeni bir handler eklemek Startup dosyasını hiç açtırmıyor.

Daha ilginç olanı, pipeline behavior’larının hiç görünmemesi. Servis Startup‘ları loglamayı, doğrulamayı, idempotency’yi bilmiyor; AddApplication içeride sıraya koyuyor. Sıra da tesadüfi değil: loglama redleri de görsün diye önde, doğrulama ucuz ve yan etkisiz olduğu için ondan sonra, idempotency anahtarı geçersiz istekte yanmasın diye en sonda. Bu sıralama tek bir yerde, bir kez yazılı — her serviste tekrarlanan ve dolayısıyla yanlış tekrarlanabilecek bir liste değil.

Bunun bedelini de dürüstçe yazayım, çünkü ödedim: tarama bir şekil eşleşmesine dayanır, ve şekle uymayan tip sessizce kapsam dışında kalır. Bende bunun bir vakası oldu — özel bir repository arayüzü, beklenen generic arayüzden türemediği için hiç kaydolmadı ve bu, kayıt eksikliğinin klasik semptomuyla (“çözülemeyen bağımlılık”) değil, davranış eksikliğiyle ortaya çıktı. Yani taramalı DI, “kaydı unutma” kuralını kaldırırken yerine daha küçük ama hâlâ gerçek bir kural bıraktı: şekle uy. Bunu kapatmanın yolu da aynı prensip — şeklin kendisini bir base tip/marker ile zorunlu kılmak, ve ne olursa olsun tarama kurallarını tek dosyada yazılı tutmak.


5. Bedeli ve sınırı

Bu üç tekniğin ortak bedeli tek kelimeyle: sihir hissi. Kod tabanına yeni giren biri “bu validator nerede kaydediliyor?” diye arattığında hiçbir şey bulamaz. Bulması gereken şey bir satır değil, bir kuraldır.

Bunu üç şeyle dengeliyorum:

  1. Kayıt yüzeyi tek ve okunur kalır. Nadir girilen kod sıkıcı olmalı — DI kayıtları, tarayıcılar, altyapı: isimli ara değişkenler, örnek girdi-çıktı yorumları. Zekice yazılmış bir tarama metodu, altı ay sonraki okuyucuya saygısızlıktır.
  2. Gömülü her kuralın gerekçesi yazılıdır. Kuralı yapıya gömmek, dokümanı gereksiz kılmaz; dokümanın işini değiştirir. Artık “unutma” demiyor, “neden böyle” diyor.
  3. Hata mesajının okunurluğu bir tasarım maddesidir. Generic’e gömülü kurallar patladığında çıkan mesajlar insanlık dışı olabiliyor. Bu yüzden tekrar eden arıza imzalarını teşhis reçetesi olarak biriktiriyorum — “hata mesajında şu tipin ikinci generic argümanında sargıyı görüyorsan sebebi çift sarmadır” gibi. Reçete, gömülü kuralın faturası.

Ne zaman gömmemeli? Üç durumda vazgeçiyorum:

  • Kural henüz oturmadıysa. Tipe gömülen kural, değiştirmesi en pahalı karardır; iki hafta içinde tersine dönebilecek bir kuralı tip sistemine yazmak, erken soyutlamanın en katı hali olur. Önce konvansiyon, tekrar kanıtlanınca yapı.
  • Unutmanın bedeli gürültülüyse. Unutulduğunda anında ve net patlayan bir şeyi ayrıca korumaya almak, karmaşıklığı bedava saymaktır. Ben yalnızca sessiz yanlışı kovalıyorum.
  • Gömme, kullanıcıyı kuralı öğrenmek zorunda bırakıyorsa. Eğer “bu metodu görmek için önce şunu çağırmalısın” bilgisi ancak dokümandan öğrenilebiliyorsa, kuralı kaldırmadım — yerini değiştirdim. Bu durumda ara tipin adı ve dönüş tipinin adı, kuralı kendi başına anlatacak kadar iyi olmalı. İsimlendirme burada süs değil, tasarımın taşıyıcı kolonu.

Karar

Kod incelemesinde tuttuğum soru değişmedi: bu kuralı unutan ne yaşar? Cevap “sessizce yanlış davranır” ise iş bitmemiştir, ve o kuralı üç kapıdan birine sokmaya çalışıyorum — type system (yanlış çağrının adı olmasın), contract (kural imzada dursun), composition root (doğru davranış için ek adım gerekmesin).

Hepsinin altındaki tez şu: unutulabilirlik bir insan zaafı değil, bir tasarım kokusudur. Kod tabanında “unutma” diye başlayan her cümle, aslında bitmemiş bir tasarımın imzasıdır.


Backend/.NET tarafında, mimari ağırlıklı işler arıyorum. Bu yazı, sıfırdan kurduğum bir .NET sisteminde verdiğim kararların defterinden — kod örnekleri gerçek, isimler nötrleştirildi.