Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Domain-Driven Design — #08

Always-Valid Domain Model: Invariant'ları Constructor, Factory ve Guard'larla Korumak

Mert Özen 7 Kas 2026 12 dk 5 görüntülenme
Always-Valid Domain Model: Invariant'ları Constructor, Factory ve Guard'larla Korumak

Always-valid domain model, invariant'ların kaydetmeden önce kontrol edilmesi değil, ihlal edilmesinin imkânsız olmasıdır: private constructor, static factory ve guard clause'lar geçersiz state'e giden her yolu kapatır. Bookstore'un Order'ında en az bir satır, kitap başına 10 adet, tek para birimi ve tek kupon kurallarını; required/init ve C# 14 field'ın sınırlarını, domain exception ile Result ayrımını ve input validation ile invariant farkını gerçek çıktılarla işliyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un katmanlı monolith'indeki Bookstore.Business project'inde sipariş böyle duruyor. Kurallar var, hepsi Validate()'in içinde:

namespace Bookstore.Business;

public class LegacyOrder
{
    public int Id { get; set; }
    public string CustomerEmail { get; set; } = "";
    public string Status { get; set; } = "New";
    public List<LegacyOrderItem> Items { get; set; } = [];

    public bool Validate() =>
        Items.Count > 0
        && Items.All(i => i.Quantity is > 0 and <= 10)
        && Items.Select(i => i.Currency).Distinct().Count() == 1;
}

public class LegacyOrderItem
{
    public string Title { get; set; } = "";
    public decimal UnitPrice { get; set; }
    public string Currency { get; set; } = "";
    public int Quantity { get; set; }
}

Bir admin ekranı, bir CSV (Comma-Separated Values) import'u ve bir gece job'ı bu class'a dokunuyor. Aşağıdaki kod derlenir mi, çalışır mı? Çalışırsa ekrana hangi toplam yazılır ve Validate() ne döner? En önemli soru: bu satırlardan sonra SaveChanges çağrılsa, veritabanına giden siparişi ne durdurur?

using System.Globalization;
using Bookstore.Business;

LegacyOrder order = new() { CustomerEmail = "ada@example.com" };
order.Items.Add(new LegacyOrderItem { Title = "Dune", UnitPrice = 12.50m, Currency = "EUR", Quantity = 2 });
order.Items.Add(new LegacyOrderItem { Title = "Emma", UnitPrice = 310m, Currency = "TRY", Quantity = 1 });
order.Status = "Shiped";
order.Items[0].Quantity = -3;

Console.WriteLine(string.Create(CultureInfo.InvariantCulture,
    $"{order.Items.Count} items, status '{order.Status}', total {order.Items.Sum(i => i.UnitPrice * i.Quantity)}"));
Console.WriteLine($"Validate(): {order.Validate()}");

Invariant Nedir?

Invariant, bir object'in hayatı boyunca, her public operasyondan önce ve sonra doğru olması gereken kuraldır. Kavram, Bertrand Meyer'in Design by Contract yaklaşımındaki class invariant'ından geliyor (Object-Oriented Software Construction, 1988): constructor invariant'ı kurar, her public method onu korur. Eric Evans aynı fikri aggregate seviyesine taşır: bir değişiklik bittiğinde aggregate'in kuralları tutarlı olmalı.

Bookstore'un sipariş kuralları, serinin glossary'sinden:

  • Bir siparişte en az bir satır (OrderItem) vardır.
  • Aynı kitaptan bir siparişte en fazla 10 adet olur (MaxQuantityPerBook = 10).
  • Bir siparişin tüm satırları aynı para birimindedir; EUR ile TRY (Türk lirası) karışmaz.
  • İptal edilmiş sipariş değişmez; kargoya verilmiş sipariş iptal edilemez.
  • Bir siparişe kupon yalnızca bir kez uygulanır.

Always-valid domain model, bu kuralların "kaydetmeden önce kontrol edilen" değil, "ihlal edilmesi imkânsız" kurallar olması demek. Vladimir Khorikov bu yaklaşımı "Always-Valid Domain Model" yazısında, domain class'larının kendilerini geçersiz hale gelmekten her zaman koruması gerektiği şeklinde tarif eder. .NET'in kendisi bu modeli her gün kullanıyor:

try { DateOnly date = new(2026, 2, 30); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine(ex.Message); }

CustomerContact contact = new() { Email = "  grace@example.com " };
Console.WriteLine($"[{contact.Email}]");

try { _ = new CustomerContact { Email = "grace" }; }
catch (ArgumentException ex) { Console.WriteLine(ex.Message); }

