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

Domain Service, Application Service ve Anemic Domain Model

Mert Özen 7 Kas 2026 17 dk 5 görüntülenme
Domain Service, Application Service ve Anemic Domain Model

Davranış önce sahibi olan entity'ye ya da value object'e gider; hiçbirine doğal olarak ait olmayan, stateless bir domain hesabı domain service olur, yükleme-kaydetme-transaction ise ince bir application service'te kalır. Bookstore'da kopyalanmış kargo kuralını anemic service'lerden Order'a ve ShippingFeeCalculator'a taşıyıp policy, factory ve domain service'e repository inject etme tuzağını gerçek çıktılarla gösteriyoruz.

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

Önce Tahmin Et

Bookstore'un katmanlı başlangıç hâlinde, Bookstore.Business project'inde sipariş kodu aşağıdaki gibi duruyor. Order yalnız property'lerden oluşuyor; hesabı checkout sırasında CheckoutService, müşteri hizmetleri bir kitabı siparişten çıkardığında OrderAdminService yapıyor. Kural basit: yurt içi siparişte ara toplam 50 EUR ve üzeriyse kargo ücretsiz, değilse 4,90 EUR; yurt dışı 12,90 EUR.

namespace Bookstore.Business;

public sealed class Order
{
    public Guid Id { get; set; }
    public string CustomerEmail { get; set; } = "";
    public string Country { get; set; } = "TR";
    public int Status { get; set; } // 0 = Placed, 1 = Shipped, 2 = Cancelled
    public List<OrderItem> Items { get; set; } = [];
    public decimal ShippingFee { get; set; }
    public decimal Total { get; set; }
}

public sealed class OrderItem
{
    public Guid BookId { get; set; }
    public string Title { get; set; } = "";
    public decimal UnitPrice { get; set; }
    public int Quantity { get; set; }
}

public sealed class CheckoutService
{
    public Order Checkout(string email, string country, List<OrderItem> items)
    {
        Order order = new() { Id = Guid.CreateVersion7(), CustomerEmail = email, Country = country, Items = items };
        decimal subtotal = order.Items.Sum(i => i.UnitPrice * i.Quantity);
        decimal baseFee = country == "TR" ? 4.90m : 12.90m;
        order.ShippingFee = country == "TR" && subtotal >= 50m ? 0m : baseFee;
        order.Total = subtotal + order.ShippingFee;
        return order;
    }
}

public sealed class OrderAdminService
{
    public void RemoveItem(Order order, Guid bookId)
    {
        if (order.Status == 1)
            throw new InvalidOperationException("Order already shipped.");

        order.Items.RemoveAll(i => i.BookId == bookId);
        decimal subtotal = order.Items.Sum(i => i.UnitPrice * i.Quantity);
        order.ShippingFee = subtotal > 50m ? 0m : 4.90m;
        order.Total = subtotal + order.ShippingFee;
    }
}

Ada, İzmir'e üç kitap sipariş ediyor: Dune 30, Emma 20, The Hobbit 15 EUR. Ertesi gün telefonla arayıp The Hobbit'i istemediğini söylüyor ve müşteri hizmetleri onu siparişten çıkarıyor. Geriye 50 EUR'luk bir sepet kalıyor. Ada'nın faturasında kargo ücreti kaç yazar? Aynı iki kitabı en baştan sipariş etseydi ne öderdi? Ve biri bu siparişi order.Status = 2 diyerek iptal ederse Total'a ne olur?

Kural İki Service'te, İki Farklı Cevap

Senaryoyu bir console uygulamasında çalıştırdım:

// Anemic: kural iki service'te kopyalanmış
Legacy.Order legacy = new Legacy.CheckoutService().Checkout("ada@example.com", "TR",
[
    new() { BookId = dune, Title = "Dune", UnitPrice = 30m, Quantity = 1 },
    new() { BookId = emma, Title = "Emma", UnitPrice = 20m, Quantity = 1 },
    new() { BookId = hobbit, Title = "The Hobbit", UnitPrice = 15m, Quantity = 1 },
]);
Console.WriteLine($"Checkout:     shipping {F(legacy.ShippingFee)}, total {F(legacy.Total)}");
new Legacy.OrderAdminService().RemoveItem(legacy, hobbit);
Console.WriteLine($"Admin remove: shipping {F(legacy.ShippingFee)}, total {F(legacy.Total)}");

