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

Aggregate Tasarımı: Consistency Boundary, Küçük Aggregate'ler ve Id ile Referans

Mert Özen 7 Kas 2026 10 dk 2 görüntülenme
Aggregate Tasarımı: Consistency Boundary, Küçük Aggregate'ler ve Id ile Referans

Aggregate, gerçek invariant'ları tek bir transaction'da tutarlı tutan bir consistency boundary'dir; dışarıdan yalnızca root'a, diğer aggregate'lere de yalnızca id ile referans verilir. Vernon'un dört kuralını Bookstore'un Order ve StockItem aggregate'lerine uyguluyor, navigation property'nin katalog fiyatını sessizce değiştirdiği gerçek bir EF Core çıktısını, stok için eventual consistency'yi, 'kimin işi?' sorusunu ve Aggregate Design Canvas'ı işliyoruz.

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

Önce Tahmin Et

Okul açılışı kampanyası başladı ve bir developer'a "Ada'nın siparişine yüzde 20 indirim uygula" görevi geldi. Eski modelde sipariş satırı kitaba bir navigation property ile bağlı, indirimi uygulamak da tek bir döngü:

using System.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

File.Delete("bookstore09.db");
await using (BookstoreDbContext db = new())
{
    await db.Database.EnsureCreatedAsync();
    Book dune = new() { Title = "Dune", ListPrice = 12.50m };
    db.Orders.Add(new Order { CustomerEmail = "ada@example.com", Items = [new OrderItem { Book = dune, Quantity = 2 }] });
    await db.SaveChangesAsync();
}

await using (BookstoreDbContext db = new(log: true))
{
    Order order = await db.Orders.Include(o => o.Items).ThenInclude(i => i.Book).FirstAsync();

    // Okul açılışı kampanyası: bu siparişe %20 indirim
    foreach (OrderItem item in order.Items)
        item.Book.ListPrice *= 0.8m;

    await db.SaveChangesAsync();
}

await using (BookstoreDbContext db = new())
{
    Book dune = await db.Books.SingleAsync();
    Console.WriteLine(string.Create(CultureInfo.InvariantCulture, $"Catalog price of {dune.Title}: {dune.ListPrice}"));
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal ListPrice { get; set; }
}

public class Order
{
    public int Id { get; set; }
    public string CustomerEmail { get; set; } = "";
    public List<OrderItem> Items { get; set; } = [];
}

public class OrderItem
{
    public int Id { get; set; }
    public int Quantity { get; set; }
    public int BookId { get; set; }
    public Book Book { get; set; } = null!;
}

public sealed class BookstoreDbContext(bool log = false) : DbContext
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
    {
        options.UseSqlite("Data Source=bookstore09.db");
        if (log) options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
    }
}

İkinci using bloğundaki SaveChangesAsync veritabanına hangi tabloya, hangi UPDATE'i gönderir? Son satırda katalogdaki Dune'un fiyatı kaç yazar? Ve bu sırada sitede Dune'a bakan Grace hangi fiyatı görür?

Aggregate Nedir?

Çıktı şu:

      UPDATE "Books" SET "ListPrice" = @p0
      WHERE "Id" = @p1
      RETURNING 1;
Catalog price of Dune: 10.0

Bir siparişe uygulanması gereken indirim, katalogdaki kitabın liste fiyatını değiştirdi. Grace ve sonraki tüm müşteriler Dune'u 10.0'a görüyor. Kod derlendi, testler muhtemelen geçti, çünkü Ada'nın siparişi gerçekten indirimli. Sorun şu: Order'dan başlayan bir değişiklik, Order'ın kurallarının hiç bilmediği başka bir şeyi, Catalog bounded context'indeki Book'u değiştirdi.

Aggregate tam olarak bunu engellemek için var. Eric Evans'ın DDD (Domain-Driven Design) Reference'ındaki tanımı kendi cümlelerimle: birbirine sıkı bağlı entity'leri ve value object'leri bir küme olarak ele al, kümenin bir sınırı olsun, kümeden bir entity'yi root seç ve dışarıdaki her şey yalnızca root'a referans tutsun. Değişiklikler root'tan geçer; root, sınırın içindeki her şeyin kurallarından sorumludur. Vaughn Vernon'un 2011 tarihli "Effective Aggregate Design" makale dizisinin vurguladığı nokta ise şu: aggregate bir object graph'ı gezinme kolaylığı için değil, bir transactional consistency boundary olarak tasarlanır. Bir transaction commit olduğunda sınırın içindeki her şey tutarlıdır; sınırın dışındakinin tutarlılığı o aggregate'in derdi değildir.

