in

Domain Driven Design: Bounded Context ve Aggregate Tasarımı

DDD çoğu projede 'entity ve repository adlandırması'na indirgenir; asıl değeri stratejik tasarımdadır. 'Müşteri' kelimesinin dört farklı anlam taşıdığı kurumsal sistemde bounded context'leri nasıl çizeceğinizi, aggregate kurallarını, değer nesnelerini ve domain olaylarını Spring Boot örnekleriyle anlatıyoruz.

Satış, muhasebe ve lojistik bounded context'leri arasındaki ilişkileri gösteren context map

Çoğu kurumsal projede "DDD uyguluyoruz" cümlesi, sınıflara Entity, Repository ve Service soneki vermek anlamına gelir. Domain Driven Design'ın asıl değeri ise oradan çok önce başlar: aynı şirkette "müşteri" kelimesinin satış, muhasebe, lojistik ve destek için dört farklı şey ifade ettiğini fark etmek ve yazılımı bu sınırlara göre bölmek. Bu stratejik karar verilmeden yapılan taktik DDD, karmaşık isimlendirmeyle sonuçlanır.

Bu yazıda stratejik tasarımı (ubiquitous language, bounded context, context map), aggregate tasarım kurallarını, değer nesnelerini ve domain olaylarını Spring Boot örnekleriyle ele alıyor; sık yapılan hataları ve DDD'nin ne zaman gerekmediğini anlatıyoruz.

Stratejik tasarım: önce sınırlar

Ubiquitous language: tek sözlük, her yerde

İş ekibi "sevkiyat", geliştirici "shipment", veritabanı "delivery" diyorsa üç ayrı model vardır ve her toplantı çeviriyle geçer. DDD'nin ilk kuralı, sınıf adlarından toplantı diline kadar tek bir sözlük kullanmaktır. Bir kavram için iki kelime varsa ya biri yanlıştır ya da iki farklı kavram vardır — ikinci durum genellikle bir sınır işaretidir.

Bounded context: "müşteri" nerede ne demek?

Satış context'i        Müşteri = potansiyel/aktif alıcı; kredi limiti, fiyat listesi, satış temsilcisi
Muhasebe context'i     Müşteri = cari hesap; vergi no, açık bakiye, vade, tahsilat geçmişi
Lojistik context'i     Müşteri = teslimat noktası; adres, çalışma saatleri, araç kısıtı
Destek context'i       Müşteri = sözleşme sahibi; SLA, açık talepler, iletişim tercihi

Dört context'i tek bir Musteri sınıfında birleştirmek, seksen alanlı, herkesin değiştirdiği ve kimsenin anlamadığı bir entity üretir. Her context kendi Musteri modelini tutar; ortak olan yalnızca kimliktir (MusteriId). Bounded context, bir modelin geçerli olduğu sınırdır ve kod tabanında bir modül ya da servis olarak somutlaşır.

Context map: sınırlar arası ilişki

İlişki

Anlamı

Örnek

Upstream / downstream

Biri modelini yayınlar, diğeri tüketir

Satış (upstream) sipariş olayını yayınlar; muhasebe (downstream) tüketir

Published language

Upstream'in kararlı, belgelenmiş sözleşmesi

SiparisOlusturuldu olayının şeması

Anti-corruption layer

Downstream, upstream modelini kendi diline çevirir

ERP'nin KUNNR'ı → MusteriId

Conformist

Downstream upstream modelini olduğu gibi kabul eder

Küçük bir raporlama modülünün satış modelini aynen kullanması

Shared kernel

İki context küçük bir modeli paylaşır

Para, MusteriId gibi ortak değer nesneleri

Context map çizilmeden yazılan sistemlerde tüm context'ler "shared kernel" olur: herkes her tabloyu kullanır. Haritanın amacı, hangi bağımlılığın bilinçli olduğunu görünür kılmaktır.

Taktik tasarım: aggregate kuralları