Legacy.Order fresh = new Legacy.CheckoutService().Checkout("ada@example.com", "TR",
[
    new() { BookId = dune, Title = "Dune", UnitPrice = 30m, Quantity = 1 },
    new() { BookId = emma, Title = "Emma", UnitPrice = 20m, Quantity = 1 },
]);
Console.WriteLine($"Same basket:  shipping {F(fresh.ShippingFee)}, total {F(fresh.Total)}");
legacy.Status = 2;
Console.WriteLine($"Cancelled via setter: status {legacy.Status}, total still {F(legacy.Total)}");
Checkout:     shipping 0.00, total 65.00
Admin remove: shipping 4.90, total 54.90
Same basket:  shipping 0.00, total 50.00
Cancelled via setter: status 2, total still 54.90

Aynı sepet için iki farklı fiyat çıktı. Checkout'tan geçen 50 EUR'luk sepet ücretsiz kargo alıyor, müşteri hizmetlerinin elinden geçen aynı sepet 4,90 EUR ödüyor. Sebep tek bir karakter: OrderAdminService kuralı kopyalarken >= yerine > yazmış. Üstelik yurt dışı ücretini de unutmuş; Ada Berlin'de olsaydı kitap çıkarmak kargoyu 12,90'dan 4,90'a düşürecekti. Son satır ayrı bir sorun: iptal, bir setter'a sayı yazmaktan ibaret. Ne toplam değişiyor ne bir kural çalışıyor; 2'nin "iptal" demek olduğu da yalnızca bir yorum satırında yazıyor.

Hiçbiri tek başına büyük bir hata değil. Asıl sorun, "kargo ücreti nasıl hesaplanır" sorusunun cevabının modelde olmaması. Order class'ına bakan biri kuralı göremiyor; kuralı bulmak için service'leri tek tek okuması gerekiyor ve her yeni ekran (iade, kampanya, toplu import) kuralı bir kez daha kopyalıyor. DDD'nin (Domain-Driven Design) tactical tarafının cevap verdiği soru tam olarak bu: davranış nerede yaşamalı?

Fowler'ın Anemic Domain Model Eleştirisi

Martin Fowler bu yapıya 2003'te yazdığı kısa bir bliki notunda Anemic Domain Model adını verdi. Tarifi tanıdık: class'ların adları domain'den geliyor, aralarında ilişkiler var, ama içleri neredeyse boş; davranış service'lerde. Fowler'ın itirazının iki yanı var. Birincisi, bu object-oriented tasarımın temel fikrine, veriyle o veriyi işleyen davranışı bir araya koymaya ters. İkincisi daha pratik: domain model'in bütün maliyetini (mapping, ORM (Object-Relational Mapping), class hiyerarşisi) ödüyorsun ama karşılığını alamıyorsun, çünkü service'ler aslında PoEAA'daki (Patterns of Enterprise Application Architecture, Martin Fowler, 2002) Transaction Script'lere dönüşmüş durumda.

Notun sık atlanan bir cümlesi var: Fowler service layer'a karşı değil. Evans'ın kitabından alıntıladığı tanımla application layer ince kalmalı, iş kuralı ya da iş bilgisi taşımamalı, işi koordine edip domain object'lerine devretmeli. Yani soru "service olsun mu" değil, "service'te ne olsun". Anemic modelin bir anti-pattern olarak genel tanıtımı ve import job'unun kuralı nasıl deldiği Design Patterns serisinin "Anti-Pattern'ler: God Object, Service Locator, Singleton Abuse, Anemic Domain Model ve Pattern Hastalığı" yazısında var; burada DDD açısından üç tür service'i ayırıp her davranışı doğru yere koyacağız.

Evans'ın Üç Service'i: Application, Domain ve Infrastructure