public sealed class CustomerContact
{
    public required string Email
    {
        get;
        init => field = value.Contains('@')
            ? value.Trim()
            : throw new ArgumentException($"'{value}' is not an email address.", nameof(Email));
    }
}
Year, Month, and Day parameters describe an un-representable DateTime.
[grace@example.com]
'grace' is not an email address. (Parameter 'Email')

İlk satır DateOnly'den: 30 Şubat diye bir DateOnly yaratamazsın, sonra IsValid() diye sormaya da gerek kalmaz. Kodun geri kalanına aşağıda döneceğiz.

Private Constructor ve Static Factory

Açılıştaki koda dönelim. Çıktı şu:

2 items, status 'Shiped', total 272.50
Validate(): False

Kod derlendi ve çalıştı. Durum adı yanlış yazılmış ("Shiped"), miktar eksi üç, EUR ile TRY toplanmış ve 272.50 gibi anlamsız bir toplam çıkmış. Validate() False döndü, ama kimse onu çağırmak zorunda değil. Gece job'ını yazan arkadaş çağırmayı unutursa kaydı hiçbir şey durdurmaz. Kurallar bir method'da yaşıyor, state ise herkesin elinde.

Always-valid Order'da geçersiz state'e giden her kapı kapalı. Constructor private, sipariş yalnızca Order.Place ile doğar, satırlar yalnızca Order'ın içinden eklenir ve state'i değiştiren her method kendi kuralını kontrol eder:

using Bookstore.SharedKernel;

namespace Bookstore.Ordering;

public sealed class DomainException(string code, string message) : Exception(message)
{
    public string Code { get; } = code;
}

public enum OrderStatus { Placed, PaymentPending, Paid, Shipped, Delivered, Cancelled }

public enum CancellationReason { CustomerRequest, PaymentFailed, OutOfStock }

public sealed record Address(string Street, string City, string PostalCode, string Country);

public sealed class OrderItem
{
    internal OrderItem(BookId bookId, string title, Money unitPrice, int quantity)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(title);
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);

        BookId = bookId;
        Title = title;
        UnitPrice = unitPrice;
        Quantity = quantity;
    }

    public BookId BookId { get; }
    public string Title { get; }
    public Money UnitPrice { get; }
    public int Quantity { get; private set; }
    public Money LineTotal => UnitPrice * Quantity;

    internal void Increase(int quantity) => Quantity += quantity;
}

public sealed record CartLine(BookId BookId, string Title, Money UnitPrice, int Quantity);

public sealed class Order
{
    public const int MaxQuantityPerBook = 10;
    private readonly List<OrderItem> _items = [];

    private Order(CustomerId customerId, Address shippingAddress, DateTimeOffset placedAt)
    {
        Id = OrderId.New();
        CustomerId = customerId;
        ShippingAddress = shippingAddress;
        PlacedAt = placedAt;
        Status = OrderStatus.Placed;
    }

    public OrderId Id { get; }
    public CustomerId CustomerId { get; }
    public Address ShippingAddress { get; private set; }
    public DateTimeOffset PlacedAt { get; }
    public OrderStatus Status { get; private set; }
    public string? CouponCode { get; private set; }
    public IReadOnlyList<OrderItem> Items => _items;
    public Money Total => _items.Select(i => i.LineTotal).Aggregate((sum, next) => sum + next);

    public static Order Place(CustomerId customerId, Address shippingAddress,
        IReadOnlyCollection<CartLine> lines, TimeProvider clock)
    {
        ArgumentNullException.ThrowIfNull(shippingAddress);
        if (lines.Count == 0)
            throw new DomainException("order.empty", "An order needs at least one item.");

        Order order = new(customerId, shippingAddress, clock.GetUtcNow());
        foreach (CartLine line in lines)
            order.AddItem(line);
        return order;
    }

    private void AddItem(CartLine line)
    {
        if (_items.Count > 0 && _items[0].UnitPrice.Currency != line.UnitPrice.Currency)
            throw new DomainException("order.currency-mismatch",
                $"Order is in {_items[0].UnitPrice.Currency}, '{line.Title}' is priced in {line.UnitPrice.Currency}.");

        OrderItem? existing = _items.Find(i => i.BookId == line.BookId);
        int newQuantity = (existing?.Quantity ?? 0) + line.Quantity;
        if (newQuantity > MaxQuantityPerBook)
            throw new DomainException("order.max-quantity",
                $"At most {MaxQuantityPerBook} copies of '{line.Title}' per order, requested {newQuantity}.");

        if (existing is null)
            _items.Add(new OrderItem(line.BookId, line.Title, line.UnitPrice, line.Quantity));
        else
            existing.Increase(line.Quantity);
    }