Aggregate, birlikte tutarlı kalması gereken nesnelerin bir kök (root) altında toplanmasıdır; dışarıdan yalnızca köke erişilir. Dört kural pratikte tasarımı belirler:

  1. Gerçek değişmezleri koru. "Sipariş toplamı kalemlerin toplamına eşittir", "onaylanan sipariş değiştirilemez" gibi kurallar aggregate'in içindedir; dışarıdan bozulamaz.

  2. Küçük tut. Aggregate bir transaction'da yüklenip yazılan şeydir; müşteri + tüm siparişleri + tüm faturaları tek aggregate olamaz.

  3. Diğer aggregate'lere kimlikle referans ver. Siparis, Musteri nesnesini değil MusteriId değerini tutar.

  4. Bir transaction, bir aggregate. İki aggregate'i aynı anda değiştirmek gerekiyorsa ya sınır yanlıştır ya da ikinci değişiklik bir domain olayıyla, sonradan (eventual consistency) yapılır.

public class Siparis {                                     // aggregate root
    private final SiparisNo no;
    private final MusteriId musteri;                       // kural 3: kimlikle referans
    private final List<Kalem> kalemler = new ArrayList<>();
    private Durum durum = Durum.TASLAK;
    private final List<Object> olaylar = new ArrayList<>();

    public void kalemEkle(UrunKodu urun, Miktar miktar, Para birimFiyat) {
        if (durum != Durum.TASLAK) throw new SiparisDegistirilemez(no);    // kural 1: değişmez
        kalemler.stream().filter(k -> k.urun().equals(urun)).findFirst()
            .ifPresentOrElse(k -> k.miktarArtir(miktar), () -> kalemler.add(new Kalem(urun, miktar, birimFiyat)));
    }

    public void onayla(KrediKarari kredi) {
        if (kalemler.isEmpty()) throw new BosSiparisOnaylanamaz(no);
        if (!kredi.uygun(toplam())) throw new KrediLimitiAsildi(no, toplam());
        durum = Durum.ONAYLANDI;
        olaylar.add(new SiparisOnaylandi(no, musteri, toplam()));            // diğer context'ler için
    }

    public Para toplam() { return kalemler.stream().map(Kalem::tutar).reduce(Para.SIFIR, Para::topla); }
    public List<Object> olaylariAl() { var o = List.copyOf(olaylar); olaylar.clear(); return o; }
}

Kalem bir entity'dir ama aggregate'in dışından erişilemez; repository yalnızca Siparis için vardır. Kredi kararı aggregate'e dışarıdan bir değer olarak verilir — aggregate, müşteri context'inin veritabanını sorgulamaz.

Değer nesneleri: ilkel tiplerden kurtulmak

public record Para(BigDecimal tutar, Currency birim) {
    public Para {
        if (tutar.scale() > 2) throw new IllegalArgumentException("kuruş hassasiyeti");
    }
    public Para topla(Para diger) {
        if (!birim.equals(diger.birim)) throw new FarkliParaBirimi(birim, diger.birim);
        return new Para(tutar.add(diger.tutar), birim);
    }
}
public record SiparisNo(String value) { public SiparisNo { Objects.requireNonNull(value); } }

BigDecimal toplam ve String siparisNo yerine değer nesneleri, kuralı tipin içine koyar: iki farklı para birimi toplanamaz, sipariş numarası boş olamaz. Derleyici, yanlış yere yanlış değerin geçmesini engeller ve testler kurala değil sınıra odaklanır.

Domain olayları: context'ler arası köprü

SiparisOnaylandi olayı, satış context'inin dünyaya söylediği şeydir; muhasebe cari hareketi, lojistik sevkiyat planını bu olaydan üretir. Olay, upstream'in published language'ıdır: adı iş dilinden gelir, geçmiş zamandadır ve yalnızca gerekli veriyi taşır. Olayın güvenilir yayınlanması için outbox pattern ya da Spring Modulith'in publication registry'si kullanılır.