Eric Evans Domain-Driven Design kitabında (2003) "service" kelimesinin üç farklı şey için kullanıldığını söyler ve bunları layer'larına göre ayırır. Kitaptaki örnek bir banka havalesidir: input'u alıp sonucu bildiren kısım application service, iki hesap arasında parayı hareket ettiren kural domain service, e-postayı gönderen kısım infrastructure service. Bookstore'a uyarlayınca tablo şöyle:

TürNe yaparNe bilmezBookstore örneği
Application serviceAggregate'i yükler, domain'e devreder, kaydeder; transaction ve yetki sınırıİş kuralıRemoveOrderItemHandler
Domain serviceHiçbir entity'ye ya da value object'e doğal olarak ait olmayan bir domain hesabıVeritabanı, HTTP (Hypertext Transfer Protocol), transactionShippingFeeCalculator
Infrastructure serviceDış dünyayla teknik konuşmaİş kuralıE-posta gönderen adapter, kargo API'sinin (Application Programming Interface) client'ı

Evans iyi bir domain service için üç özellik sayar: işlem bir domain kavramıyla ilgilidir ama bir entity'nin ya da value object'in doğal parçası değildir; interface'i domain model'in diğer elemanlarıyla tanımlanır (burada OrderItem, Address, Money); işlem stateless'tır. Üçüncüsü "hiç field'ı yok" demek değil; çağrılar arasında taşınan bir konuşma durumu yok demek. Değişmeyen bir configuration'ı constructor'dan almak bunu bozmaz.

Sıralama önemli: önce davranışın sahibi olan entity'yi ya da value object'i ararsın, bulamazsan domain service'e gidersin. Tersini yapıp her şeyi domain service'e koyarsan anemic modeli yeni bir isimle yeniden kurmuş olursun.

Davranışı Entity'ye Taşımak

Taşımayı üç küçük adımda yaptım ve her adımda açılıştaki senaryoyu yeniden çalıştırdım. Birinci adım hesaplanan değerleri hesaplanan yere koymak: Subtotal ve Total artık setter'ı olan field'lar değil, satırlardan türeyen property'ler; hiçbir service onları yanlış güncelleyemiyor. İkinci adım değişikliği yapan kodu sahibine vermek: RemoveItem ve "kargoya verildiyse satır çıkarılamaz" kuralı Order'a geçti, liste dışarıya salt okunur açıldı. Üçüncü adım, sahibi olmayan hesabı ayırmak; onu bir sonraki bölümde konuşacağız. Sonuç:

using Bookstore.SharedKernel;

namespace Bookstore.Ordering;

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

public sealed record OrderItem(BookId BookId, string Title, Money UnitPrice, int Quantity)
{
    public Money LineTotal => UnitPrice * Quantity;
}

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

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

public sealed class Order
{
    private readonly List<OrderItem> _items;

    private Order(OrderId id, CustomerId customerId, Address shippingAddress, List<OrderItem> items)
    {
        Id = id;
        CustomerId = customerId;
        ShippingAddress = shippingAddress;
        _items = items;
    }

    public OrderId Id { get; }
    public CustomerId CustomerId { get; }
    public Address ShippingAddress { get; }
    public OrderStatus Status { get; private set; } = OrderStatus.Placed;
    public IReadOnlyList<OrderItem> Items => _items;
    public Money ShippingFee { get; private set; }
    public Money Subtotal => _items.Aggregate(Money.Zero(_items[0].UnitPrice.Currency), (sum, i) => sum + i.LineTotal);
    public Money Total => Subtotal + ShippingFee;

    // Factory: geçersiz bir Order hiç oluşmuyor (08. yazı)
    public static Order Place(CustomerId customerId, Address shippingAddress,
        IEnumerable<OrderItem> items, ShippingFeeCalculator shipping)
    {
        List<OrderItem> list = [.. items];
        if (list.Count == 0)
            throw new DomainException("order.empty", "An order needs at least one item.");

        Order order = new(OrderId.New(), customerId, shippingAddress, list);
        order.ShippingFee = shipping.Calculate(order.Items, shippingAddress);
        return order;
    }

