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

Uçtan Uca: Bookstore Ordering Context'ini Anemic CRUD'dan Zengin Modele Refactor Etmek

Mert Özen 7 Kas 2026 19 dk 5 görüntülenme
Uçtan Uca: Bookstore Ordering Context'ini Anemic CRUD'dan Zengin Modele Refactor Etmek

Anemic bir CRUD kodunu zengin modele taşımanın güvenli yolu, önce mevcut davranışı characterization test'lerle sabitleyip sonra dil, value object, aggregate sınırı, invariant ve domain event adımlarını her biri yeşil kalan ayrı commit'lerle atmaktır. Bookstore Ordering'i 11 commit'te dönüştürüyor, "3 al 2 öde" kampanyasıyla refactoring toward deeper insight'ı ve öncesi/sonrası metrikleri gerçek çıktılarla gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10 ve Entity Framework Core (EF Core) 10 ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore'un Ordering kodu, serinin başından beri "bugünkü hâl" dediğimiz katmanlı monolith'te şöyle duruyor: bir entity, bir service, bir controller. Her şey derleniyor, production'da aylardır çalışıyor.

namespace Bookstore.Ordering.Entities;

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public DateTime OrderDate { get; set; }
    public int Status { get; set; } // 0 = new, 1 = paid, 2 = shipped, 3 = cancelled
    public string Currency { get; set; } = "EUR";
    public decimal Total { get; set; }
    public List<OrderDetail> Details { get; set; } = new();
}
using Bookstore.Ordering.Data;
using Bookstore.Ordering.Dtos;
using Bookstore.Ordering.Entities;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Ordering.Services;

public class OrderService(OrderingDbContext db)
{
    public async Task<int> CreateOrder(CreateOrderDto dto)
    {
        if (dto.Lines.Count == 0)
            throw new InvalidOperationException("Order must have at least one line.");

        var order = new Order { CustomerId = dto.CustomerId, OrderDate = DateTime.Now, Status = 0 };
        foreach (var line in dto.Lines)
            AddOrMerge(order, line);

        order.Total = order.Details.Sum(d => d.Price * d.Qty);
        db.Orders.Add(order);
        await db.SaveChangesAsync();
        return order.Id;
    }

    public async Task AddItem(int orderId, OrderLineDto line)
    {
        var order = await db.Orders.Include(o => o.Details).FirstAsync(o => o.Id == orderId);
        if (order.Status != 0)
            throw new InvalidOperationException("Order cannot be changed.");

        AddOrMerge(order, line);
        order.Total = order.Details.Sum(d => d.Price * d.Qty);
        await db.SaveChangesAsync();
    }

    public async Task RemoveItem(int orderId, int productId)
    {
        var order = await db.Orders.Include(o => o.Details).FirstAsync(o => o.Id == orderId);
        if (order.Status != 0)
            throw new InvalidOperationException("Order cannot be changed.");

        var detail = order.Details.First(d => d.ProductId == productId);
        db.OrderDetails.Remove(detail);
        order.Total = order.Details.Where(d => d != detail).Sum(d => d.Price * d.Qty);
        await db.SaveChangesAsync();
    }

    public async Task UpdateStatus(int orderId, int status)
    {
        var order = await db.Orders.FirstAsync(o => o.Id == orderId);
        if (status == 3 && order.Status == 2)
            throw new InvalidOperationException("Shipped orders cannot be cancelled.");

        order.Status = status;
        await db.SaveChangesAsync();
    }

    private static void AddOrMerge(Order order, OrderLineDto line)
    {
        var existing = order.Details.FirstOrDefault(d => d.ProductId == line.ProductId);
        if (existing != null)
            existing.Qty += line.Qty;
        else
            order.Details.Add(new OrderDetail
            {
                ProductId = line.ProductId, ProductName = line.ProductName, Price = line.Price, Qty = line.Qty
            });

        if (order.Details.Any(d => d.Qty > 10))
            throw new InvalidOperationException("Max 10 copies per book.");
    }
}
using Bookstore.Ordering.Data;
using Bookstore.Ordering.Dtos;
using Bookstore.Ordering.Entities;
using Bookstore.Ordering.Services;
using Microsoft.AspNetCore.Mvc;

namespace Bookstore.Web.Controllers;