.NET'in koleksiyonları bu fikrin küçük bir örneği. List<T>, içindeki array'i ve _size'ı dışarıya kapalı tutar, her değişiklikte _version adlı bir sayacı artırır ve enumerator her adımda bu sayacı kontrol eder:

List<int> quantities = [1, 2, 3];
try
{
    foreach (int quantity in quantities)
        if (quantity == 2) quantities.Add(4);
}
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
Collection was modified; enumeration operation may not execute.

İçerideki state'e yalnızca Add, Remove gibi "root" method'larından ulaşılıyor ve tutarsız bir anı yakalayan kod reddediliyor. Aggregate'ler de aynı ikiliyi kullanır: değişiklik yalnızca root'tan, eşzamanlı değişikliğe karşı da bir version kontrolü. Version kısmını bir sonraki yazıda Entity Framework Core (EF Core) ile kuracağız.

Vernon'un Dört Kuralı

Vernon makalelerde aggregate tasarımını dört kurala bağlıyor. Bookstore üzerinden:

Gerçek invariant'ları consistency boundary'nin içine al. Önceki yazıdaki kurallar (en az bir satır, kitap başına 10 adet, tek para birimi, tek kupon) yalnızca Order ve OrderItem'a bakıyor. Hepsi aynı transaction'da tutarlı olmalı, öyleyse aynı aggregate'teler. Vernon'un uyarısı ters yönde: geliştiricilerin uydurduğu "false invariant"lar. "Bir müşterinin bütün siparişleri birlikte yüklensin" bir invariant değil, bir alışkanlık.

Küçük aggregate'ler tasarla. Vernon'un örneğindeki Product, bütün backlog item'larını, release'lerini ve sprint'lerini içeren büyük bir aggregate'ti; iki kullanıcının birbiriyle hiçbir ilgisi olmayan iki değişikliği, aynı version numarasına çarptığı için çakışıyordu. Bookstore'da bunun karşılığı, Customer'ın tüm Order'ları bir collection'da tutması olurdu: Ada'nın yeni siparişi ile müşteri hizmetlerinin eski bir siparişteki iadesi aynı aggregate'e yazmaya çalışırdı. Vernon'un aktardığı bir projede aggregate'lerin yaklaşık yüzde 70'i yalnızca root ve birkaç value object'ten oluşuyordu.

Diğer aggregate'lere yalnızca id ile referans ver. Açılıştaki hata buradan doğdu. OrderItem'ın bir Book navigation'ı olunca, Order'ı yükleyen kod Book'u da değiştirebiliyor ve EF Core'un change tracker'ı bunu sadakatle kaydediyor. Doğru modelde OrderItem kitabı BookId ile tanır, sipariş anındaki başlığı ve fiyatı da kendi value'su olarak kopyalar:

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;
}

Bu kopyalama bir duplication değil, bir iş kuralı: glossary'deki UnitPrice "siparişe yazılan, kampanya uygulanmış fiyat"; katalogdaki liste fiyatı yarın değişse bile Ada'nın faturası değişmemeli. İndirim de artık Order'ın kendi method'u olur ve yalnızca Order'ın satırlarına dokunur.

Sınırın dışında eventual consistency kullan. Bir değişiklik iki aggregate'i etkiliyorsa ikincisi ayrı bir transaction'da, bir domain event'e tepki olarak güncellenir. Bunun en önemli örneği stok.

09-order-aggregate-tr.png

Stok Nerede? StockItem ve Eventual Consistency

Clean, Onion ve Hexagonal Architecture serisinde basit tutmak için Order.TryAddItem içinden book.TryReserve'ü çağırıyorduk: sipariş ve stok aynı transaction'da değişiyordu. Tek kullanıcıyla sorun yok. Kampanya döneminde ise Mimariye Giriş serisindeki hedeflere göre zirvede dakikada 120 sipariş geliyor ve bunların önemli bir kısmı aynı birkaç çok satan kitaba yöneliyor. Her sipariş aynı stok satırını kendi transaction'ında kilitlemeye ya da version'ını artırmaya çalışırsa, birbirinden bağımsız müşteriler birbirinin işlemini düşürmeye başlar.

Seri boyunca kullandığımız model stoğu Inventory bounded context'indeki ayrı bir aggregate'e taşıyor:

using Bookstore.Ordering;

namespace Bookstore.Inventory;

public readonly record struct StockItemId(Guid Value)
{
    public static StockItemId New() => new(Guid.CreateVersion7());
}