    // Domain service parametre olarak geliyor; Order onu saklamıyor
    public void RemoveItem(BookId bookId, ShippingFeeCalculator shipping)
    {
        if (Status is not (OrderStatus.Placed or OrderStatus.PaymentPending))
            throw new DomainException("order.not-editable", $"Order is {Status}; items cannot be removed.");
        if (_items.Count == 1)
            throw new DomainException("order.last-item", "Cannot remove the last item; cancel the order instead.");

        _items.RemoveAll(i => i.BookId == bookId);
        ShippingFee = shipping.Calculate(_items, ShippingAddress);
    }

    public void MarkShipped()
    {
        if (Status is OrderStatus.Cancelled)
            throw new DomainException("order.cancelled", "A cancelled order cannot be shipped.");
        Status = OrderStatus.Shipped;
    }
}

Status artık bir sayı değil, OrderStatus enum'u ve setter'ı private; durum yalnızca MarkShipped gibi anlamı olan method'larla değişiyor. Satır listesi IReadOnlyList<OrderItem>; dışarıdan Add ya da RemoveAll çağrılamıyor. Son satırın çıkarılması da yasak: müşteri her şeyi istemiyorsa bu bir iptal, "boş sipariş" değil. Kural ihlalleri, serinin 08. yazısındaki hata modeline uygun olarak kodu olan bir DomainException fırlatıyor. Bu kural anemic modelde hiçbir yerde yoktu; davranışı entity'ye taşıyınca, "hangi durumda bu method çağrılabilir" sorusunu sormak zorunda kaldık ve kural kendiliğinden ortaya çıktı. Invariant'ları constructor, factory ve guard'larla koruma tekniklerini serinin 08. yazısında, aggregate sınırını 09. yazısında ayrıntılı ele almıştık.

Entity'ye Ait Olmayan Davranış: ShippingFeeCalculator

Kargo ücretini Order'ın içine bir method olarak da yazabilirdik. Yazmadık, çünkü hesabın girdilerinin çoğu siparişe ait değil. Fiyat tablosu kargo firmasıyla yapılan anlaşmadan geliyor ve her çeyrek değişebiliyor. "Ücretsiz kargo" eşiği pazarlamanın kararı ve kampanya dönemlerinde değişiyor. Bölge (yurt içi ya da yurt dışı) adresten çıkıyor. Order yalnızca satırları biliyor. Hesabı Order'a koyarsak sipariş, kargo anlaşmasının ve kampanya takviminin detaylarını öğrenmek zorunda kalır; Address'e koymak daha da anlamsız. Bu, Evans'ın birinci ölçütünün ders kitabı örneği:

using Bookstore.SharedKernel;

namespace Bookstore.Ordering;

public enum ShippingZone { Domestic, International }

// Kargo firmasıyla yapılan anlaşma: her çeyrek değişebilen fiyat tablosu (value object)
public sealed record ShippingRateTable(Money DomesticBase, Money InternationalBase, Money PerExtraBook)
{
    public Money BaseFor(ShippingZone zone) => zone == ShippingZone.Domestic ? DomesticBase : InternationalBase;
}

// Policy: "ücretsiz kargo" kuralı kampanyaya göre değişiyor
public interface IFreeShippingPolicy
{
    bool Qualifies(Money subtotal, ShippingZone zone);
}

public sealed record ThresholdFreeShipping(Money Threshold) : IFreeShippingPolicy
{
    public bool Qualifies(Money subtotal, ShippingZone zone) =>
        zone == ShippingZone.Domestic && subtotal.IsAtLeast(Threshold);
}

public sealed class NoFreeShipping : IFreeShippingPolicy
{
    public bool Qualifies(Money subtotal, ShippingZone zone) => false;
}

// Domain service: hesap Order'a da adrese de ait değil; durum tutmuyor, I/O yapmıyor
public sealed class ShippingFeeCalculator(ShippingRateTable rates, IFreeShippingPolicy freeShipping)
{
    public Money Calculate(IReadOnlyCollection<OrderItem> items, Address destination)
    {
        ShippingZone zone = destination.Country == "TR" ? ShippingZone.Domestic : ShippingZone.International;
        Money subtotal = items.Aggregate(Money.Zero(rates.DomesticBase.Currency), (sum, i) => sum + i.LineTotal);
        if (freeShipping.Qualifies(subtotal, zone))
            return Money.Zero(subtotal.Currency);

        int books = items.Sum(i => i.Quantity);
        return rates.BaseFor(zone) + rates.PerExtraBook * (books - 1);
    }
}

