Domain-Driven Design Nedir? Ne Zaman Değer, Ne Zaman Gereksiz?
DDD, karmaşıklığın teknolojide değil iş kurallarında olduğunu kabul edip modeli domain uzmanıyla birlikte koda taşıyan bir yaklaşımdır; kuralları sık değişen core domain'de değer üretir, CRUD ekranlarında ve kısa ömürlü MVP'lerde gereksizdir. Kampanya indirimi hatasıyla CRUD ve domain modelinin farkı, strategic ve tactical design ayrımı, eShop'un Ordering.Domain'i ve serinin yol haritası.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'da okul açılışı kampanyası başladı. Kampanya sorumlusunun kuralı tek cümle: aynı siparişte 3 veya daha fazla kitap alan müşteriye %10 indirim. Ada sepetine Dune (20 EUR), Emma (10 EUR) ve The Hobbit'i (15 EUR) koyuyor; ara toplam 45, indirimle 40,50 EUR. Ertesi gün fikrini değiştiriyor ve Emma'yı siparişten çıkarıyor.
Sipariş ekranı klasik bir CRUD (Create, Read, Update, Delete) ekranı: arayüz kalan satırları indirimli birim fiyatlarıyla PUT /orders/1 isteğine koyuyor, backend de gelen veriyi olduğu gibi kaydediyor. İki soru: kalan iki kitap için sistem hangi tutarı kaydeder, doğru tutar nedir? Bir de, sipariş kargoya verilmişken müşteri hizmetlerinden biri "İptal" düğmesine basarsa ne olur? Cevabını aklında tut; birazdan iki farklı modelle çalıştırıp göreceğiz.
Kodda Gör: CRUD Modeli ve Domain Modeli
Önce bugünkü hâl. Entity'ler veritabanı tablosunun aynası, iş kuralı yok; service, client'tan geleni yazıyor:
namespace Bookstore.Crud;
// İşin değil, tablonun şekli
public class Order
{
public int Id { get; set; }
public string Status { get; set; } = "Placed";
public List<OrderItem> Items { get; set; } = [];
public decimal Total { get; set; }
}
public class OrderItem
{
public string Title { get; set; } = "";
public decimal UnitPrice { get; set; }
public int Quantity { get; set; }
}
public record OrderDto(string Status, List<OrderItem> Items);
public class OrderService
{
// PUT /orders/{id}: client ne gönderirse olduğu gibi yazılır
public void Update(Order order, OrderDto dto)
{
order.Status = dto.Status;
order.Items = dto.Items;
order.Total = dto.Items.Sum(i => i.UnitPrice * i.Quantity);
}
}
Şimdi aynı siparişi, kuralın kendisini taşıyan bir model ile yazalım. Bu serinin ilerleyen yazılarında bu modeli parça parça inceleyeceğiz; şimdilik yalnız şuna bak: indirim kuralı ve "kargodaki sipariş iptal edilmez" kuralı Order class'ının içinde.
namespace Bookstore.Ordering;
public enum OrderStatus { Placed, Paid, Shipped, Cancelled }
public sealed record OrderItem(string Title, decimal ListPrice, int Quantity);
public sealed class Order
{
// Okul açılışı kampanyası: siparişte 3 veya daha fazla kitap varsa %10 indirim
private const int CampaignMinBooks = 3;
private const decimal CampaignDiscount = 0.10m;
private readonly List<OrderItem> _items;
public Order(IEnumerable<OrderItem> items) => _items = [.. items];
public OrderStatus Status { get; private set; } = OrderStatus.Placed;
public IReadOnlyList<OrderItem> Items => _items;
public decimal Total
{
get
{
decimal subtotal = _items.Sum(i => i.ListPrice * i.Quantity);
int books = _items.Sum(i => i.Quantity);
return books >= CampaignMinBooks ? subtotal * (1 - CampaignDiscount) : subtotal;
}
}
public void MarkShipped() => Status = OrderStatus.Shipped;
public void CancelItem(string title)
{
if (Status is OrderStatus.Shipped)
throw new InvalidOperationException("A shipped order cannot be cancelled; request a return instead.");
_items.RemoveAll(i => i.Title == title);
if (_items.Count == 0)
Status = OrderStatus.Cancelled;
}
}
İki modeli aynı senaryoyla çalıştıralım:
using System.Globalization;
using Crud = Bookstore.Crud;
using Bookstore.Ordering;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// 1) CRUD: indirimli fiyatları arayüz hesapladı, sonra Emma'yı çıkardı
Crud.Order crudOrder = new() { Id = 1 };
Crud.OrderService service = new();
service.Update(crudOrder, new("Placed",
[
new() { Title = "Dune", UnitPrice = 18.00m, Quantity = 1 },
new() { Title = "The Hobbit", UnitPrice = 13.50m, Quantity = 1 },
]));
Console.WriteLine($"CRUD total after cancelling Emma: {crudOrder.Total:0.00}");
service.Update(crudOrder, new("Shipped", crudOrder.Items));
service.Update(crudOrder, new("Cancelled", crudOrder.Items));
Console.WriteLine($"CRUD status of a shipped order: {crudOrder.Status}");
// 2) Domain model: kural Order'ın içinde
Order order = new(
[
new("Dune", 20.00m, 1),
new("Emma", 10.00m, 1),
new("The Hobbit", 15.00m, 1),
]);
Console.WriteLine($"Domain total with 3 books: {order.Total:0.00}");
order.CancelItem("Emma");
Console.WriteLine($"Domain total after cancelling Emma: {order.Total:0.00}");
order.MarkShipped();
try
{
order.CancelItem("Dune");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Domain shipped order: {ex.Message}");
}
CRUD total after cancelling Emma: 31.50
CRUD status of a shipped order: Cancelled
Domain total with 3 books: 40.50
Domain total after cancelling Emma: 35.00
Domain shipped order: A shipped order cannot be cancelled; request a return instead.
CRUD tarafı 31,50 EUR kaydetti. Doğru tutar 35,00: iki kitapla kampanya şartı artık sağlanmıyor, indirim de gitmeli. Ada 3,50 EUR fazladan indirim aldı ve kodun hiçbir yeri bunu fark etmedi, çünkü kural kodda değil, arayüzü yazan developer'ın aklındaydı. Kargodaki sipariş de sessizce Cancelled oldu; oysa iş tarafında bunun adı iptal değil, iade. Kitap depodan çıkmış, kargo firması parasını almış, muhasebe başka bir süreç işletecek.
Domain modelinde iki kural da tek yerde ve sipariş üzerinden yapılan her değişiklik o kurallardan geçiyor. Fark daha az satır kod değil; fark, kuralın kimin aklında yaşadığı. Domain-Driven Design'ın derdi tam olarak bu.
Evans'ın Çıkış Noktası: Karmaşıklık İş Kurallarında
DDD (Domain-Driven Design) adı, Eric Evans'ın Ağustos 2003'te Addison-Wesley'den çıkan Domain-Driven Design: Tackling Complexity in the Heart of Software kitabından geliyor. Kapağının renginden dolayı topluluk ona "Blue Book" diyor. Alt başlık tezi özetliyor: kurumsal yazılımda asıl karmaşıklık framework seçiminde, veritabanında ya da deployment'ta değil, işin kendisinde. Teknik problemler zor olabilir ama çoğunun çözümü bellidir; kampanya, iptal, iade ve stok kurallarının birbirine nasıl dokunduğu ise her şirkette farklıdır ve yanlış anlaşıldığında para kaybettirir.
Evans 2015'te kitaptaki pattern'lerin kısa tanımlarını DDD Reference adıyla Creative Commons lisansıyla (CC BY 4.0) yayımladı. Reference, DDD'yi üç maddeyle tanımlıyor: core domain'e odaklan; modelleri domain uzmanlarıyla yazılımcıların yaratıcı iş birliği içinde keşfet; açıkça sınırlanmış bir context içinde ubiquitous language konuş. Bu üç maddenin hiçbirinde class, repository ya da veritabanı geçmiyor.
Reference'taki iki tanım da önemli. Domain, kullanıcının yazılımı uyguladığı bilgi, etki ya da faaliyet alanı: Bookstore için online kitap satışı. Model ise o domain'in seçilmiş yönlerini anlatan ve o domain'deki problemleri çözmek için kullanılabilen bir soyutlamalar sistemi. Model bir diyagram değil, veritabanı şeması da değil. Evans'ın Model-Driven Design pattern'inde kod, modelin ifadesi: kodu değiştirdiğinde modeli değiştirmiş olursun, modeli değiştirdiğinde kodu. Yukarıdaki Order.Total property'si modelin bir parçası; kampanya kuralı değişirse orası değişir.
Strategic Design ve Tactical Design
DDD iki seviyede çalışır. Strategic design büyük resmi kurar: hangi parçaya ne kadar yatırım yapılacağını, modellerin nerede başlayıp nerede bittiğini, ekiplerin ve sistemlerin birbiriyle nasıl konuşacağını belirler. Tactical design ise tek bir sınırın içinde modeli koda dökmenin yapı taşlarını verir.
| Soru | Strategic design | Tactical design |
|---|---|---|
| Neye bakar? | Tüm sistem, ekipler, dış sistemler | Tek bir bounded context'in içi |
| Temel kavramlar | Subdomain, ubiquitous language, bounded context, context map | Entity, value object, aggregate, domain service, domain event, repository, factory |
| Cevapladığı soru | Nereye yatırım yapalım, sınırlar nereden geçsin? | Bu kural hangi object'te yaşasın, tutarlılık nerede korunsun? |
| Yanlış yapınca | Yanlış yere en iyi ekibi koyarsın, modeller birbirine karışır | Kural service'lere dağılır, invariant'lar delinir |
| Bu serideki yazılar | 02–06 | 07–13 |