[ApiController]
[Route("api/orders")]
public class OrdersController(OrderService service, OrderingDbContext db) : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Create(CreateOrderDto dto) => Ok(await service.CreateOrder(dto));

    [HttpPost("{id:int}/items")]
    public async Task<IActionResult> AddItem(int id, OrderLineDto line)
    {
        await service.AddItem(id, line);
        return NoContent();
    }

    [HttpPut("{id:int}/status")]
    public async Task<IActionResult> UpdateStatus(int id, [FromBody] int status)
    {
        await service.UpdateStatus(id, status);
        return NoContent();
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, Order order)
    {
        db.Orders.Update(order);
        await db.SaveChangesAsync();
        return NoContent();
    }
}

Üç soru. Bir siparişin Status'u kaç farklı yoldan değişebilir? Mobil uygulama PUT /api/orders/42'ye "total": 1 içeren bir JSON (JavaScript Object Notation) gönderirse ne olur? Ve yarın pazarlama "okul açılışında 3 al 2 öde" derse, kodu nereye yazarsın?

Başlangıç Noktası ve Güvenlik Ağı

Cevaplar rahatsız edici. Status üç yoldan değişiyor: UpdateStatus her sayıyı kabul ediyor, PUT endpoint'i entity'yi olduğu gibi yazıyor, kod tabanında new Order { ... } diyen herkes istediği değeri veriyor. "total": 1 veritabanına yazılıyor; hiçbir kural çalışmıyor. Kampanya kodu ise büyük ihtimalle OrderService'e, toplamın hesaplandığı üç yerden birine yazılacak.

Bu yazıda bu kodu, serinin 07–12. yazılarındaki parçaları kullanarak zengin bir modele taşıyacağız. Kural tek: her adım ayrı bir commit ve her commit'te bütün solution derleniyor, bütün test'ler geçiyor. İlk iş, davranışı değiştirmeden önce mevcut davranışı sabitlemek. Michael Feathers Working Effectively with Legacy Code (2004) kitabında buna characterization test der: kodun ne yapması gerektiğini değil, bugün ne yaptığını test eder. Sekiz test yazdım; son ikisi bilerek "yanlış" davranışı sabitliyor:

[Fact]
public async Task Removing_the_last_item_leaves_an_empty_order()
{
    int id = await NewService().CreateOrder(new(7, [Dune()]));
    await NewService().RemoveItem(id, productId: 1);

    using OrderingDbContext db = NewDb();
    Assert.Equal(0m, (await db.Orders.SingleAsync(o => o.Id == id)).Total);
}

[Fact]
public async Task Any_status_number_is_accepted()
{
    int id = await NewService().CreateOrder(new(7, [Dune()]));
    await NewService().UpdateStatus(id, 99);

    using OrderingDbContext db = NewDb();
    Assert.Equal(99, (await db.Orders.SingleAsync(o => o.Id == id)).Status);
}

Test'ler gerçek SQLite üzerinde çalışıyor, çünkü kuralların hepsi OrderService'te ve service DbContext'e bağlı; bu aşamada başka yolu yok. Son sipariş satırını silmenin boş bir sipariş bırakması ve 99'un geçerli bir durum sayılması bugünkü davranış. Onları değiştireceğiz, ama ayrı commit'lerde ve bilerek. Aynı tekniği Clean Code serisinin "Uygulamalı Clean Code: Legacy OrderService'i Adım Adım Temizlemek" yazısında okunabilirlik için kullanmıştık; burada hedef okunabilirlik değil, modelin kendisi.

Adım 1: Dil

İlk commit yalnızca isimleri değiştiriyor: OrderDetail → OrderItem, ProductId → BookId, Qty → Quantity, OrderDate → PlacedAt, int Status → OrderStatus. Serinin 02. yazısında konuştuğumuz gibi, "ürün" bu context'te belirsiz bir kelime; Ordering'in satırları kitaptır. Veritabanı tarafında ise hiçbir şey değişmiyor:

using Bookstore.Ordering.Entities;
using Microsoft.EntityFrameworkCore;

namespace Bookstore.Ordering.Data;

public class OrderingDbContext(DbContextOptions<OrderingDbContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Kodda domain dili, şemada eski adlar: migration gerekmiyor
        modelBuilder.Entity<Order>(order =>
        {
            order.Property(o => o.PlacedAt).HasColumnName("OrderDate");
            order.HasMany(o => o.Items).WithOne().HasForeignKey(i => i.OrderId);
        });
        modelBuilder.Entity<OrderItem>(item =>
        {
            item.ToTable("OrderDetails");
            item.Property(i => i.BookId).HasColumnName("ProductId");
            item.Property(i => i.Title).HasColumnName("ProductName");
            item.Property(i => i.UnitPrice).HasColumnName("Price");
            item.Property(i => i.Quantity).HasColumnName("Qty");
        });
    }
}