ShippingFeeCalculator veritabanına, HTTP'ye, saate dokunmuyor. Fiyat tablosunu bir value object olarak, kampanya kuralını bir policy olarak constructor'dan alıyor ve aynı girdiye her zaman aynı cevabı veriyor. Bu yüzden testi tek satırlık bir setup ile yazılıyor; ne mock ne veritabanı:

using Bookstore.Ordering;
using Bookstore.SharedKernel;

namespace Bookstore.Ordering.Tests;

public sealed class ShippingFeeCalculatorTests
{
    private static readonly ShippingRateTable Rates =
        new(new Money(4.90m, "EUR"), new Money(12.90m, "EUR"), new Money(0.50m, "EUR"));

    private static readonly Address Izmir = new("Kordon 12", "Izmir", "35220", "TR");
    private static readonly Address Berlin = new("Unter den Linden 5", "Berlin", "10115", "DE");

    private static OrderItem Book(decimal price, int quantity = 1) =>
        new(BookId.New(), "Dune", new Money(price, "EUR"), quantity);

    [Theory]
    [InlineData(49.99, 4.90)]
    [InlineData(50.00, 0.00)]
    public void Domestic_threshold_is_inclusive(decimal price, decimal expectedFee)
    {
        ShippingFeeCalculator calculator = new(Rates, new ThresholdFreeShipping(new Money(50m, "EUR")));

        Money fee = calculator.Calculate([Book(price)], Izmir);

        Assert.Equal(new Money(expectedFee, "EUR"), fee);
    }

    [Fact]
    public void International_orders_pay_base_plus_extra_books()
    {
        ShippingFeeCalculator calculator = new(Rates, new ThresholdFreeShipping(new Money(50m, "EUR")));

        Money fee = calculator.Calculate([Book(30m, 3)], Berlin);

        Assert.Equal(new Money(13.90m, "EUR"), fee);
    }

    [Fact]
    public void Campaign_policy_can_turn_free_shipping_off()
    {
        ShippingFeeCalculator calculator = new(Rates, new NoFreeShipping());

        Money fee = calculator.Calculate([Book(80m)], Izmir);

        Assert.Equal(new Money(4.90m, "EUR"), fee);
    }
}
Passed!  - Failed:     0, Passed:     4, Skipped:     0, Total:     4, Duration: 8 ms - Bookstore.P11.Tests.dll (net10.0)

Açılıştaki >/>= karışıklığı artık bir test adıyla belgelenmiş durumda: eşik dahil. Bu kural kodda bir kez yazıldığı için müşteri hizmetleri ekranı, checkout ve ileride gelecek iade akışı aynı cevabı vermek zorunda.

.NET'in kendi kütüphanelerinde de aynı şekli görürsün. ASP.NET Core Identity'deki IPasswordHasher<TUser>, HashPassword(TUser user, string password) ve VerifyHashedPassword(...) method'larıyla bir kullanıcı üzerinde çalışan ama kullanıcı class'ına ait olmayan bir işlem sunar; hash algoritması ve iterasyon sayısı IdentityUser'ın sorumluluğu değildir. TimeZoneInfo.ConvertTime(DateTimeOffset, TimeZoneInfo) da iki değer arasında çalışan, ikisine de ait olmayan stateless bir işlemdir. İkisi de aynı soruya aynı cevabı verir: bu davranışın doğal bir sahibi yok, ama domain dilinde bir adı var.

Policy ve Factory Object'leri