    public void ApplyCoupon(string couponCode)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(couponCode);
        EnsureNotCancelled();
        if (CouponCode is not null)
            throw new DomainException("order.coupon-already-applied", $"Coupon {CouponCode} is already applied.");
        CouponCode = couponCode;
    }

    public void Cancel(CancellationReason reason)
    {
        EnsureNotCancelled();
        if (Status is OrderStatus.Shipped or OrderStatus.Delivered)
            throw new DomainException("order.already-shipped", $"Order {Id.Value} is {Status} and cannot be cancelled.");
        Status = OrderStatus.Cancelled;
    }

    private void EnsureNotCancelled()
    {
        if (Status == OrderStatus.Cancelled)
            throw new DomainException("order.cancelled", $"Order {Id.Value} is cancelled.");
    }
}

Birkaç tasarım kararı. OrderItem'ın constructor'ı internal; dışarıdan satır yaratılıp listeye eklenemez, liste de zaten IReadOnlyList olarak dışarı açılıyor. Place sepetten gelen satırları (CartLine) alıyor; aynı kitap iki satırda gelirse birleştiriyor ve 10 adet kuralını birleşik miktar üzerinden kontrol ediyor. Zaman, DateTime.UtcNow yerine .NET 8'den beri BCL'de (Base Class Library) olan TimeProvider'dan geliyor; test'te sabit bir saat vermek için. Total'daki Aggregate boş bir listede exception fırlatırdı; "en az bir satır" invariant'ı sayesinde bu satır hiç patlamaz. Invariant'lar diğer kodu da sadeleştirir.

Guard Clause'lar ve Fail Fast

using Bookstore.Ordering;
using Bookstore.SharedKernel;

TimeProvider clock = TimeProvider.System;
Address home = new("1 Baker St", "London", "NW1 6XE", "GB");
BookId dune = BookId.New();
BookId emma = BookId.New();

Order order = Order.Place(CustomerId.New(), home,
    [new(dune, "Dune", new Money(12.50m, "EUR"), 2), new(dune, "Dune", new Money(12.50m, "EUR"), 1)], clock);
Console.WriteLine($"{order.Status} {order.Items.Count} item(s), Dune x{order.Items[0].Quantity}, total {order.Total}");

Try(() => Order.Place(CustomerId.New(), home, [], clock));
Try(() => Order.Place(CustomerId.New(), home,
    [new(dune, "Dune", new Money(12.50m, "EUR"), 6), new(dune, "Dune", new Money(12.50m, "EUR"), 5)], clock));
Try(() => Order.Place(CustomerId.New(), home,
    [new(dune, "Dune", new Money(12.50m, "EUR"), 1), new(emma, "Emma", new Money(310m, "TRY"), 1)], clock));
Try(() => Order.Place(CustomerId.New(), home, [new(dune, "Dune", new Money(12.50m, "EUR"), 0)], clock));

order.ApplyCoupon("BACK2SCHOOL");
Try(() => order.ApplyCoupon("NEWYEAR"));
order.Cancel(CancellationReason.CustomerRequest);
Try(() => order.Cancel(CancellationReason.CustomerRequest));