Sık yapılan hatalar

  1. Tanrı aggregate: Musteri içinde List<Siparis>, siparişte List<Fatura>. Bir müşteri yüklemek bin kayıt getirir; iki kullanıcı aynı müşteriye dokununca kilit çatışır. Çözüm: kimlikle referans, ayrı aggregate.

  2. Anemik model: Entity yalnızca getter/setter, kurallar servislerde. Aggregate'in koruması gereken değişmez her servis tarafından ayrı ayrı (ve tutarsız) kontrol edilir.

  3. Her entity için repository: KalemRepository varsa kalem aggregate dışından değiştirilebilir demektir; repository yalnızca root için.

  4. JPA ilişkileriyle context sınırını delmek: Satış modülündeki entity'nin muhasebe entity'sine @ManyToOne vermesi. Sınır kağıtta kalır.

  5. Her yerde DDD: Parametre tabloları, tanımlar, basit CRUD ekranları için aggregate ve değer nesnesi yazmak israftır.

Bounded context'ten koda

Context'ler kod tabanında modül olarak somutlaşır: modüler monolitte Spring Modulith modülleri, dağıtık sistemde ayrı servisler. Modül sınırı bounded context sınırıdır; modül içi yapı için hexagonal mimari bağımlılık yönünü korur; ERP gibi dış modellere karşı anti-corruption layer kurulur. Üçü birlikte tek bir mimari disiplinin katmanlarıdır.

DDD ne zaman gerekmez?

DDD'nin bedeli modelleme zamanı ve daha fazla sınıftır; bunu yalnızca core domain için ödeyin: şirketi farklılaştıran süreç (fiyatlandırma motoru, sipariş-üretim planlama, risk değerlendirme). Destekleyici alt alanlar (raporlama, bildirim) basit katmanlı yapıyla, jenerik alt alanlar (kimlik doğrulama, e-fatura, muhasebe) hazır bileşenle çözülür. Alt alanı sınıflandırmadan her modüle aynı özeni göstermek, çekirdeğe ayrılması gereken zamanı çevreye harcamaktır.

Nereden başlamalı: event storming

Bounded context'leri ve aggregate'leri masa başında değil, iş ekibiyle yapılan bir event storming çalıştayında keşfedin: bir duvara zaman sırasıyla domain olayları (turuncu), komutlar (mavi), aktörler ve aggregate'ler (sarı) yapıştırılır; olayların kümelendiği yerler context adaylarıdır, aynı kelimenin farklı anlam kazandığı yerler sınırlardır. Yarım günlük bir çalıştay, aylarca süren yanlış modeli önler.

Sık sorulan sorular

Aggregate ile JPA entity aynı sınıf olabilir mi?

Basit context'lerde evet, aggregate kurallarına uyulduğu sürece (kimlikle referans, root üzerinden erişim). Zengin kuralları olan core domain'de domain modeli ile persistence modelini ayırmak, aggregate'i JPA kısıtlarından kurtarır.

Eventual consistency iş için kabul edilebilir mi?

Çoğu iş sürecinde zaten öyledir: sipariş onaylandıktan sonra fatura birkaç saniye içinde oluşur, kimse aynı milisaniyede beklemez. Kabul edilemediği yerde (ödeme ve sipariş durumu) iki kavram aynı aggregate'e aittir.

Küçük ekipte DDD uygulanabilir mi?

Evet; stratejik tasarım ekip büyüklüğünden bağımsızdır ve küçük ekipte daha ucuzdur. Taktik desenleri yalnızca core domain'e uygulamak küçük ekibin bütçesine uyar.

Sonuç

DDD, sınıf soneklerinden değil sınırlardan ibarettir: hangi kelimenin nerede ne anlama geldiğini yazmak, modeli o sınırlara bölmek, her aggregate'in kendi değişmezini korumasını sağlamak ve context'leri olaylarla bağlamak. Bu disiplin core domain'e uygulandığında, yazılım iş kadar hızlı değişebilir.

Kurumsal sisteminizin bounded context haritasını bir event storming çalıştayıyla çıkarmak için yazılım tasarımı ve mimari hizmetimize göz atabilirsiniz.