IFreeShippingPolicy Evans'ın Policy dediği şey; GoF (Gang of Four: Gamma, Helm, Johnson ve Vlissides'in 1994 tarihli Design Patterns kitabı) kataloğundaki Strategy pattern'in domain dilindeki adı. Okul açılışı kampanyasında pazarlama "yurt içinde 30 EUR üzeri ücretsiz" derse yeni bir ThresholdFreeShipping(new Money(30m, "EUR")) instance'ı yeterli; maliyet hesabı için kampanyayı geçici olarak kapatmak gerekirse NoFreeShipping. Hesabın iskeleti değişmeden kuralın değişen parçası takılıp çıkarılıyor. Strategy'nin .NET'teki çeşitli biçimleri (keyed service, IEnumerable<IStrategy>, Func) Design Patterns serisinin "Strategy Pattern Derinlemesine" yazısında; BCL'deki (Base Class Library) en tanıdık policy object ise Dictionary<string, T>'nin constructor'ına verdiğin StringComparer.OrdinalIgnoreCase: dictionary'nin "iki key ne zaman aynıdır" kuralı dışarıdan takılıyor.

Factory tarafında Order.Place bir static factory method. Evans, karmaşık bir aggregate'i oluşturma bilgisinin ne client'ta ne de aggregate'in constructor'ında dağınık durmasını, tek bir yerde toplanmasını önerir; oluşturma kuralı aggregate'in kendisine aitse en doğal yer root üzerindeki bir factory method'dur. Oluşturma birden fazla aggregate'ten bilgi topluyorsa (sepet, müşteri, kampanya) ayrı bir factory class'ı da düşünülebilir; ama o da bir domain kavramı olarak kalmalı, repository çağırmamalı.

Order.Place ve RemoveItem'ın domain service'i parametre olarak aldığına dikkat et. Vaughn Vernon Implementing Domain-Driven Design (2013) kitabında aggregate'e repository ya da domain service inject etmeyi zararlı sayar; bağımlılığı command method'unu çağıran tarafın hazırlayıp vermesini önerir. Burada da öyle: entity service'i field olarak saklamıyor, DI (dependency injection) container'ını tanımıyor; ihtiyacı olduğu an çağıran ona veriyor. Alternatifi, application service'in ücreti hesaplayıp order.SetShippingFee(fee) çağırması. Bu daha az "zekice" görünür ama public bir setter'ı geri getirir: herkes istediği ücreti yazabilir ve kural yine service'e kaçar. Parametre tekniğinde kural "satır değişince kargo yeniden hesaplanır" Order'da kalıyor ve unutulamıyor.

Application Service: Yükle, Devret, Kaydet

Geriye kalan, use case'i dış dünyaya bağlayan ince layer:

using Bookstore.Ordering;

namespace Bookstore.Ordering.Application;

public interface IOrderRepository
{
    Task<Order?> FindAsync(OrderId id, CancellationToken ct);
    void Add(Order order);
}

public interface IShippingTermsProvider
{
    Task<(ShippingRateTable Rates, IFreeShippingPolicy FreeShipping)> GetCurrentAsync(CancellationToken ct);
}

public interface IUnitOfWork
{
    Task SaveChangesAsync(CancellationToken ct);
}

public sealed record RemoveOrderItem(OrderId OrderId, BookId BookId);

// Application service: yükle, domain'e devret, kaydet. İş kuralı yok.
public sealed class RemoveOrderItemHandler(
    IOrderRepository orders, IShippingTermsProvider shippingTerms, IUnitOfWork unitOfWork)
{
    public async Task HandleAsync(RemoveOrderItem command, CancellationToken ct)
    {
        Order order = await orders.FindAsync(command.OrderId, ct)
            ?? throw new KeyNotFoundException($"Order {command.OrderId.Value} not found.");

        (ShippingRateTable rates, IFreeShippingPolicy freeShipping) = await shippingTerms.GetCurrentAsync(ct);
        order.RemoveItem(command.BookId, new ShippingFeeCalculator(rates, freeShipping));

        await unitOfWork.SaveChangesAsync(ct);
    }
}