Kodda domain dili, şemada eski adlar. Enum değerlerinin sayılarını açıkça yazmak, veritabanındaki mevcut 0/1/2/3 satırlarının anlamını korumanın yolu. İkinci commit ise davranış değiştiriyor ve bunu commit mesajında söylüyor: UpdateStatus(int) gidiyor, yerine CancelAsync, MarkPaidAsync, MarkShippedAsync geliyor. Characterization test'lerinden Any_status_number_is_accepted silinip yerine Cancel_sets_the_cancelled_status yazılıyor. Davranış değişikliğini bir refactoring commit'inin içine saklamamak, sonradan "bu ne zaman bozuldu" sorusunu cevaplanabilir kılar.

Adım 2: Value Object'ler ve Strongly Typed Id'ler

decimal Total ve string Currency artık tek bir Money; serinin 07. yazısındaki value object, Bookstore.SharedKernel'de. Id'ler de tip kazanıyor, ama 07'deki gibi GUID (Globally Unique Identifier) değil:

namespace Bookstore.Ordering.Entities;

// Mevcut int identity kolonları korunuyor; tip güvenliği kodda
public readonly record struct OrderId(int Value);
public readonly record struct BookId(int Value);
public readonly record struct CustomerId(int Value);

Sebep veritabanı: tablolar int identity kolonlarıyla dolu ve bu adımın amacı tip güvenliği, şema göçü değil. OrderId ile BookId'yi karıştıran bir çağrı artık derlenmiyor. Money, EF Core'da complex type olarak map'leniyor; Order.Total mevcut Total ve Currency kolonlarına oturuyor, OrderItem.UnitPrice ise OrderDetails tablosuna yeni bir Currency kolonu ekliyor. Bu, yazı boyunca bilinçli olarak kabul ettiğimiz ilk şema değişikliği ve commit mesajında yazıyor. Complex type'ın mapping detayları EF Core serisinin 26. ve 28. yazılarında.

Adım 3: Aggregate Sınırı

Bu commit en büyüğü. Setter'lar private oluyor, satır listesi _items backing field'ının arkasına geçiyor ve service'teki kurallar sahibine taşınıyor:

public void AddItem(BookId bookId, string title, Money unitPrice, int quantity)
{
    EnsureEditable();
    OrderItem? existing = _items.FirstOrDefault(i => i.BookId == bookId);
    int newQuantity = (existing?.Quantity ?? 0) + quantity;
    if (newQuantity > MaxQuantityPerBook)
        throw new InvalidOperationException($"Max {MaxQuantityPerBook} copies per book.");

    if (existing is null)
        _items.Add(new OrderItem(bookId, title, unitPrice, quantity));
    else
        existing.Increase(quantity);
    RecalculateTotal();
}

public void RemoveItem(BookId bookId)
{
    EnsureEditable();
    _items.Remove(_items.First(i => i.BookId == bookId));
    RecalculateTotal();
}

DbSet<OrderItem> siliniyor; bir satıra artık yalnızca Order üzerinden ulaşılıyor ve satırlar AutoInclude ile siparişle birlikte yükleniyor. OrderService, DbContext yerine aggregate başına bir IOrderRepository alıyor. Serinin 09. ve 10. yazılarında çizdiğimiz sınır bu. Controller'daki PUT /api/orders/{id} de bu commit'te gidiyor: entity'yi dışarıdan bütün olarak yazan bir endpoint, aggregate sınırının olmaması demek. Test'ler hâlâ sekiz ve hepsi yeşil; davranış değişmedi, yalnızca yeri değişti.

Adım 4: Invariant'lar ve Factory

Kurallar artık bir yerde toplandığı için eksikleri görünür oldu. Boş bir siparişi engelleyen kontrol OrderService'teydi; Order.Place factory'si onu modele alıyor. Hata modeli, serinin 08. yazısındaki karar: çağıranın önceden bilebileceği kural ihlalleri kodu olan bir DomainException.

public static Order Place(CustomerId customerId, 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, clock.GetUtcNow().UtcDateTime);
    foreach (CartLine line in lines)
        order.Add(line);
    return order;
}

public void AddItem(CartLine line)
{
    EnsureStatus(OrderStatus.Placed, "order.not-editable");
    Add(line);
}

public void RemoveItem(BookId bookId)
{
    EnsureStatus(OrderStatus.Placed, "order.not-editable");
    OrderItem item = _items.Find(i => i.BookId == bookId)
        ?? throw new DomainException("order.item-not-found", $"Book {bookId.Value} is not in the order.");
    if (_items.Count == 1)
        throw new DomainException("order.last-item", "The last item cannot be removed; cancel the order instead.");

    _items.Remove(item);
    RecalculateTotal();
}