static void Try(Action action)
{
    try { action(); Console.WriteLine("OK"); }
    catch (DomainException ex) { Console.WriteLine($"{ex.Code}: {ex.Message}"); }
    catch (ArgumentException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
}
Placed 1 item(s), Dune x3, total 37.50 EUR
order.empty: An order needs at least one item.
order.max-quantity: At most 10 copies of 'Dune' per order, requested 11.
order.currency-mismatch: Order is in EUR, 'Emma' is priced in TRY.
ArgumentOutOfRangeException: quantity ('0') must be a non-negative and non-zero value. (Parameter 'quantity')
Actual value was 0.
order.coupon-already-applied: Coupon BACK2SCHOOL is already applied.
order.cancelled: Order 01a1b807-ebac-7523-bf58-faf1f7ab3a09 is cancelled.

İlk sipariş iki ayrı satırda gelen Dune'ları tek satırda üç adet olarak birleştirdi. Sonraki dört deneme dört ayrı kurala takıldı ve hiçbiri yarım kalmış bir Order bırakmadı; factory exception fırlattığında ortada referansı tutulan bir sipariş yok. Kupon ikinci kez uygulanamadı, iptal edilmiş sipariş ikinci kez iptal edilemedi.

Kontrollerin iki tür olduğuna dikkat et. ArgumentException.ThrowIfNullOrWhiteSpace ve ArgumentOutOfRangeException.ThrowIfNegativeOrZero .NET 8 ile gelen BCL throw helper'ları; method'u yanlış argümanla çağıran bir programlama hatasını yakalıyor. DomainException ise bir iş kuralının ihlali ve domain diliyle konuşan bir Code taşıyor (order.max-quantity). Guard clause'ları Clean Code serisinin "Clean Code'da Fonksiyonlar: Küçük Method, Tek Soyutlama Seviyesi ve Guard Clause" yazısında, fail fast prensibini SOLID (Robert C. Martin'in beş tasarım prensibinin baş harfleri) serisinin "Daha Az Bilinen Prensipler" yazısında konuşmuştuk; burada ikisi birlikte invariant'ın koruyucusu.

required, init ve C# 14'ün field Keyword'ü

C# 11'in required'ı ilk bakışta always-valid için yapılmış gibi görünür. Bir property'yi set etmeyi unutursan derleyici durdurur:

CustomerContact contact = new();
Console.WriteLine(contact.Email);

public sealed class CustomerContact
{
    public required string Email { get; init; }
}
Program.cs(1,27): error CS9035: Required member 'CustomerContact.Email' must be set in the object initializer or attribute constructor.

Ama required yalnızca "bir değer verildi" garantisi verir, "doğru bir değer verildi" garantisi vermez. Email = "grace" derleyiciyi memnun eder. Doğrulamayı init accessor'ın içine koymanın en kısa yolu C# 14'ün field keyword'ü: yukarıdaki CustomerContact e-postayı hem object initializer'da hem de with expression'ında doğruluyor ve çıktıda gördüğün gibi "grace"i reddediyor. Yine de çok alanlı kurallar (ör. "fatura adresi verilmediyse teslimat adresi zorunlu") tek bir property'nin accessor'ına sığmaz. Object initializer'ın property'leri sırayla set ettiğini, aradaki anlarda object'in yarım olduğunu unutma. Kuralları birden fazla alana yayılan entity'ler için constructor ya da factory hâlâ doğru yer; required ve init daha çok DTO (Data Transfer Object) ve küçük value object'ler için.

Hata Modeli: Domain Exception mı, Result mı?

Bir kural ihlal edildiğinde domain bunu nasıl söylemeli? İki seçenek var.

Exception. Domain fail fast davranır, invariant asla ihlal edilmez, çağıran tarafın her satırda sonuç kontrol etmesi gerekmez. Bedeli, başarısızlık ihtimalinin method imzasında görünmemesi; çağıran, hangi kuralların patlayabileceğini kodu okuyarak öğrenir.

Result ya da Try pattern. Başarısızlık dönüş tipinde görünür. Clean, Onion ve Hexagonal Architecture serisinin "Application Layer ve Use Case'ler: MediatR Olmadan Handler, Validation ve Transaction Sınırı" yazısında bu yolu seçmiştik: domain'deki TryReserve bool döndürüyor, handler bunu bir Error'a çeviriyor, Result yalnızca Application'da yaşıyordu. Bedeli, her çağrıda sonucu kontrol etme zorunluluğu ve kontrol unutulduğunda sessizce devam eden kod.

Bu seride ikisini kuralın doğasına göre ayırıyoruz ve Clean serisindeki kararla çelişmiyoruz. Çağıranın önceden bilebileceği kurallar (iptal edilmiş siparişe kupon uygulanmaz, kargodaki sipariş iptal edilmez) DomainException fırlatır; UI (user interface) zaten bu butonu göstermemeli, buraya ulaşmak bir bug ya da bir race condition'dır. Önceden bilinemeyen ve olağan sonuçlar (rafta kitap kalmaması) Try pattern ile bool döner; bunu 09'daki StockItem.TryReserve'de göreceğiz. Domain hiçbir durumda Result tipini tanımaz. Application katmanı DomainException'ı tek bir yerde yakalayıp Code'una göre bir Error'a, oradan HTTP'de (Hypertext Transfer Protocol) 409 ya da 422'ye çevirir.

Input Validation ile Invariant'ın Farkı

"Miktar 1 ile 10 arasında olmalı" kuralını API (Application Programming Interface) request'inde de kontrol ediyorsak, domain'deki kontrol tekrar değil mi? Değil, çünkü ikisi farklı sorulara cevap veriyor:

SoruInput validationInvariant
Nerede?Sistemin sınırında: endpoint, request DTO'suDomain model'in içinde: constructor, factory, method
Neyi kontrol eder?İsteğin şekli: alan dolu mu, format doğru muİşin kuralı: bu değişiklik bu durumda yapılabilir mi
Hata nasıl döner?Tüm hatalar birden, 400 Bad Requestİlk ihlalde durur, 409 ya da 422
ÖrnekE-posta formatı, quantity alanı boşAynı kitaptan 11 adet, iptal edilmiş siparişe kupon

Input validation kullanıcıya iyi bir form deneyimi verir: beş hatayı tek seferde gösterir. Invariant ise son savunma hattıdır; sipariş HTTP'den değil de CSV import'undan ya da bir message queue'dan gelse bile çalışır. Aynı kuralın iki yerde görünmesi normaldir; kuralın kaynağı domain'dir, sınırdaki kontrol kullanıcı deneyimi içindir.

Sık Düşülen Tuzaklar

Public setter ve unutulan Validate(). Açılıştaki kodun sorunu kuralların yokluğu değil, kuralların isteğe bağlı olması. Kural ancak state'e giden tüm yollar kapalıysa invariant olur.

Parametresiz constructor'ı serializer ya da ORM (Object-Relational Mapper) için public yapmak. Entity Framework Core'un (EF Core) ihtiyaç duyduğu constructor private olabilir; 10. yazıda bunu kullanacağız. Public bir Order() constructor'ı, factory'deki tüm kontrolleri atlamanın en kısa yolu.

Veritabanına sormayı gerektiren kuralları invariant sanmak. "Aynı e-postayla ikinci müşteri olamaz" kuralı tek bir Customer'ın değil, tüm müşterilerin kümesinin kuralıdır; bir aggregate onu kendi içinde koruyamaz. Bunun yeri bir unique index ve Application katmanındaki bir kontrol. Hangi kuralın hangi sınırda korunduğu bir sonraki yazının konusu.

Taslak state'i için invariant'ı gevşetmek. "Müşteri sepeti doldururken sipariş geçici olarak boş olabilmeli" diye Order'ın "en az bir satır" kuralını kaldırmak cazip gelir. Doğru cevap çoğu zaman ayrı bir kavram: glossary'deki Cart boş olabilir, Order olamaz. Yarı-geçerli bir state'e ihtiyaç duyuyorsan muhtemelen henüz isim vermediğin bir kavram var.

Exception'ı akış kontrolü için kullanmak. Kullanıcının her gün karşılaştığı sonuçlar için (stok yok, kupon süresi dolmuş) exception fırlatıp yakalamak, hem performans hem okunabilirlik açısından pahalı. Bu sonuçlar için yukarıdaki Try ayrımını kullan.

Ne Zaman Kullanma

Always-valid model, kuralı olan yerde değerlidir. Bir admin panelindeki "kategori adı ve açıklaması" formu için factory, private constructor ve DomainException yazmak, kuralı olmayan bir veriye tören yapmak olur; orada basit bir DTO ve input validation yeterli. Serinin 03. yazısındaki ayrımla, generic ve supporting subdomain'lerdeki CRUD (Create, Read, Update, Delete) kodu bu yatırıma ihtiyaç duymaz. Bookstore'da always-valid model Ordering context'inde ve özellikle Order'da.

Küçük Bir Deneme

★☆☆ Order'a ChangeShippingAddress(Address address) ekle: sipariş kargoya verildiyse adres değişemez. Hangi exception kodunu seçtin ve neden?

★★☆ Order.Place'i, DomainException yerine tüm ihlalleri bir listede toplayan bir sürümle yeniden yaz. Hangi durumda bu daha iyi bir kullanıcı deneyimi sağlar, hangi durumda invariant'ı zayıflatır?

★★★ Bir OrderFactory yaz: aynı müşterinin son 24 saatte 50'den fazla siparişi varsa (dolandırıcılık şüphesi) yeni sipariş yaratmasın. Bu kural bir invariant mı, bir policy mi? Bilgiyi nereden alacak ve Order'ın kendisi bu bilgiye ihtiyaç duymalı mı?

Şu ana kadar kuralları tek bir Order'ın içinde koruduk. Peki stok? "Rafta olmayan kitap satılmaz" kuralı Order'ın mı, StockItem'ın mı, ikisinin birlikte mi kuralı? Bir sonraki yazıda aggregate tasarımına geçiyoruz: consistency boundary, küçük aggregate'ler ve başka aggregate'lere yalnızca id ile referans vermek.