Handler'ın içinde tek bir if yok. Siparişi yüklüyor, güncel kargo koşullarını bir port'tan alıyor (veritabanından mı configuration'dan mı geldiğini bilmiyor), işi Order'a devrediyor ve kaydediyor. Transaction sınırı burada: bir use case, bir SaveChangesAsync. Bu handler şeklinin kendisi, validation'ın nerede yapıldığı ve MediatR'sız decorator'lar Clean, Onion ve Hexagonal Architecture serisinin "Application Layer ve Use Case'ler: MediatR Olmadan Handler, Validation ve Transaction Sınırı" yazısında anlatıldı; DDD'nin bu tabloya kattığı tek şey, handler'ın içinde iş kuralı aramamak gerektiği.

Açılıştaki senaryonun zengin modelle çalışan hâli:

ShippingRateTable rates = new(new Money(4.90m, "EUR"), new Money(12.90m, "EUR"), new Money(0.50m, "EUR"));
IFreeShippingPolicy standard = new ThresholdFreeShipping(new Money(50m, "EUR"));
InMemoryOrders store = new();
FixedShippingTerms terms = new(rates, standard);

Order order = Order.Place(CustomerId.New(), new Address("Kordon 12", "Izmir", "35220", "TR"),
[
    new OrderItem(new BookId(dune), "Dune", new Money(30m, "EUR"), 1),
    new OrderItem(new BookId(emma), "Emma", new Money(20m, "EUR"), 1),
    new OrderItem(new BookId(hobbit), "The Hobbit", new Money(15m, "EUR"), 1),
], new ShippingFeeCalculator(rates, standard));
store.Add(order);
Console.WriteLine($"Placed:       shipping {order.ShippingFee}, total {order.Total}");

RemoveOrderItemHandler handler = new(store, terms, store);
await handler.HandleAsync(new RemoveOrderItem(order.Id, new BookId(hobbit)), CancellationToken.None);
Console.WriteLine($"Item removed: shipping {order.ShippingFee}, total {order.Total}, saves {store.Saves}");

Order abroad = Order.Place(CustomerId.New(), new Address("Unter den Linden 5", "Berlin", "10115", "DE"),
    [new OrderItem(new BookId(dune), "Dune", new Money(30m, "EUR"), 3)], new ShippingFeeCalculator(rates, standard));
Console.WriteLine($"International: shipping {abroad.ShippingFee}, total {abroad.Total}");

order.MarkShipped();
try
{
    await handler.HandleAsync(new RemoveOrderItem(order.Id, new BookId(emma)), CancellationToken.None);
}
catch (DomainException ex)
{
    Console.WriteLine($"After shipping: {ex.Message} (saves {store.Saves})");
}
Placed:       shipping 0.00 EUR, total 65.00 EUR
Item removed: shipping 0.00 EUR, total 50.00 EUR, saves 1
International: shipping 13.90 EUR, total 103.90 EUR
After shipping: Order is Shipped; items cannot be removed. (saves 1)

The Hobbit çıkarılınca sepet 50 EUR'ya iniyor ve kargo ücretsiz kalıyor, çünkü checkout'la aynı hesap çalışıyor. Berlin'e üç Dune 12,90 + 2 × 0,50 = 13,90 EUR kargo ödüyor. Kargoya verilmiş siparişten kitap çıkarma denemesi Order'ın içinde durduruluyor ve SaveChangesAsync ikinci kez çağrılmıyor; sayaç 1'de kalıyor.

Sık Düşülen Tuzaklar

Domain service'e repository ve HttpClient inject etmek

En yaygın hata, "nasılsa service" diyerek domain service'i dış dünyaya bağlamak:

using System.Net.Http.Json;
using Bookstore.SharedKernel;

namespace Bookstore.Ordering.Bad;

public interface IShippingRateRepository
{
    Task<ShippingRateTable> GetCurrentAsync(CancellationToken ct);
}

// Tuzak: domain service'in içine repository ve HttpClient girmiş
public sealed class ShippingFeeCalculator(IShippingRateRepository rates, HttpClient carrierApi)
{
    public async Task<Money> CalculateAsync(IReadOnlyCollection<OrderItem> items, Address destination, CancellationToken ct)
    {
        ShippingRateTable table = await rates.GetCurrentAsync(ct);
        using HttpResponseMessage response = await carrierApi.GetAsync($"surcharges/{destination.PostalCode}", ct);
        decimal surcharge = await response.Content.ReadFromJsonAsync<decimal>(ct);
        int books = items.Sum(i => i.Quantity);
        return table.DomesticBase + table.PerExtraBook * (books - 1) + new Money(surcharge, "EUR");
    }
}

Derleniyor, çalışıyor, ama bedeli ağır. Hesap artık async; onu çağıran Order.RemoveItem da async olmak ve bir CancellationToken taşımak zorunda, yani entity'nin içine I/O girdi. Aynı sepet için cevap ağın durumuna bağlı; testi için bir fake repository ve sahte bir HttpMessageHandler gerekiyor. Kargo API'si yavaşladığında sipariş düzenleme ekranı da yavaşlıyor. Doğru yol, veriyi application service'te toplayıp domain service'e değer olarak vermek; dış API'yi çağıran kod bir infrastructure adapter'ı olarak kalır.

Application service'te iş kuralı yazmak

Handler'da if (order.Items.Sum(...) >= 50) gibi bir satır görüyorsan, açılıştaki iki service'in hikâyesi yeniden başlamıştır. Pratik bir test: aynı kararı bir console komutundan ya da background job'dan da vermen gerekse, bu satırı kopyalamak zorunda kalır mıydın? Kalıyorsan kural yanlış yerde.

Her şeyi domain service'e taşımak

OrderDomainService.Cancel(order), OrderDomainService.AddItem(order, ...) gibi method'lar anemic modelin aynısı; yalnızca service'in namespace'i değişti. Davranışın tek bir entity'nin state'ini değiştirdiği her yerde method o entity'de olmalı. Domain service, sahibi gerçekten olmayan hesaplar için istisnadır, kural değildir.

Manager, Helper, Util

OrderManager ya da PricingHelper gibi isimler, class'ın ne yaptığının belirsiz olduğunun işareti. Domain service'in adı domain dilinden gelir ve domain expert'in tanıyacağı bir işi anlatır: ShippingFeeCalculator, "kargo ücreti hesabı". Adını koyamıyorsan muhtemelen kavramı henüz bulamamışsındır.

Ne Zaman Kullanma

Bütün bu ayrım, kuralların gerçekten var olduğu yerde değer üretir. Kitap kategorisi ekleyen bir admin ekranı, bir raporlama sorgusu ya da gerçekten yalnızca CRUD (Create, Read, Update, Delete) yapan bir form için Transaction Script yeterli ve dürüst bir seçimdir; Fowler da PoEAA'da basit mantık için onu önerir. Anemic model, model gibi görünüp kural taşıyamadığında bir sorundur. Hiç kural olmayan yerde zengin model kurmak ise kendi başına bir maliyettir: Bookstore'da Catalog'un kategori yönetimi öyle bir yer, Ordering değil. Hangi alana bu yatırımın yapılacağı sorusunu serinin 03. yazısında core, supporting ve generic subdomain ayrımıyla cevaplamıştık.

Küçük Bir Deneme

★☆☆ ShippingFeeCalculatorTests'e, satır listesinde iki farklı para birimi olan bir senaryo ekle. Hangi tip, hangi mesajla exception fırlatıyor? Bu kontrolün ShippingFeeCalculator'da değil Money'de olması neden daha iyi?

★★☆ Pazarlama yeni bir kural getirdi: "Okul açılışı kampanyasında (1–15 Eylül) yurt içi ücretsiz kargo eşiği 30 EUR." Bunu bir IFreeShippingPolicy implementation'ı olarak yaz. Tarihi nereden alacaksın? DateTime.UtcNow yerine TimeProvider'ı kim, nerede verecek?

★★★ BadCalculator.cs'teki surcharge çağrısını kaldırmadan kuralı korumak zorundasın: kargo firması bazı posta kodları için ek ücret istiyor. Ek ücret listesini application service'te önceden yükleyip ShippingRateTable'a eklemenin, onu bir infrastructure adapter'ı arkasında bırakmaya göre artısını ve eksisini yaz.

Kargo ücreti artık tek bir yerde hesaplanıyor, ama sipariş iptal edildiğinde stoğun geri bırakılması, onay e-postası ya da kargo firmasına haber verilmesi hâlâ birinin hatırlamasına bağlı. Bir sonraki yazıda bunları aggregate'lerin içinden domain event olarak yayıp Entity Framework Core (EF Core) 10'un SaveChanges'ında nasıl dispatch edeceğimizi göreceğiz.