public sealed record StockReservation(OrderId OrderId, int Quantity);

public sealed class StockItem
{
    private readonly List<StockReservation> _reservations = [];

    public StockItem(BookId bookId, int onHand)
    {
        ArgumentOutOfRangeException.ThrowIfNegative(onHand);
        Id = StockItemId.New();
        BookId = bookId;
        OnHand = onHand;
    }

    public StockItemId Id { get; }
    public BookId BookId { get; }
    public int OnHand { get; private set; }
    public int Reserved => _reservations.Sum(r => r.Quantity);
    public int Available => OnHand - Reserved;

    public bool TryReserve(OrderId orderId, int quantity)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(quantity);
        if (_reservations.Exists(r => r.OrderId == orderId))
            return true;    // aynı OrderPlaced iki kez gelirse ikinci kez ayırma
        if (quantity > Available)
            return false;

        _reservations.Add(new StockReservation(orderId, quantity));
        return true;
    }

    public void Release(OrderId orderId) => _reservations.RemoveAll(r => r.OrderId == orderId);
}
using Bookstore.Inventory;
using Bookstore.Ordering;

StockItem dune = new(BookId.New(), onHand: 3);
OrderId first = OrderId.New();
OrderId second = OrderId.New();

Console.WriteLine($"first:  {dune.TryReserve(first, 2)}  available={dune.Available}");
Console.WriteLine($"second: {dune.TryReserve(second, 2)} available={dune.Available}");
Console.WriteLine($"first again: {dune.TryReserve(first, 2)} available={dune.Available}");
dune.Release(first);
Console.WriteLine($"after release: available={dune.Available}");
first:  True  available=1
second: False available=1
first again: True available=1
after release: available=3

StockItem'ın invariant'ı kendi içinde: ayrılan miktar eldekini geçemez. Order'ı tanımıyor, yalnızca OrderId'yi rezervasyonun anahtarı olarak tutuyor. Aynı OrderPlaced event'i iki kez gelirse ikinci rezervasyon yapılmıyor; çıktıdaki "first again" satırı bunu gösteriyor. Event'lerin en az bir kez teslim edildiği bir dünyada bu idempotency, eventual consistency'nin bedelinin parçası. TryReserve da önceki yazıda verdiğimiz karara uyuyor: rafta kitap kalmaması olağan ve önceden bilinemeyen bir sonuç, exception değil bool.

Akış artık şöyle: Order.Place kendi transaction'ında commit olur ve OrderPlaced yayınlar; Inventory bu event'e tepki olarak TryReserve'ü kendi transaction'ında çalıştırır; sonuç StockReserved ya da OutOfStock olarak geri döner, ikincisinde Ordering siparişi CancellationReason.OutOfStock ile iptal eder. EventStorming yazısında duvarda gördüğümüz policy'nin ta kendisi. Event'lerin SaveChanges sırasında nasıl toplanıp dağıtılacağı 12. yazının, iki process arasında güvenilir taşınması (outbox) ise ilerideki Messaging ve Event-Driven Architecture serisinin konusu.

"Kimin İşi?" Sorusu

Transactional mı, eventual mı kararını vermek her zaman bu kadar kolay değil. Vernon, Eric Evans ile yaptığı bir konuşmadan şu ölçütü aktarıyor: use case'i çalıştıran kullanıcının işi veriyi tutarlı kılmaksa transactional consistency'yi dene (diğer aggregate kurallarına uyarak); başka bir kullanıcının ya da sistemin işiyse eventual consistency'ye izin ver.

Bookstore'da uygularsak: sipariş satırlarının toplamı ile kuponun tutarlılığı Ada'nın işi, siparişi verirken tutarlı olmalı, aynı aggregate. Stok ayırmak Ada'nın işi değil, deponun ve sistemin işi; birkaç saniyelik gecikme ve nadiren yaşanacak bir OutOfStock iptali, EventStorming oturumunda iş tarafının kabul ettiği bir bedel. Ödemenin alınması da ödeme sağlayıcısının ve Payments context'inin işi. Bu ölçüt kesin cevap vermez, ama tartışmayı "teknik tercih"ten "iş kuralı"na taşır.

Aggregate Design Canvas ile Karar

DDD Crew'un Aggregate Design Canvas'ı bu kararları tek sayfada toplamak için iyi bir araç. Order için doldurduğumuz versiyon:

BölümOrder
DescriptionBir müşterinin tek bir alışverişi; satırlar, teslimat adresi, kupon ve durum
State transitionsPlaced → PaymentPending/Paid → Shipped → Delivered; Shipped'den önce Cancelled
Enforced invariantsEn az bir satır, kitap başına en fazla 10 adet, tek para birimi, tek kupon, iptal edilmiş sipariş değişmez
Corrective policiesOutOfStock gelirse siparişi ya da satırı iptal et, müşteriye haber ver
Handled commandsPlaceOrder, ApplyCoupon, CancelOrder, CancelItem, RequestReturn
Created eventsOrderPlaced, OrderCancelled, ReturnRequested
ThroughputBir sipariş ömrü boyunca birkaç command alır; eşzamanlı yazan en fazla müşteri, destek ekibi ve gelen event'ler
SizeTipik olarak 1–5 satır; satır sayısının üst sınırı pratikte sepetin büyüklüğü

Aynı canvas'ı StockItem için doldurunca fark hemen görünüyor. Throughput satırında "kampanyada çok satan bir kitap için dakikada onlarca rezervasyon", size satırında "açık rezervasyon sayısı kadar büyür" yazıyor. Bu iki satır, kargoya verilen siparişlerin rezervasyonlarının Release ile temizlenmesi gerektiğini ve StockItem'ın, Order'dan çok daha sıcak bir aggregate olduğunu söylüyor. Bir sonraki yazıdaki optimistic concurrency kararını da bu satırlara göre vereceğiz.

Sık Düşülen Tuzaklar

Navigation property ile başka bir aggregate'i değiştirmek. Açılıştaki örnek. Başka bir aggregate'e navigation koymak, referans vermenin en kolay ama en tehlikeli yolu; okuma ihtiyacı için bir query ya da read model, yazma ihtiyacı için id ve event kullan.

Büyük aggregate. "Müşteri siparişlerine sahiptir" cümlesi composition gibi duyulur, ama Vernon'un Product'ı gibi her işlemde yüzlerce satır yükler ve ilgisiz değişiklikleri çakıştırır. UML (Unified Modeling Language) serisinin "Class Diagram 2: Association, Aggregation, Composition, Generalization ve Dependency — C# Karşılıklarıyla" yazısındaki composition ile DDD'deki aggregate aynı şey değil; birincisi yaşam döngüsü ilişkisi, ikincisi consistency boundary.

Bir transaction'da iki aggregate'i "nasılsa aynı veritabanı" diye değiştirmek. Bugün ikisi de aynı veritabanında, ama context'ler ayrı ekiplere geçtiğinde bu transaction bir distributed transaction'a ya da gizli bir coupling'e dönüşür. Vernon'un kural esnetme gerekçeleri (UI (user interface) kolaylığı, teknik mekanizma eksikliği, global transaction, query performansı) gerçek, ama her biri bilinçli verilmiş bir karar olarak yazılmalı.

Lazy loading ile sınırı delmek. virtual bir Book navigation'ı ve lazy loading proxy'leri, açılıştaki hatayı bir de görünmez yapar: kod Include yazmadan diğer aggregate'i yükler ve değiştirir. Lazy loading'in kendisini EF Core serisinin "EF Core'da İlişkili Veri Yükleme: Include, Explicit ve Lazy Loading, Split Query" yazısında anlattık.

Her tabloyu bir aggregate saymak. OrderItems tablosu var diye OrderItem bir aggregate değil; kendi repository'si, kendi DbSet'i olmamalı. Aggregate tablolardan değil, kurallardan çıkar.

Küçük Bir Deneme

★☆☆ Açılıştaki kampanya görevini doğru modelde yap: Order'a ApplyDiscount(decimal percent) ekle, yalnızca satırların UnitPrice'ını değiştirsin. İptal edilmiş bir siparişte ne olmalı?

★★☆ Aggregate Design Canvas'ı StockItem için doldur. "Corrective policies" bölümüne, ödeme başarısız olduğunda rezervasyonun ne zaman ve hangi event ile bırakılacağını yaz.

★★★ İş tarafı "rafta olmayan kitap asla satılmasın, sıfır tolerans" diyor. Stoğu Order ile aynı transaction'a almadan bu kuralı nasıl yaklaşık olarak sağlarsın? Read model'den stok kontrolü, sepette rezervasyon ve OutOfStock sonrası telafi seçeneklerini Vernon'un "kimin işi" sorusuyla karşılaştır.

Aggregate'in sınırını çizdik, ama sınır yalnızca kodda. Bir sonraki yazıda onu veritabanına taşıyoruz: EF Core 10 ile backing field'lar, private constructor ile materialization, aggregate başına repository ve concurrency token'ın neden root'ta durması gerektiği.