İki davranış değişikliği bu commit'te ve mesajında: son satır artık silinemiyor (müşteri her şeyi istemiyorsa bu bir iptal), ödenmemiş sipariş kargoya verilemiyor. İlgili characterization test'i yeni davranışa göre güncellendi. Asıl kazanç ise test'lerin şekli. Kurallar Order'da olduğu için onları veritabanı olmadan sınayabiliyoruz:

[Fact]
public void The_last_item_cannot_be_removed() =>
    Assert.Equal("order.last-item", CodeOf(() => Place(Dune()).RemoveItem(new BookId(1))));

[Fact]
public void An_unpaid_order_cannot_be_shipped() =>
    Assert.Equal("order.not-shippable", CodeOf(() => Place(Dune()).MarkShipped()));

Sekiz yeni domain test'i eklendi, toplam 16. Ölçtüğümde domain test'lerinin her biri 3 ms'nin altında kaldı; SQLite üzerindeki characterization test'lerinden ilki ise EF Core'un model kurulumu yüzünden tek başına 383 ms sürdü.

Adım 5: Domain Event'ler ve Identity'yi Kim Üretir?

12. yazıdaki AggregateRoot, dispatcher ve interceptor'ı olduğu gibi aldım; Order.Place bir OrderPlaced, Order.Cancel bir OrderCancelled üretiyor. İlk denemede event'in içindeki OrderId'ye baktım:

Before SaveChanges: OrderId = 0
After SaveChanges:  OrderId = 1

Int identity kolonunda id'yi veritabanı INSERT sırasında üretiyor. Event ise Place anında, kayıttan önce oluşuyor ve içinde 0 taşıyor; Inventory'ye "0 numaralı sipariş verildi" demek hiçbir işe yaramaz. Refactoring burada modelde bir eksik gösterdi: siparişin identity'si, siparişin kendisi var olduğu anda bilinmeli. Çözüm, Çözüm Desenleri serisinin "Id'yi Dışarıya Açmak: int Identity, GUID v7 ve Public Id Ayrımı" yazısındaki ayrım: OrderId artık domain'in Guid.CreateVersion7() ile ürettiği bir public id ve PublicId kolonunda unique index'le duruyor; int Id yalnızca teknik anahtar olarak shadow property'de kalıyor. API (Application Programming Interface) route'ları {id:guid} oldu. Bu ayrı bir commit; event'ler ondan sonraki commit'te geliyor ve bir integration test'i, OrderPlaced'in commit'ten sonra tam bir kez ve doğru id ile dağıtıldığını doğruluyor.

Refactoring Toward Deeper Insight: 3 Al 2 Öde

Evans, kitabının üçüncü bölümüne "Refactoring Toward Deeper Insight" adını verir: model yalnızca kod temizlendikçe değil, domain daha iyi anlaşıldıkça değişir. Bunu görmek için yeni bir kural lazım. Pazarlama okul açılışı için bir kampanya istiyor: üç kitap alana en ucuzu bedava. İlk deneme, aceleyle yazılmış ama test'leri geçen bir kod:

// Okul açılışı kampanyası: 3 kitap alana en ucuzu bedava
public void ApplyThreeForTwo()
{
    if (_items.Sum(i => i.Quantity) < 3)
        return;

    OrderItem cheapest = _items.MinBy(i => i.UnitPrice.Amount)!;
    if (cheapest.Quantity == 1)
        _items.Remove(cheapest);
    else
        cheapest.Decrease(1);
    _items.Add(new OrderItem(cheapest.BookId, cheapest.Title, Money.Zero(cheapest.UnitPrice.Currency), 1));
    RecalculateTotal();
}

Dune 30, Emma 20, The Hobbit 15 EUR: The Hobbit bedava satıra dönüşüyor, toplam 50, test yeşil. Commit edildi. Ertesi gün kampanyayı tasarlayan kişiyle örnekler üzerinden konuşurken o şunu soruyor: "Müşteri ödemeden önce Dune'u çıkarırsa ne olur? İki kitap kaldı, kampanya şartı bozuldu." Onun cümlesini olduğu gibi bir test'e çevirdim:

Assert.Equal() Failure: Values differ
Expected: 35.00 EUR
Actual:   20.00 EUR
Failed!  - Failed:     1, Passed:    20, Skipped:     0, Total:    21, Duration: 408 ms - Bookstore.Ordering.Tests.dll (net10.0)