Görseldeki ayrım da önemli. Problem space işin kendisidir: domain, subdomain'ler ve bu bilgiyi taşıyan domain expert'ler. Bunları sen tasarlamazsın, keşfedersin. Solution space ise yazılımın cevabıdır: dil, bounded context'ler, context map ve sınırın içindeki tactical model. Bunları tasarlarsın ve tasarım sırasında edindiğin yeni içgörü, problemi anlama biçimini de değiştirir.
En yaygın yanılgı DDD'yi bir pattern kataloğu sanmak: bir Entity base class'ı, bir generic repository, bir Domain klasörü ve iş bitti. Reference, pattern'leri altı bölümde topluyor ve tactical yapı taşları bunlardan yalnız biri. Hiç Repository class'ı olmayan bir proje DDD'nin özüne sadık olabilir; her class'ı AggregateRoot'tan türeyen bir proje ise kampanya kuralını hâlâ arayüzde hesaplıyor olabilir. Evans'ın kendisi de Reference'ta taktik ve stratejinin birlikte uygulanması gerektiğini vurguluyor.
Domain Expert ile Birlikte Çalışmak
DDD'nin ikinci maddesi, modeli domain uzmanıyla birlikte keşfetmek. Domain expert unvan değil, roldür: işin kurallarını en iyi bilen ve "hayır, öyle değil" diyebilen kişi. Bookstore'da kampanya sorumlusu fiyat ve indirim kurallarını, müşteri hizmetleri iptal ve iade süreçlerini, depo sorumlusu stok ve rezervasyonu bilir. Hiçbiri developer değil ve hiçbirinin bilgisi bir ticket'a sığmıyor.
Evans bu süreci knowledge crunching diye anlatır: developer'lar ve uzmanlar birlikte konuşur, örnek senaryolar üzerinden modeli dener, eksik ya da çelişkili yerleri bulur ve modeli yeniler. Reference'taki Hands-on Modelers pattern'i de modeli kuran kişinin koda dokunması gerektiğini söyler; modeli çizip koda geçirmeyi başkasına bırakan bir ayrım, öğrenilenin yolda kaybolmasına yol açar.
Pratikte bu şöyle bir konuşmadır: "Ada üç kitap aldı, kampanyadan yararlandı, sonra birini iptal etti. Kalan iki kitap için ne ödemeli?" Kampanya sorumlusu "35 EUR, indirim düşer" der. Müşteri hizmetleri ekler: "Kargoya verildiyse iptal yok, iade talebi açarız." Bu iki cümle, yukarıdaki Order class'ının iki kuralı oldu. Bookstore'daki 3,50 EUR'luk hatayı da test'ler değil, müşteri hizmetleri yakaladı; kural konuşmalarda yaşıyordu, kodda değil. Serinin altıncı yazısında bu konuşmaları sistemli hâle getiren EventStorming'i göreceğiz.
.NET Dünyasında Bir Örnek: eShop'un Ordering.Domain'i
Microsoft'un referans uygulaması dotnet/eShop, DDD'nin seçici uygulanmasına iyi bir örnek. Uygulamada Catalog.API, Basket.API, Ordering.API, Identity.API gibi service'ler var, ama ayrı bir domain project'i yalnız sipariş tarafında: Ordering.Domain. İçindeki AggregatesModel/OrderAggregate klasöründe Order, OrderItem, Address, OrderStatus ve IOrderRepository duruyor. Order satırlarını private bir List<OrderItem> field'ında tutuyor ve kaynak kodundaki yorum bunun aggregate encapsulation'ı için tercih edildiğini açıkça söylüyor. Catalog tarafı ise basit bir model ve CRUD endpoint'leriyle yetiniyor.
Bu bir tutarsızlık değil, bir karar: zengin model yalnız kuralların yoğun olduğu yerde. eShop'un sipariş modelinde bile dikkat çekici bir şey var: method adları SetShippedStatus(), SetCancelledStatus() gibi. Kural method'un içinde korunuyor, ama ad bir setter gibi okunuyor; iş tarafı "durumu iptal olarak ayarla" demez, "siparişi iptal et" der. Bir sonraki yazının konusu tam olarak bu fark.
Ne Zaman Değer?
DDD'nin bedeli var: domain uzmanının zamanı, modelleme için harcanan saatler, daha fazla tip ve ekibin öğrenme eğrisi. Bu bedel şu koşullarda geri döner:
- İş kuralları karmaşık ve birbirine dokunuyor: kampanya, iptal ve iade aynı siparişte kesişiyor.
- Kurallar sık değişiyor: her kampanya döneminde yeni bir indirim türü geliyor.
- Bu kurallar şirketin rekabet farkı: Bookstore'u rakiplerinden ayıran şey kampanya kurgusu ve esnek iade politikası.
- Domain uzmanlarına erişebiliyorsun ve ürün yıllarca yaşayacak.
Vlad Khononov, Learning Domain-Driven Design (O'Reilly, 2021) kitabında iş mantığını uygulama pattern'ini subdomain'in türüne bağlar: basit iş mantığı için transaction script ya da active record yeterlidir, karmaşık ve sık değişen core iş mantığı için domain model gerekir. Bookstore'da bu, Ordering ve kampanya fiyatlandırmasının zengin modelle, geri kalanının daha sade yollarla yazılması demek. Subdomain türlerini üçüncü yazıda ayrıntılı göreceğiz.
Ne Zaman Kullanma
Kategori yönetimi gibi formdan veritabanına giden ekranlarda tactical DDD fazladan tören üretir; bir validation ve bir SaveChanges yeter. Satış raporları gibi okuma ağırlıklı işler için aggregate yüklemek yerine doğrudan sorgu yazmak hem hızlı hem dürüsttür; Dapper serisindeki yaklaşım burada daha uygundur. Fikri doğrulamaya çalışan, birkaç hafta yaşayacak bir MVP (Minimum Viable Product) için de model yatırımı erkendir; önce işin gerçekten bu olduğunu öğren.
Domain uzmanına erişimin yoksa da dikkatli ol. O durumda kurduğun model, uzmanın değil developer'ların kafasındaki modeldir ve DDD'nin en değerli kısmı eksik kalır. Ödeme ve kimlik doğrulama gibi her şirkette aynı çözülen işlerde ise kendi modelini kurmak yerine hazır bir çözüm kullanmak çoğu zaman en doğru karardır. Şunu da ekleyelim: tactical pattern'leri kullanmadığın yerde bile strategic düşünce ucuzdur ve işe yarar. Sınırların nereden geçtiğini ve hangi kelimenin ne anlama geldiğini bilmek, CRUD bir modülde de değerlidir.
Bookstore'un Domain'i ve Serinin Yol Haritası
Bu seri, yayındaki C#, Entity Framework Core (EF Core) ve Mimariye Giriş serilerindeki Bookstore ile devam ediyor: kendi stoğunu satan bir online kitapçı. Mimariye Giriş serisinde parçaları Catalog, Ordering, Inventory, Payment, Shipping ve Notification olarak adlandırmıştık. Domain'in konuları kitap kataloğu, sepet ve sipariş, kampanya fiyatlandırması, iptal ve iade, stok ayırma, kargo ve müşteriye bildirim. Bu seride özellikle Ordering'e odaklanacağız, çünkü kuralların yoğunlaştığı yer orası.
Yol haritası şöyle: 02'de ubiquitous language ile kodun iş diliyle konuşmasını, 03'te subdomain'lerle yatırımın nereye yapılacağını, 04'te bounded context'lerle model sınırlarını, 05'te context mapping ile sınırlar arası ilişkileri, 06'da EventStorming ile domain'i keşfetmeyi ele alıyoruz. 07–11 tactical yapı taşları: entity ve value object, always-valid model, aggregate tasarımı, EF Core 10 ile persist etmek, domain service. 12'de domain event'ler, 13'te Ordering'i anemic CRUD'dan zengin modele uçtan uca refactor ediyoruz, 14'te DDD'nin evrimine ve yaygın yanlışlara bakıyoruz.
Seride olmayanlar da belli. Domain project'inin solution'daki yeri ve dependency yönü yayındaki Clean, Onion ve Hexagonal Architecture serisinin konusu. Event-sourced aggregate ilerideki CQRS (Command Query Responsibility Segregation) ve Event Sourcing serisinde, integration event'lerin güvenilir yayını ilerideki Messaging ve Event-Driven Architecture serisinde, bounded context'i module ya da service'e dönüştürmek ise ilerideki Modular Monolith ve Microservices serilerinde.
Sık Düşülen Tuzaklar
DDD'yi klasör yapısı sanmak. Domain/Entities, Domain/Repositories, Domain/Services klasörleri açmak DDD değildir; içleri public setter'lı property'lerle doluysa, eski CRUD modeli yeni klasörlere taşınmış demektir.
Her yere tactical DDD uygulamak. Kategori ekranına aggregate, value object ve domain event koymak, ekibin enerjisini kuralların gerçekten yoğun olduğu yerden çeker. Zengin model bir yatırım; yatırım her yere eşit yapılmaz.
Domain uzmanı olmadan modellemek. Developer'ların kendi aralarında kurduğu model, ilk gerçek iş kuralında çatlar. "Kampanya kuralını kim biliyor?" sorusunun cevabı bir kişi adı değilse, önce o kişiyi bul.
Modeli bir kere çizip dondurmak. Reference'taki Refactoring Toward Deeper Insight pattern'i, modelin iş anlaşıldıkça yeniden şekillenmesini bekler. İlk model hep eksiktir; DDD bu eksikliği kabul eden bir süreçtir, büyük bir ön tasarım değil.
DDD'yi microservices ile eşitlemek. Bounded context'ler service sınırlarını bulmaya yardım eder, ama tek bir monolith içinde de DDD yapılır. Bookstore bugün tek bir uygulama ve bu seride de öyle kalıyor.
Küçük Bir Deneme
★☆☆ Kendi projende son altı ayda en çok değişen beş dosyayı git log --name-only çıktısından say. Bu dosyalar iş kuralı mı taşıyor, yoksa altyapı mı? İş kuralı taşıyanlardaki kuralı ilk kim söyledi?
★★☆ Örnekteki Order'a "kampanya yalnız 1 Ağustos ile 30 Eylül arasında verilen siparişlerde geçerli" kuralını ekle. Sipariş tarihini nereden alırsın? DateTime.UtcNow yerine TimeProvider kullanıp kuralı tarih vererek test et.
★★★ Aynı iki kuralı CRUD tarafındaki OrderService.Update method'una if'lerle ekle. Sonra her iki versiyon için "kargodaki sipariş iptal edilemez" kuralını test eden birer unit test yaz. Hangisinde test daha az kurulum istiyor, hangisinde kuralı atlayan ikinci bir kod yolu açmak daha kolay?
Bu yazıda DDD'nin neden iş kurallarıyla başladığını ve nerede bedeline değdiğini gördük. Ama kural koda geçerken önce kelimelere dönüşür: "iptal", "iade", "durum". Bir sonraki yazıda ubiquitous language'e bakıyoruz: toplantıdaki dilin class ve method adlarına nasıl yansıdığını, "iade" kelimesinin neden iki ayrı kavram olduğunu ve Türkçe konuşup İngilizce kod yazan bir ekibin glossary'sini nasıl kuracağını.