Aggregate'leri EF Core 10 ile Persist Etmek: Backing Field, Owned/Complex Type ve Concurrency
Bir aggregate'i EF Core 10 ile persist ederken root ve child'lar tek bütün olarak yüklenip tek SaveChanges ile yazılır, concurrency token root'ta durur ve child'daki her değişiklik root'un version'ını artırmalıdır; aksi halde kargoya verilmiş bir siparişte satır iptal edilebilir. Backing field, private constructor, AutoInclude, aggregate başına repository ve JSON complex collection alternatifini gerçek SQL ve çıktılarla gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Order aggregate'ini Entity Framework Core (EF Core) 10 ile SQLite'a yazıyoruz. Root'ta Version adında bir concurrency token var ve MarkShipped onu artırıyor. CancelItem ise yalnızca ilgili OrderItem'ı iptal edildi olarak işaretliyor. Depoda Linus siparişi kargoya veriyor; aynı saniyede müşteri hizmetlerinde Grace, Ada'nın isteğiyle Dune satırını iptal ediyor. İkisi de siparişi kargodan önce yüklemiş:
await using (OrderingDbContext warehouse = new(Db))
await using (OrderingDbContext support = new(Db, log: true))
{
Order shipping = (await new EfOrderRepository(warehouse).FindAsync(orderId))!;
Order cancelling = (await new EfOrderRepository(support).FindAsync(orderId))!;
shipping.MarkShipped();
await warehouse.SaveChangesAsync();
cancelling.CancelItem(dune);
try
{
await support.SaveChangesAsync();
Console.WriteLine("support: saved");
}
catch (DbUpdateConcurrencyException ex) { Console.WriteLine($"support: {ex.Message}"); }
}
Kural açık: kargoya verilmiş bir siparişte satır iptal edilemez. Grace'in SaveChangesAsync'i DbUpdateConcurrencyException fırlatır mı, yoksa sipariş "kargoya verildi ama Dune iptal edildi, parası iade edilsin" durumuna mı düşer?
Domain Model'i EF Core İçin Bozmamak
Önce modeli kuralım. Önceki iki yazıdaki Order'a persistence için yalnızca üç şey ekledik: EF Core'un materialization için kullanacağı private parametresiz constructor'lar, property'lerde private set ve aggregate'in version'ı. Domain hâlâ EF Core'a referans vermiyor:
namespace Bookstore.Ordering;
public readonly record struct OrderId(Guid Value)
{
public static OrderId New() => new(Guid.CreateVersion7());
}
public readonly record struct OrderItemId(Guid Value)
{
public static OrderItemId New() => new(Guid.CreateVersion7());
}
public readonly record struct BookId(Guid Value)
{
public static BookId New() => new(Guid.CreateVersion7());
}
public readonly record struct CustomerId(Guid Value)
{
public static CustomerId New() => new(Guid.CreateVersion7());
}
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 sealed record Address(string Street, string City, string PostalCode, string Country);
public sealed record CartLine(BookId BookId, string Title, Money UnitPrice, int Quantity);
public sealed class OrderItem
{
private OrderItem() { }
internal OrderItem(BookId bookId, string title, Money unitPrice, int quantity)
{
Id = OrderItemId.New();
BookId = bookId;
Title = title;
UnitPrice = unitPrice;
Quantity = quantity;
}
public OrderItemId Id { get; private set; }
public BookId BookId { get; private set; }
public string Title { get; private set; } = null!;
public Money UnitPrice { get; private set; }
public int Quantity { get; private set; }
public bool IsCancelled { get; private set; }
internal void Cancel() => IsCancelled = true;
}
public sealed class Order
{
public const int MaxQuantityPerBook = 10;
private readonly List<OrderItem> _items = [];
private Order() { }
private Order(CustomerId customerId, Address shippingAddress, DateTimeOffset placedAt)
{
Id = OrderId.New();
CustomerId = customerId;
ShippingAddress = shippingAddress;
PlacedAt = placedAt;
Status = OrderStatus.Placed;
}
public OrderId Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public Address ShippingAddress { get; private set; } = null!;
public DateTimeOffset PlacedAt { get; private set; }
public OrderStatus Status { get; private set; }
public int Version { get; private set; }
public IReadOnlyList<OrderItem> Items => _items;
public static Order Place(CustomerId customerId, Address shippingAddress,
IReadOnlyCollection<CartLine> lines, TimeProvider clock)
{
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)
{
if (line.Quantity is < 1 or > MaxQuantityPerBook)
throw new DomainException("order.max-quantity", $"1..{MaxQuantityPerBook} copies of '{line.Title}' per order.");
order._items.Add(new OrderItem(line.BookId, line.Title, line.UnitPrice, line.Quantity));
}
return order;
}
public void MarkShipped()
{
if (Status is not (OrderStatus.Placed or OrderStatus.Paid))
throw new DomainException("order.not-shippable", $"Order is {Status}.");
Status = OrderStatus.Shipped;
Version++;
}
public void CancelItem(BookId bookId)
{
if (Status is OrderStatus.Shipped or OrderStatus.Delivered or OrderStatus.Cancelled)
throw new DomainException("order.item-not-cancellable", $"Order is {Status}; items can no longer be cancelled.");
OrderItem item = _items.Find(i => i.BookId == bookId && !i.IsCancelled)
?? throw new DomainException("order.item-not-found", "No active item for this book.");
item.Cancel();
Version++;
}
}
Satırlar private readonly List<OrderItem> _items backing field'ında duruyor, dışarıya IReadOnlyList<OrderItem> olarak açılıyor. EF Core materialization sırasında public setter'a ya da public constructor'a ihtiyaç duymuyor; private constructor'ı çağırıp değerleri field'lara yazabiliyor. Getter-only property'lerin ve constructor binding'in domain'in şeklini nasıl etkilediğini Clean, Onion ve Hexagonal Architecture serisinin "Persistence Ignorance ve Infrastructure Adapter'ları: EF Core, HttpClient ve Dış Servisler" yazısında konuşmuştuk. Money ise 07'deki gibi constructor'ıyla okunuyor; bunun bir sonucunu tuzaklar bölümünde göreceğiz.
Mapping: IEntityTypeConfiguration ve AutoInclude
using Bookstore.Ordering;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.Extensions.Logging;
namespace Bookstore.Ordering.Infrastructure;
public sealed class OrderingDbContext(string connectionString, bool log = false) : DbContext
{
internal DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite(connectionString);
if (log) options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderingDbContext).Assembly);
}
internal sealed class OrderConfiguration : IEntityTypeConfiguration<Order>
{
public void Configure(EntityTypeBuilder<Order> order)
{
order.HasKey(o => o.Id);
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
order.Property(o => o.CustomerId).HasConversion(id => id.Value, value => new CustomerId(value));
order.Property(o => o.Status).HasConversion<string>().HasMaxLength(20);
order.Property(o => o.Version).IsConcurrencyToken();
order.ComplexProperty(o => o.ShippingAddress);
order.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId").IsRequired().OnDelete(DeleteBehavior.Cascade);
order.Navigation(o => o.Items).HasField("_items").AutoInclude();
}
}
internal sealed class OrderItemConfiguration : IEntityTypeConfiguration<OrderItem>
{
public void Configure(EntityTypeBuilder<OrderItem> item)
{
item.ToTable("OrderItems");
item.HasKey(i => i.Id);
item.Property(i => i.Id).HasConversion(id => id.Value, value => new OrderItemId(value));
item.Property(i => i.BookId).HasConversion(id => id.Value, value => new BookId(value));
item.Property(i => i.Title).HasMaxLength(200);
item.ComplexProperty(i => i.UnitPrice, money =>
{
money.Property(m => m.Amount).HasColumnName("UnitPrice").HasPrecision(18, 2);
money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
});
}
}
public interface IOrderRepository
{
Task<Order?> FindAsync(OrderId id, CancellationToken ct = default);
void Add(Order order);
Task SaveChangesAsync(CancellationToken ct = default);
}
internal sealed class EfOrderRepository(OrderingDbContext db) : IOrderRepository
{
public Task<Order?> FindAsync(OrderId id, CancellationToken ct = default) =>
db.Orders.SingleOrDefaultAsync(o => o.Id == id, ct);
public void Add(Order order) => db.Orders.Add(order);
public Task SaveChangesAsync(CancellationToken ct = default) => db.SaveChangesAsync(ct);
}
Kararlar tek tek. Id'ler converter ile Guid'e iniyor; converter'ı olan bir key için EF Core değer üretmiyor, Id'leri domain kendisi Guid.CreateVersion7() ile veriyor. ShippingAddress ve UnitPrice 07'deki gibi complex type. OrderItem kendi tablosunda, ama DbContext'te DbSet<OrderItem> yok ve Orders bile internal; dışarıdan erişim repository'den. Foreign key domain'de görünmeyen bir shadow property (OrderId) ve IsRequired() ile zorunlu; bunu yazmadan ilk denemede kolon NULL kabul eden bir sütun olarak oluşmuştu. Navigation(...).AutoInclude(), Order her sorgulandığında satırların da yüklenmesini sağlıyor. Oluşan şema:
CREATE TABLE "Orders" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY,
"CustomerId" TEXT NOT NULL,
"PlacedAt" TEXT NOT NULL,
"Status" TEXT NOT NULL,
"Version" INTEGER NOT NULL,
"ShippingAddress_City" TEXT NOT NULL,
"ShippingAddress_Country" TEXT NOT NULL,
"ShippingAddress_PostalCode" TEXT NOT NULL,
"ShippingAddress_Street" TEXT NOT NULL
);
CREATE TABLE "OrderItems" (
"Id" TEXT NOT NULL CONSTRAINT "PK_OrderItems" PRIMARY KEY,
"BookId" TEXT NOT NULL,
"Title" TEXT NOT NULL,
"Quantity" INTEGER NOT NULL,
"IsCancelled" INTEGER NOT NULL,
"OrderId" TEXT NOT NULL,
"UnitPrice" TEXT NOT NULL,
"Currency" TEXT NOT NULL,
CONSTRAINT "FK_OrderItems_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id") ON DELETE CASCADE
);
Repository'nin FindAsync'i Include yazmıyor, ama üretilen SQL (Structured Query Language) satırları yine getiriyor:
SELECT "o1"."Id", "o1"."CustomerId", "o1"."PlacedAt", "o1"."Status", "o1"."Version", "o1"."ShippingAddress_City", "o1"."ShippingAddress_Country", "o1"."ShippingAddress_PostalCode", "o1"."ShippingAddress_Street", "o0"."Id", "o0"."BookId", "o0"."IsCancelled", "o0"."OrderId", "o0"."Quantity", "o0"."Title", "o0"."UnitPrice", "o0"."Currency"
FROM (
SELECT "o"."Id", "o"."CustomerId", "o"."PlacedAt", "o"."Status", "o"."Version", "o"."ShippingAddress_City", "o"."ShippingAddress_Country", "o"."ShippingAddress_PostalCode", "o"."ShippingAddress_Street"
FROM "Orders" AS "o"
WHERE "o"."Id" = @id
LIMIT 2
) AS "o1"
LEFT JOIN "OrderItems" AS "o0" ON "o1"."Id" = "o0"."OrderId"
ORDER BY "o1"."Id"
Aggregate ya bütün olarak yüklenir ya hiç; yarım yüklenmiş bir Order'da "en az bir satır" ya da "kitap başına 10 adet" kontrolü yanlış sonuç verir. AutoInclude bunu, geliştiricinin Include yazmayı hatırlamasına bırakmıyor. Raporlama gibi satırlara ihtiyaç duymayan okumalarda IgnoreAutoIncludes() ya da doğrudan projection kullan; Include'un ve split query'nin ayrıntıları EF Core serisinin "EF Core'da İlişkili Veri Yükleme: Include, Explicit ve Lazy Loading, Split Query" yazısında.
Neden OwnsMany değil? Clean serisinin "Clean Architecture Solution'ı Kurmak" yazısında satırları OwnsMany ile map'lemiştik ve o da geçerli bir yol: owned entity'nin DbSet'i olamaz, sahibiyle birlikte otomatik yüklenir, yani aggregate semantiğini kendiliğinden verir. Ama owned type arka planda gizli key'i olan bir entity ve EF Core 10 dokümanı table splitting ve JSON (JavaScript Object Notation) için artık complex type'ı öneriyor; ayrı tablodaki bir collection için ise iki seçenek kalıyor: OwnsMany ya da burada yaptığımız gibi açık bir key'i olan normal bir entity ve AutoInclude. OrderItem'ın kendi OrderItemId'si olması, iade ve kısmi iptal gibi satıra işaret eden işlemlerde işimizi kolaylaştırdığı için ikincisini seçtik.
Aggregate Başına Repository
IOrderRepository'de yalnızca üç method var: id ile bul, ekle, kaydet. IRepository<T> gibi generic bir repository yok, IQueryable dışarı sızmıyor ve OrderItem'ın repository'si yok. Repository'nin birimi tablo değil aggregate; Vernon'un "bir transaction'da bir aggregate" kuralı burada SaveChangesAsync'e karşılık geliyor. SaveChanges zaten tek bir transaction açtığı için aggregate'in root'u ve satırları ya birlikte yazılıyor ya hiç. Repository mi, doğrudan DbContext mi tartışmasını ASP.NET Core Web API (Application Programming Interface) serisinin "ASP.NET Core Web API'de Katmanlı Yapı: Service Katmanı ve Repository Tartışması" yazısında, ayrıca CQRS'in (Command Query Responsibility Segregation) okuma tarafını da ele aldığımız EF Core serisinin "EF Core ve Mimari: Repository, CQRS'in Okuma Tarafı ve Dapper'a Geçiş" yazısında yaptık. DDD (Domain-Driven Design) açısından cevap net: aggregate'i bütün halinde yükleyip kaydeden ve başka hiçbir şey yapmayan dar bir repository.
Concurrency Token Neden Root'ta?
Açılıştaki soruya dönelim. İlk denemede CancelItem'da Version++ satırı yoktu ve Grace'in kaydı şöyle bitti:
UPDATE "OrderItems" SET "IsCancelled" = @p0
WHERE "Id" = @p1
RETURNING 1;
support: saved
Shipped v1, cancelled items: 1
İki kayıt da başarılı. Sipariş kargoda, Dune satırı iptal edilmiş; invariant ihlal edildi ve iade sürecini tetikleyecek bir veri oluştu. Sebebi SQL'de görünüyor: Grace'in değişikliği yalnızca OrderItems tablosuna gitti, Orders satırına hiç dokunulmadı. Concurrency token root'ta durduğu ve root değişmediği için EF Core kontrol edecek bir şey bulmadı. SQL Server'daki rowversion da bu durumu kurtarmazdı; rowversion yalnızca kendi satırı güncellendiğinde değişir.
Aggregate tek bir consistency boundary ise, içindeki herhangi bir değişiklik root'un version'ını değiştirmeli. Yukarıdaki Order'da CancelItem'ın sonundaki Version++ bunu yapıyor ve aynı senaryo şimdi şöyle bitiyor:
UPDATE "OrderItems" SET "IsCancelled" = @p0
WHERE "Id" = @p1
RETURNING 1;
UPDATE "Orders" SET "Version" = @p0
WHERE "Id" = @p1 AND "Version" = @p2
RETURNING 1;
support: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may have been modified or deleted since entities were loaded. See https://go.microsoft.com/fwlink/?LinkId=527962 for information on understanding and handling optimistic concurrency exceptions.
Shipped v1, cancelled items: 0
OrderItems update'i de aynı transaction'daydı ve geri alındı; son satırda iptal edilmiş satır sayısı sıfır. Grace'in uygulaması exception'ı yakalayıp siparişi yeniden yükler, CancelItem bu kez order.item-not-cancellable ile reddedilir. Token'ın kendisini, rowversion'ı ve exception'ı çözme yollarını EF Core serisinin "EF Core'da Concurrency: Token, rowversion ve DbUpdateConcurrencyException" yazısında ayrıntılı işledik.
Her mutating method'a Version++ yazmak unutulabilir. Alternatif, bir SaveChangesInterceptor'da değişen child entity'lerin root'larını bulup version'larını artırmak; interceptor mekaniği EF Core serisinin "EF Core Interceptor'ları ve Event'leri: Audit Field'ları ve Yavaş Sorgu Logu" yazısında. Küçük bir aggregate'te açık Version++ daha okunaklı, test'i de kolay.
Alternatif: Satırları JSON'da Tutmak
EF Core 10'un complex collection'ları başka bir seçenek açıyor: OrderItem'ı bir value object yapıp tüm satırları Orders tablosundaki tek bir JSON kolonunda tutmak. Vernon'un "aggregate'in parçalarını mümkünse value yap" tavsiyesiyle de örtüşüyor. Aynı senaryoyu bu modelle denedim:
using Bookstore.SharedKernel;
namespace Bookstore.Ordering;
public enum OrderStatus { Placed, PaymentPending, Paid, Shipped, Delivered, Cancelled }
public sealed record OrderItem
{
public required BookId BookId { get; init; }
public required string Title { get; init; }
public required Money UnitPrice { get; init; }
public required int Quantity { get; init; }
public bool IsCancelled { get; init; }
}
public sealed class Order
{
private List<OrderItem> _items = [];
private Order() { }
public Order(IEnumerable<OrderItem> items)
{
Id = OrderId.New();
_items = [.. items];
}
public OrderId Id { get; private set; }
public OrderStatus Status { get; private set; }
public int Version { get; private set; }
public IReadOnlyList<OrderItem> Items => _items;
public void MarkShipped()
{
Status = OrderStatus.Shipped;
Version++;
}
public void CancelItem(BookId bookId)
{
int index = _items.FindIndex(i => i.BookId == bookId && !i.IsCancelled);
_items[index] = _items[index] with { IsCancelled = true };
}
}
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Bookstore.Ordering.Infrastructure;
public sealed class JsonOrderingDbContext(bool log = false) : DbContext
{
public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
{
options.UseSqlite("Data Source=ordering10json.db");
if (log) options.LogTo(Console.WriteLine, [DbLoggerCategory.Database.Command.Name], LogLevel.Information);
}
protected override void OnModelCreating(ModelBuilder modelBuilder) => modelBuilder.Entity<Order>(order =>
{
order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value));
order.Property(o => o.Status).HasConversion<string>();
order.Property(o => o.Version).IsConcurrencyToken();
order.Ignore(o => o.Items);
order.ComplexCollection<List<OrderItem>, OrderItem>("_items", items =>
{
items.ToJson("Items");
items.Property(i => i.BookId).HasConversion(id => id.Value, value => new BookId(value));
items.ComplexProperty(i => i.UnitPrice, money =>
{
money.Property(m => m.Amount);
money.Property(m => m.Currency);
});
});
});
}
Burada CancelItem bilerek Version++ yapmıyor. Sonuç:
CREATE TABLE "Orders" (
"Id" TEXT NOT NULL CONSTRAINT "PK_Orders" PRIMARY KEY,
"Status" TEXT NOT NULL,
"Version" INTEGER NOT NULL,
"Items" TEXT NOT NULL
);
UPDATE "Orders" SET "Items" = @p0
WHERE "Id" = @p1 AND "Version" = @p2
RETURNING 1;
support: DbUpdateConcurrencyException
Satırlar root'un satırında yaşadığı için her satır değişikliği bir Orders update'i ve token kontrolü her zaman yapılıyor. Depo version'ı artırdığı için Grace'in kaydı reddedildi. Yine de iki tarafın da yalnızca satırları değiştirdiği bir senaryoda token değişmez; ya her method version'ı artırmalı ya da SQL Server'da otomatik değişen rowversion kullanılmalı. Bu modelde rowversion gerçekten işe yarıyor, çünkü artık her değişiklik aynı satırı güncelliyor.
İki gerçek sürtünme var. Birincisi, complex collection'ın CLR (Common Language Runtime) tipi IList<T> implement etmek zorunda; IReadOnlyList<OrderItem> property'sini doğrudan map'lediğimde model şu hatayı verdi:
The complex collection 'Order'.'Items' is of type 'IReadOnlyList<OrderItem>' which does not implement 'IList<OrderItem>'.
Encapsulation'ı korumak için property'yi Ignore edip _items field'ını adıyla map'ledim. Bedeli, LINQ (Language Integrated Query) sorgularında o.Items yerine EF.Property<List<OrderItem>>(o, "_items") yazmak; EF Core bunu SQLite'ta json_each ile bir EXISTS'e çevirdi ve doğru sonucu döndürdü. İkincisi, kitap bazlı raporlar (hangi kitaptan bu ay kaç adet satıldı?) artık bir JOIN değil JSON sorgusu. Bookstore'da Ordering'in satır tablosuna raporlama ihtiyacı yüksek olduğu için ayrı tabloyu seçiyoruz; satırları nadiren sorgulanan bir aggregate için JSON çok daha az tören demek.
Sık Düşülen Tuzaklar
Domain'in ürettiği Guid key ve "0 satır etkilendi". Converter'sız düz bir Guid key'i EF Core varsayılan olarak "veritabanı üretir" diye işaretler. Domain Id'yi constructor'da verdiyse, yüklü bir aggregate'e eklenen yeni child'ın dolu Id'si EF'e "bu zaten var" der:
namespace Bookstore.Shipping;
public sealed class Shipment
{
private readonly List<Parcel> _parcels = [];
public Guid Id { get; private set; } = Guid.CreateVersion7();
public IReadOnlyList<Parcel> Parcels => _parcels;
public void AddParcel(decimal weightKg) => _parcels.Add(new Parcel(weightKg));
}
public sealed class Parcel(decimal weightKg)
{
public Guid Id { get; private set; } = Guid.CreateVersion7();
public decimal WeightKg { get; private set; } = weightKg;
}
public sealed class ShippingDbContext(bool keysFromDomain) : DbContext
{
public DbSet<Shipment> Shipments => Set<Shipment>();
protected override void OnConfiguring(DbContextOptionsBuilder options) =>
options.UseSqlite("Data Source=shipping10.db");
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Shipment>().HasMany(s => s.Parcels).WithOne().HasForeignKey("ShipmentId");
if (keysFromDomain)
modelBuilder.Entity<Parcel>().Property(p => p.Id).ValueGeneratedNever();
}
}
Parcel {Id: 01a1b80d-c319-7993-8136-c07d2924b918} Unchanged FK {ShipmentId: 01a1b80d-c319-72ca-a23a-dfcdbb1602a6}
Parcel {Id: 01a1b80d-c3a7-728c-b0bf-b0f8c40989fc} Modified FK {ShipmentId: 01a1b80d-c319-72ca-a23a-dfcdbb1602a6}
Shipment {Id: 01a1b80d-c319-72ca-a23a-dfcdbb1602a6} Unchanged
DbUpdateConcurrencyException: The database operation was expected to affect 1 row(s), but actually affected 0 row(s); data may...
Yeni koli Added değil Modified işaretlendi, olmayan bir satıra UPDATE gitti. ValueGeneratedNever() ile aynı program:
Parcel {Id: 01a1b80d-c6ff-7aef-965e-9b5d382d8821} Unchanged FK {ShipmentId: 01a1b80d-c6ff-7903-8ac5-8bb04be122a4}
Parcel {Id: 01a1b80d-c791-72f0-b2c3-fc1cc80e35f2} Added FK {ShipmentId: 01a1b80d-c6ff-7903-8ac5-8bb04be122a4}
Shipment {Id: 01a1b80d-c6ff-7903-8ac5-8bb04be122a4} Unchanged
saved
Order'da bu sorunu yaşamadık, çünkü converter'ı olan strongly typed id'ler için EF Core değer üretmeyi zaten kapatıyor.
Child'ı kendi DbSet'inden ya da Set<T>()'den değiştirmek. DbSet'i açmasan da Set<OrderItem>() herkesin elinde:
await using (OrderingDbContext db = new(Db))
{
// Aggregate'i atlayıp satır tablosunu doğrudan güncellemek
await db.Set<OrderItem>().Where(i => i.BookId == dune).ExecuteUpdateAsync(s => s.SetProperty(i => i.Quantity, 50));
Order order = (await new EfOrderRepository(db).FindAsync(orderId))!;
Console.WriteLine($"Dune x{order.Items.First(i => i.BookId == dune).Quantity}");
}
await using (OrderingDbContext db = new(Db))
{
// Eski bir import script'i desteklenmeyen para birimi yazmış
await db.Database.ExecuteSqlAsync($"UPDATE OrderItems SET Currency = 'USD' WHERE Title = 'Emma'");
try { await new EfOrderRepository(db).FindAsync(orderId); }
catch (ArgumentException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.Message}"); }
}
Dune x50
ArgumentException: Unsupported currency 'USD'. (Parameter 'currency')
İlk blok, kitap başına 10 adet kuralını aggregate'e hiç uğramadan deldi ve yükleme bunu fark etmedi; Quantity düz bir int. İkinci blokta ise Money'nin constructor'ı okuma sırasında da çalıştığı için bozuk veri siparişin yüklenmesini tamamen engelledi. Bu iki yüzlü bir sonuç: geçersiz state domain'e giremiyor, ama eski bir import'un bıraktığı tek bir bozuk satır siparişi açılamaz hale getiriyor. Kuralları sıkılaştırmadan önce mevcut veriyi bir migration ya da script ile temizle; ExecuteUpdate ve raw SQL'i aggregate'lerin tablolarında yalnızca bu tür bilinçli bakım işleri için kullan.
Lazy loading ve disconnected graph. Lazy loading proxy'leri, 09'da konuştuğumuz gibi başka aggregate'lere giden navigation'ları sessizce yükler; aggregate'lerde kapalı tut. API'den gelen bir DTO (Data Transfer Object) graph'ını db.Update(order) ile bağlamak da tüm satırları Modified işaretler ve domain method'larını atlar. Aggregate'i her zaman yükle, değişikliği domain method'uyla yap, sonra kaydet; disconnected senaryonun ayrıntıları EF Core serisinin "EF Core ile Veri Kaydetme: SaveChanges, İlişkili Graf ve Disconnected Entity'ler" yazısında.
Küçük Bir Deneme
★☆☆ CancelItem'daki Version++'ı sil ve senaryoyu kendin çalıştır. Sonra aynı testi MarkShipped'deki Version++'ı silerek tekrarla; bu kez hangi taraf kazanıyor?
★★☆ Bir SaveChangesInterceptor yaz: ChangeTracker'da Modified ya da Added durumundaki her OrderItem için shadow OrderId üzerinden root'u bulup Version'ını artırsın. Domain'deki Version++ satırlarını kaldırabildin mi?
★★★ Aynı aggregate'i SQL Server'da rowversion ile hem ayrı tablo hem JSON modelinde dene. Hangi modelde rowversion tek başına yeterli, hangisinde değil? Sonucu Aggregate Design Canvas'ın throughput satırıyla karşılaştır.
Aggregate'lerimiz artık hem kodda hem veritabanında tutarlı. Ama bazı davranışlar ne Order'a ne StockItem'a ait: kargo ücretini hesaplamak, bir siparişi ödeme ve stok arasında yönetmek. Bir sonraki yazıda bu davranışların nereye gideceğine bakıyoruz: domain service, application service ve Fowler'ın anemic domain model eleştirisi.