Müşteri 35 EUR ödemeliydi, 20 ödüyor; The Hobbit hâlâ bedava satırda duruyor. Koda bakınca bir hata daha görünüyor: bir Hobbit daha eklense Add, aynı BookId'ye sahip ilk satırı, yani bedava satırı büyütür. Bunlar tek tek düzeltilebilir, ama asıl sorun modelde: indirim bir satır olarak modellenmiş. Domain expert'in dilinde indirim bir satır değil, sepetin o anki hâlinden hesaplanan bir sonuç; ve siparişe yazılan şey indirim değil, kampanyanın koşulu. Model bu cümleye göre değişti:

using Bookstore.SharedKernel;

namespace Bookstore.Ordering.Entities;

// "3 al 2 öde": her Buy kitapta en ucuz (Buy - PayFor) kitap bedava
public sealed record BundlePromotion
{
    public BundlePromotion(int buy, int payFor)
    {
        if (payFor < 1 || payFor >= buy)
            throw new DomainException("promotion.invalid", $"Buy {buy} pay {payFor} is not a bundle.");
        (Buy, PayFor) = (buy, payFor);
    }

    public int Buy { get; }
    public int PayFor { get; }

    public Money DiscountFor(IReadOnlyList<OrderItem> items)
    {
        int freeBooks = items.Sum(i => i.Quantity) / Buy * (Buy - PayFor);
        return items
            .SelectMany(i => Enumerable.Repeat(i.UnitPrice, i.Quantity))
            .OrderBy(price => price.Amount)
            .Take(freeBooks)
            .Aggregate(Money.Zero(items[0].UnitPrice.Currency), (sum, price) => sum + price);
    }
}

Order, kampanya koşulunu sipariş anındaki hâliyle Promotion property'sinde saklıyor; kampanya yarın bitse de bu sipariş kendi koşuluyla yaşıyor. Discount bir field değil, her çağrıda satırlardan hesaplanan bir property; Total raporlar için kolonda tutulmaya devam ediyor ama yalnızca Order'ın kendisi güncelliyor. ApplyThreeForTwo ve bedava satır tamamen silindi. Kampanyanın aktif olup olmadığını application service bir IPromotionCalendar'dan soruyor ve sonucu Order.Place'e veriyor; bu, 11. yazıdaki policy object'in aynısı. Mapping'de Promotion, EF Core 10'un optional complex type desteğiyle iki nullable kolona oturuyor:

modelBuilder.Entity<Order>(order =>
{
    order.Property<int>("DbId").HasColumnName("Id");
    order.HasKey("DbId");
    order.Property(o => o.Id).HasConversion(id => id.Value, value => new OrderId(value)).HasColumnName("PublicId");
    order.HasIndex(o => o.Id).IsUnique();
    order.Property(o => o.CustomerId).HasConversion(id => id.Value, value => new CustomerId(value));
    order.Property(o => o.PlacedAt).HasColumnName("OrderDate");
    order.ComplexProperty(o => o.Total, money =>
    {
        money.Property(m => m.Amount).HasColumnName("Total");
        money.Property(m => m.Currency).HasColumnName("Currency").HasMaxLength(3);
    });
    order.Ignore(o => o.Subtotal);
    order.Ignore(o => o.Discount);
    order.ComplexProperty(o => o.Promotion, promotion =>
    {
        promotion.Property(p => p.Buy).HasColumnName("PromotionBuy");
        promotion.Property(p => p.PayFor).HasColumnName("PromotionPayFor");
    });
    order.HasMany(o => o.Items).WithOne().HasForeignKey("OrderId").IsRequired().OnDelete(DeleteBehavior.Cascade);
    order.Navigation(o => o.Items).HasField("_items").AutoInclude();
});
[Fact]
public void Removing_a_book_recalculates_the_bundle()
{
    Order order = PlaceInCampaign(Dune(), Emma(), Hobbit());

    order.RemoveItem(new BookId(1)); // Dune çıkınca kampanya şartı bozuluyor

    Assert.Equal(new Money(35m, "EUR"), order.Total);
}

[Fact]
public void Adding_a_cheaper_book_moves_the_discount_to_it()
{
    Order order = PlaceInCampaign(Dune(), Emma(), Hobbit());

    order.AddItem(new CartLine(new BookId(4), "Middlemarch", new Money(10m, "EUR"), 1));

    Assert.Equal(new Money(10m, "EUR"), order.Discount);
    Assert.Equal(new Money(65m, "EUR"), order.Total);
}
Passed!  - Failed:     0, Passed:    23, Skipped:     0, Total:    23, Duration: 400 ms - Bookstore.Ordering.Tests.dll (net10.0)

Uzmanın örneği artık 35 EUR. Ayrıca daha ucuz bir kitap eklenince indirimin ona kaydığını gösteren yeni bir test var; bu, uzmanla konuşurken ortaya çıkan ikinci örnek. Kod bu kez kendiliğinden doğru davrandı, çünkü kavram doğru yere oturdu.

Sonuç: Her Commit Yeşil, Kural Nerede?

Yazının başındaki vaadi bir script'le doğruladım: bütün commit'leri sırayla checkout edip solution'ı derliyor ve test'leri çalıştırıyor.

#!/bin/zsh
# Her commit'i sırayla checkout et, solution'ı derle, testleri çalıştır
for c in $(git rev-list --reverse main); do
  git checkout -q $c
  errors=$(dotnet build 2>&1 | grep -cE " error ")
  tests=$(dotnet test --no-build 2>&1 | grep -E "^(Passed|Failed)!" | sed -E 's/^(Passed|Failed)!.*Failed: +([0-9]+), Passed: +([0-9]+).*/\3 passed, \2 failed/')
  printf "%s  errors: %s  tests: %-20s %s\n" $(git log -1 --format=%h) $errors "${tests:-none}" "$(git log -1 --format=%s | cut -c1-60)"
done
git checkout -q main
b24fbc1  errors: 0  tests: none                 Baseline: anemic CRUD ordering
f817b8d  errors: 0  tests: 8 passed, 0 failed   Pin current behavior with characterization tests
9c81aee  errors: 0  tests: 8 passed, 0 failed   Rename to the ubiquitous language (schema unchanged)
492266c  errors: 0  tests: 8 passed, 0 failed   Replace UpdateStatus(int) with Cancel/MarkPaid/MarkShipped (
d090ca7  errors: 0  tests: 8 passed, 0 failed   Introduce Money and strongly typed ids (adds OrderDetails.Cu
4a79e6d  errors: 0  tests: 8 passed, 0 failed   Make Order the aggregate root: private setters, backing fiel
14c604c  errors: 0  tests: 16 passed, 0 failed  Protect invariants with a factory and guarded transitions (b
a7d13c9  errors: 0  tests: 16 passed, 0 failed  Let the domain generate OrderId (Guid v7 PublicId column; in
9199e8b  errors: 0  tests: 19 passed, 0 failed  Raise OrderPlaced and OrderCancelled; dispatch them on SaveC
1f8926f  errors: 0  tests: 20 passed, 0 failed  Add the back-to-school 3-for-2 campaign (first attempt: a fr
621056a  errors: 0  tests: 23 passed, 0 failed  Model the bundle as an order-level promotion (refactoring to
1d2ad2d  errors: 0  tests: 23 passed, 0 failed  Keep OrderDetails.OrderId required with cascade delete (caug

Script'in yakaladığı değil ama son adımda karşılaştırdığım şema farkının yakaladığı bir hata da var. Adım 3'te foreign key'i shadow property'ye çevirirken IsRequired()'ı unutmuşum; test'ler geçti, ama EnsureCreated'ın ürettiği şema şöyleydi:

CREATE TABLE "OrderDetails" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_OrderDetails" PRIMARY KEY AUTOINCREMENT,
    "ProductId" INTEGER NOT NULL,
    "ProductName" TEXT NOT NULL,
    "Qty" INTEGER NOT NULL,
    "OrderId" INTEGER NULL,
    "Price" TEXT NOT NULL,
    "Currency" TEXT NOT NULL,
    CONSTRAINT "FK_OrderDetails_Orders_OrderId" FOREIGN KEY ("OrderId") REFERENCES "Orders" ("Id")
);

OrderId nullable olmuş, ON DELETE CASCADE kaybolmuştu. Son commit bunu düzeltiyor. Ders: refactoring sırasında şema da bir çıktıdır; GenerateCreateScript() ya da migration diff'i adım adım karşılaştırılmalı. Sonuç metrikleri, başlangıç commit'i (f817b8d) ile son commit (1d2ad2d) arasında:

ÖlçüBaşlangıçSon
src altındaki .cs dosyası718
src satır sayısı (boş ve yorum hariç)134422
OrderService satırı6034
Order + OrderItem satırı21108
Test sayısı (veritabanısız)8 (0)23 (13)
Bookstore.Ordering line / branch coverage%89,2 / %87,5%97,7 / %90,5

Kod üç katına çıktı ve bunu saklamanın anlamı yok. Artışın bir kısmı yeni özellik (kampanya), bir kısmı altyapı (event dispatch, repository, shared kernel), bir kısmı da daha önce hiç yazılmamış kurallar: son satır, durum geçişleri, para birimi. Zengin model daha az kod vaat etmez; kuralın tek bir yerde olmasını ve o yerin domain'in diliyle konuşmasını vaat eder. Kuralların yeri:

KuralÖnceSonra
En az bir satırOrderService.CreateOrderOrder.Place
Kitap başına en fazla 10OrderService.AddOrMergeOrder.Add
Ödemeden sonra satır değişmezOrderService'te iki kopyaOrder.EnsureStatus
Durum geçişleriYok; PUT her değeri yazıyorMarkPaid, MarkShipped, Cancel
Toplam tutarÜç ayrı hesapOrder.RecalculateTotal
Kampanya indirimiYokBundlePromotion.DiscountFor

Son hâliyle Order ve application service:

using Bookstore.BuildingBlocks;
using Bookstore.SharedKernel;

namespace Bookstore.Ordering.Entities;

// Sayılar veritabanındaki mevcut satırlarla aynı kalmalı
public enum OrderStatus { Placed = 0, Paid = 1, Shipped = 2, Cancelled = 3 }

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

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

    private Order() { } // EF Core

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

    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public DateTime PlacedAt { get; private set; }
    public OrderStatus Status { get; private set; }
    public BundlePromotion? Promotion { get; private set; }
    public Money Subtotal => _items.Aggregate(Money.Zero(_items[0].UnitPrice.Currency), (sum, i) => sum + i.LineTotal);
    public Money Discount => Promotion?.DiscountFor(_items) ?? Money.Zero(_items[0].UnitPrice.Currency);
    public Money Total { get; private set; } = Money.Zero("EUR");
    public IReadOnlyList<OrderItem> Items => _items;

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

        // Kampanya koşulu sipariş anındaki hâliyle siparişe yazılır
        Order order = new(customerId, clock.GetUtcNow().UtcDateTime) { Promotion = promotion };
        foreach (CartLine line in lines)
            order.Add(line);

        order.Raise(new OrderPlaced(order.Id, customerId, order.Total, clock.GetUtcNow()));
        return order;
    }

    public void AddItem(CartLine line)
    {
        EnsureStatus(OrderStatus.Placed, "order.not-editable");
        Add(line);
    }

    public void RemoveItem(BookId bookId)
    {
        EnsureStatus(OrderStatus.Placed, "order.not-editable");
        OrderItem item = _items.Find(i => i.BookId == bookId)
            ?? throw new DomainException("order.item-not-found", $"Book {bookId.Value} is not in the order.");
        if (_items.Count == 1)
            throw new DomainException("order.last-item", "The last item cannot be removed; cancel the order instead.");

        _items.Remove(item);
        RecalculateTotal();
    }

    public void MarkPaid()
    {
        EnsureStatus(OrderStatus.Placed, "order.not-payable");
        Status = OrderStatus.Paid;
    }

    public void MarkShipped()
    {
        EnsureStatus(OrderStatus.Paid, "order.not-shippable");
        Status = OrderStatus.Shipped;
    }

    public void Cancel(CancellationReason reason, TimeProvider clock)
    {
        if (Status is not (OrderStatus.Placed or OrderStatus.Paid))
            throw new DomainException("order.not-cancellable", $"Order is {Status} and cannot be cancelled.");
        Status = OrderStatus.Cancelled;
        Raise(new OrderCancelled(Id, reason, clock.GetUtcNow()));
    }

    private void Add(CartLine line)
    {
        ArgumentOutOfRangeException.ThrowIfNegativeOrZero(line.Quantity);
        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);
        RecalculateTotal();
    }

    private void EnsureStatus(OrderStatus expected, string code)
    {
        if (Status != expected)
            throw new DomainException(code, $"Order is {Status}, expected {expected}.");
    }

    // İndirim bir satır değil: her değişiklikte satırların o anki hâlinden yeniden hesaplanır
    private void RecalculateTotal() => Total = Subtotal - Discount;
}
using Bookstore.Ordering.Data;
using Bookstore.Ordering.Dtos;
using Bookstore.Ordering.Entities;
using Bookstore.SharedKernel;

namespace Bookstore.Ordering.Services;

// Application service: yükle, domain'e devret, kaydet
public class OrderService(IOrderRepository orders, IPromotionCalendar promotions, TimeProvider clock)
{
    public async Task<Guid> PlaceAsync(PlaceOrderRequest request, CancellationToken ct = default)
    {
        BundlePromotion? promotion = promotions.ActiveBundle(clock.GetUtcNow());
        Order order = Order.Place(new CustomerId(request.CustomerId), [.. request.Items.Select(ToCartLine)], promotion, clock);
        orders.Add(order);
        await orders.SaveChangesAsync(ct);
        return order.Id.Value;
    }

    public Task AddItemAsync(Guid orderId, OrderItemRequest item, CancellationToken ct = default) =>
        UpdateAsync(orderId, order => order.AddItem(ToCartLine(item)), ct);

    public Task RemoveItemAsync(Guid orderId, int bookId, CancellationToken ct = default) =>
        UpdateAsync(orderId, order => order.RemoveItem(new BookId(bookId)), ct);

    public Task MarkPaidAsync(Guid orderId, CancellationToken ct = default) =>
        UpdateAsync(orderId, order => order.MarkPaid(), ct);

    public Task MarkShippedAsync(Guid orderId, CancellationToken ct = default) =>
        UpdateAsync(orderId, order => order.MarkShipped(), ct);

    public Task CancelAsync(Guid orderId, CancellationToken ct = default) =>
        UpdateAsync(orderId, order => order.Cancel(CancellationReason.CustomerRequest, clock), ct);

    private async Task UpdateAsync(Guid orderId, Action<Order> change, CancellationToken ct)
    {
        Order order = await orders.GetAsync(new OrderId(orderId), ct);
        change(order);
        await orders.SaveChangesAsync(ct);
    }

    private static CartLine ToCartLine(OrderItemRequest item) =>
        new(new BookId(item.BookId), item.Title, new Money(item.UnitPrice, "EUR"), item.Quantity);
}

Sık Düşülen Tuzaklar

Büyük patlama

"Ordering'i baştan DDD (Domain-Driven Design) ile yazalım" kararı, aylarca paralel iki sistem ve sonunda eski davranışın unutulmuş köşeleri demektir. Yukarıdaki on bir commit'in her biri tek başına deploy edilebilir durumda; iş yarıda kesilse bile kod tabanı başladığından iyi.

Yapıyla davranışı aynı commit'te değiştirmek

Kent Beck'in refactoring için kullandığı ifadeyle iki şapka var: ya yapıyı değiştirirsin ya davranışı, ikisini aynı anda değil. Bu yazıdaki davranış değişiklikleri (keyfi durum kodu, son satır, ödemeden kargo) her biri ayrı bir commit'te ve mesajında "behavior change" yazıyor.

Characterization test'lerini erken silmek

Domain test'leri geldi diye veritabanı üzerindeki test'leri silmek cazip. Ama onlar mapping'i, AutoInclude'u ve repository'yi de sınıyor; sonuçta 23 test'in 8'i hâlâ onlar. Silmek yerine yeni davranışa göre güncelle.

Şemayı çıktı saymamak

Kayıp ON DELETE CASCADE hiçbir test'i kırmadı. Production'da bir sipariş silindiğinde satırların öksüz kalmasıyla fark edilirdi. Mapping'e dokunan her adımda şema farkına bak.

Domain expert'siz refactoring

Bedava satır modeli teknik olarak düzeltilebilirdi; yanlış olduğunu söyleyen, kampanyayı tasarlayan kişinin tek bir örneğiydi. Derin bir kavrayış klavyeden gelmez; örnek, soru ve test'e çevrilmiş bir cümle olarak gelir.

Küçük Bir Deneme

★☆☆ check-commits.sh'i kendi projende bir branch üzerinde çalıştır. Kırmızı bir commit bulursan, onu ayırmak için git rebase mı git revert mü kullanırsın?

★★☆ Uzman yeni bir örnek getiriyor: "Dört kitap alan müşteri yine tek kitap bedava alır, altı kitap alan iki." BundlePromotion için bu iki örneği test'e çevir. Geçiyorlar mı? Geçmiyorsa model mi, kod mu yanlış?

★★★ Kampanya koşulu siparişe yazıldı ama OrderPlaced event'i indirimden habersiz. Payments context'i iade tutarını hesaplarken neye ihtiyaç duyar? Event'e Discount eklemek ile Payments'ın Order'ı sorgulaması arasındaki tercihi, serinin 05. yazısındaki context map'e bakarak yaz.

Tactical parçalarla uçtan uca bir dönüşüm yaptık. Serinin son yazısında geriye çekilip DDD'nin yirmi yılını konuşacağız: Evans'ın kitabından EventStorming'e, Vernon'ın aggregate kurallarından aggregate'i tamamen sorgulayan Dynamic Consistency Boundary'ye; ve bu yolda en sık yapılan yanlışlara.