İçeriğe atla

Bounded Context Sınırında Command mı Event mi: Mesajın Sahibi Kim

Bounded context sınırını hangi mesajın geçeceğine karar verin: command şemasının sahibi alıcı, event şemasının sahibi yayıncıdır. Varsayılan integration event.

Ayhan Sipahi Ayhan Sipahi

Event yayınlayan ekipler command ile event’in ne olduğu konusunda çoğunlukla hemfikirdir. Eksik kalan, bir bounded context sınırını bu ikisinden hangisinin geçebileceğini söyleyen kuraldır. Kural olmayınca seçim her iş kaleminde yeniden yapılır. Her seçim yerel görünür, ama her biri bir şemanın sahibini ve hangi ekibin hangisine bağımlı olduğunu belirler.

Kural şu: mesajın sahibi sınıra göre belirlenir. Command’in sözleşmesi alıcıya, event’in sözleşmesi yayıncıya aittir. Tek bir bounded context içinde ikisi de uygundur, çünkü iki ucun sahibi aynı ekiptir. Context’ler arasında varsayılan event’tir. Sınırı geçen her command ise bilerek kurulmuş ve adı konmuş bir bağımlılık olarak ele alınır.

Tanımlar event’lerle düşünmeye giriş rehberinde anlatılıyor ve buradan sonra bilindiği varsayılıyor.

Context İçinde ve Sınır Ötesinde Command#

Tek bir bounded context içinde gönderenin de handler’ın da sahibi aynı ekiptir. ReserveStock command’i ile OrderPlaced domain event’i orada iç tasarım tercihleridir. Microsoft’un .NET mikroservis rehberi domain event’leri böyle ele alır. Aynı domain içinde kalırlar, senkron ya da asenkron olabilirler ve rehberin referans uygulaması onları süreç içinde dağıtır. Tercih yanlış çıkarsa tek ekip iki ucu tek bir pull request ile değiştirir.

Aynı command sınırın ötesinde daha pahalıdır. Gönderen, alıcının adını, dilini ve şemasını bilmek zorundadır. Alıcının işi yapması da gerekir. Alıcı çalışmıyorsa ya da isteği reddederse gönderenin akışı durur. Giriş rehberindeki turnusol testine göre, belirli bir servisin işlemesi gereken mesaj bir command’dir. Sınırda bu cümle, başka bir ekibe hem tasarım hem çalışma zamanında bağımlı olmayı da tarif eder.

Şemanın Sahibi ve Bağımlılığın Yönü#

Command’in sözleşmesi alıcıya aittir. Neyi kabul ettiğini handler tanımlar, gönderenlerin hepsi buna uyar. Gönderen çok olabilir, sahip tektir. Event’in sözleşmesi yayıncıya aittir. Olguyu yayıncı tanımlar, abonelerin hepsi buna uyar. NServiceBus dokümantasyonu aynı ayrımı yönlendirme diliyle anlatır: command mantıksal sahibine gönderilir, event mantıksal sahibi tarafından yayınlanır. Lutz Hühnken bunu bir ölçüt önerisi olarak sunar: “The schema of an event is defined by the sending service, the schema of a command by the receiving service.” (Event’in şemasını gönderen servis, command’in şemasını alan servis tanımlar.)

Bağımlılığın yönünü sahiplik belirler. Command’de bu yön mesajın akışıyla aynıdır. Event’te tersidir.

command

bağımlı

event

bağımlı

Gönderen context

Alıcı context, şemanın sahibi

Yayıncı context, şemanın sahibi

Abone context

Event’te abone yayıncıya bağımlıdır. Bu, downstream’in upstream’e bağlı olduğu olağan yöndür. Yayıncı abonelerini tanımaz ve onlar hata verdiğinde çalışmaya devam eder. Bağımlılık yine de ortadan kalkmaz. Abone, yayıncının şemasına ve her alanın anlamına bağlıdır. Event bağımlılığın yönünü değiştirir, bağımlılığı silmez. Sınırı geçen command’de ise gönderen, alıcının modeline ve erişilebilirliğine bağımlıdır. Oskar Dudycz pasif agresif event’ler üzerine yazısında pratikteki farkı şöyle ortaya koyar: handler bir command’i reddedebilir, event ise yalnızca yok sayılabilir. Bu yüzden command gönderenin bir ret yoluna ihtiyacı vardır, yayıncının yoktur.

Tasarım incelemesinde mesaj türü tartışmalarının çoğunu iki soru kapatır: şemanın sahibi kim ve bağımlılık hangi yöne bakıyor. Adı ne olursa olsun, şemasını alıcının tanımladığı mesaj bir command’dir.

Şemanın sahibi olmak, tek başına karar vermek anlamına gelmez. Consumer-driven contract yaklaşımında aboneler hangi alanlara güvendiklerini bildirir, yayıncı da her değişikliği bu bildirimlere göre sınar. Şemanın sahibi ve sürümleyeni yine yayıncıdır.

Domain Event ile Dışa Açık Sözleşme#

Domain event içeride kalır. Biçimini aggregate verir, süreç içinde dağıtılır ve her refactor ile değişebilir. Microsoft’un rehberi onu, domain’de olmuş ve aynı domain’in diğer parçalarının bilmesi istenen bir şey olarak tanımlar. Integration event ise context’in dışa açık API’sidir. Aynı rehbere göre commit edilmiş değişiklikleri diğer mikroservislere, bounded context’lere ya da dış uygulamalara taşır. Rehber iki koşul da ekler: event yalnızca entity kalıcı hâle geldikten sonra yayınlanır ve iletişim asenkron olur.

İki tür farklı kitlelere hitap ettiği için sınırdaki varsayılan bir dönüşüm adımıdır. Bir domain event handler’ı iç event’i sürümlü bir integration event’e çevirir ve outbox’a yazar.

Sınırda Dönüşüm#

Aşağıdaki örnek yalnızca Node’un yerleşik type stripping özelliğinin sildiği tip açıklamalarını kullanır. translate.ts olarak kaydedildiğinde Node 22.18 ve sonrasında çalışır, derleme adımı da dış servis de gerektirmez.

// translate.ts: domain event (iç) -> integration event (dışa açık, sürümlü)
type OrderPlacedDomainEvent = {
  orderId: string;
  customerId: string;
  lines: { sku: string; qty: number; unitPriceCents: number }[];
  paymentToken: string; // iç ayrıntı, context'in dışına asla çıkmamalı
  placedAt: Date;
};

type OrderPlacedV1 = {
  type: "sales.order.placed.v1";
  id: string;
  occurredAt: string;
  data: { orderId: string; customerId: string; totalCents: number };
};

function toIntegrationEvent(e: OrderPlacedDomainEvent, id: string): OrderPlacedV1 {
  const totalCents = e.lines.reduce((sum, l) => sum + l.qty * l.unitPriceCents, 0);
  return {
    type: "sales.order.placed.v1",
    id,
    occurredAt: e.placedAt.toISOString(),
    data: { orderId: e.orderId, customerId: e.customerId, totalCents },
  };
}

const outbox: OrderPlacedV1[] = [];

const domainEvent: OrderPlacedDomainEvent = {
  orderId: "order-42",
  customerId: "cust-7",
  lines: [{ sku: "BOOK-1", qty: 2, unitPriceCents: 1500 }],
  paymentToken: "tok_internal",
  placedAt: new Date("2026-01-01T10:00:00Z"),
};

outbox.push(toIntegrationEvent(domainEvent, "evt-1"));
console.log(JSON.stringify(outbox[0], null, 2));

paymentToken context’in içinde kalır, lines ise dışarıya yalnızca toplam olarak çıkar. Çünkü OrderPlacedV1 tipinde ikisine de karşılık gelen alan yoktur. Dışa açık tip sürümünü adında taşır. Sales ekibi, toIntegrationEvent hâlâ OrderPlacedV1 döndürdüğü sürece OrderPlacedDomainEvent üzerinde serbestçe refactor yapabilir. Kırıcı bir değişiklik için yeni bir tip tanımlanır: sales.order.placed.v2. Bu tip, tüketiciler geçene kadar eskisiyle yan yana yayınlanır. Zarf yapısı ve sürümleme ayrıntıları CloudEvents yazısında anlatılıyor.

Bir serviste bu dizinin yerini, siparişle aynı transaction içinde yazılan bir outbox tablosu alır. AWS Prescriptive Guidance, çift yazma açığını kapatmanın iki yolu olarak outbox tablosunu ve change data capture’ı sayar. Uygulaması outbox pattern yazısında yer alıyor.

Bu varsayılanın bir bedeli var. Dışa açılan her event için ikinci bir şema, bir eşleme fonksiyonu, bir outbox ve bir katalog kaydı gerekir. Eşleme zamanla domain’den kopabilir: domain değişir, eşleme anlamın bir kısmını sessizce düşürür. Tüketiciler, yayıncının önemsiz saydığı alanlara da bağlanabilir. Dolayısıyla dışa açılan her alan bir taahhüttür.

Domain Event’i Doğrudan Yayınlamanın Gerekçesi#

En yaygın alternatif, domain event’leri oldukları gibi yayınlamaktır. En güçlü hâliyle sağlam bir gerekçesi vardır: daha az kod, dönüşüm katmanı yok, ikinci şema yok ve çoğu zaman bütün tüketicilerin sahibi tek bir ekip. Yayıncı ile tüketiciler aynı ekipteyse ve aynı tempoyla deploy ediliyorsa bu görüş geçerlidir. Context’in tek bir iç tüketicisi olduğunda da geçerlidir. Bu durumlarda aradaki çizgi, tek bir context içindeki bir modül sınırıdır.

Bedel, ikinci bir ekip abone olduğunda ortaya çıkar. Her aggregate refactor’ı ekipler arası bir kırıcı değişikliğe dönüşür ve tüketiciler yayıncının sürüm takvimini bekler. İç alanlar da bütün abonelere ulaşır. Microsoft’un referans uygulamasındaki OrderStartedDomainEvent, CardNumber ve CardSecurityNumber alanlarını taşır. Bu, sipariş domain’inin içindeki bir handler için makuldür, ortak bir bus için uygun olmaz.

Payload Boyutu da Sınıra Bağlı#

Fowler, bir event’in ne taşıdığına göre iki pattern’e ad verir: event notification ve event-carried state transfer. Yan Cui bu seçimi sınıra bağlar. Bir domain içinde hafif event’ler yeterlidir, çünkü yayıncının da abonenin de sahibi aynı ekiptir. Domain’ler arasında ise yayıncı kimin dinlediğini bilmez. Bu yüzden daha zengin bir event temkinli seçimdir.

Bu ayrım iki event türüne uygulanırsa domain event ince kalabilir. Integration event, tüketicinin geri sormadan iş yapabilmesi için gerekeni taşır ve içe ait hiçbir şeyi taşımaz. Kişisel veri, ancak bir tüketicinin ihtiyacı varsa payload’a girer, çünkü her abone bir kopyasını alır. Microsoft’un rehberi, tek bir integration event kütüphanesini servisler arasında paylaşmayı önermez. Bunun pratikteki karşılığı şemaları yayınlamaktır. Her tüketici kendi tiplerini üretir ya da yazar.

Context Map Üzerinde Mesaj Türleri#

Evans’ın DDD Reference dokümanı context’ler arasındaki ilişkileri adlandırır. Vernon’ın Implementing Domain-Driven Design kitabı da context’lerin mesajlaşmayla entegrasyonunu işler. Bu sitedeki DDD’ye giriş yazısı bu ilişkileri kodla gösteriyor. Mesaj tasarımı için haritaya bir sütun daha gerekir: her kenardan geçen mesajın türü.

İlişkiSözleşmenin sahibiSınırı geçen varsayılan mesajDownstream ne yapar
Customer/supplierUpstream, planına downstream ihtiyaçlarını da alırUpstream’in integration event’leri. Command yalnızca supplier’ın bu amaçla tasarladığı bir API’ye giderİhtiyacını müzakere eder ve tüketir
ConformistUpstreamUpstream’in integration event’leri, olduğu gibi benimsenirModelinin upstream modelini izlemesini kabul eder
Anti-corruption layerEvent için upstream, kendi modeli için downstreamIntegration event’ler içeri girerHer event’i yerel bir command’e ya da domain event’e çevirir
Open host service ve published languageUpstream, çok sayıda tüketici için belgelenmiş bir protokol olarakKatalogdaki integration event’lerKataloğu okur, müzakere gerekmez
Shared kernel ya da partnershipİki ekip birlikteİki tür de olur. Command burada daha ucuzdurSürümleri ortağıyla koordine eder

Anti-corruption layer satırı, event’lerin içeri girdiği ve command’lerin içeride kaldığı yerdir. Sipariş verildiğinde stok ayırma kuralı Fulfillment’a aittir. Fulfillment, sales.order.placed.v1 event’ini kendi ReserveStock command’ine çevirir ve Sales bu command’in varlığından haberdar olmaz. Bu tür kurallar, bir event storming oturumunda duvara yazılan policy’lerdir. Azure Architecture Center bu katmanın maliyetlerini sayar: ek gecikme ve işletilip ölçeklenmesi gereken bir bileşen daha. İş kurallarını ve orkestrasyonu katmanın dışında tutmayı da önerir.

open host: OrderPlaced v1 event

yerel command: ReserveStock

adı konmuş command: ChargeCard

PaymentCaptured v1 event

CustomerRegistered v1 event

Sales

Fulfillment ACL

Fulfillment modeli

Billing

Notifications

Bu haritadaki tek command olan ChargeCard, Sales’in Billing’e bağımlılığı olarak görünür durumdadır. Diğer bütün kenarlar, sahibi yayıncısı olan event’lerdir.

Her Şeyi Event Olarak Modellemek#

İkinci yaygın alternatif, her etkileşimi bir olgu olarak modellemektir. Gerekçesi şudur: event’ler yayıncının tüketicilerden habersiz kalmasını sağlar, o hâlde yalnızca event’lerden kurulan sistem en gevşek bağlı sistemdir. Fan-out, isteğe bağlı tepkiler ve analitik için bu gerekçe doğrudur.

SendWelcomeEmailRequested, ChargeCardRequested ya da ReserveStockRequested gibi mesajlarda ise bu gerekçe tutmaz. Bunların her birinin tam olarak bir tüketicisi vardır ve yayıncı çoğu zaman bir yanıt event’i bekler. Yayıncı, belirli bir servisin o işi yapmasına muhtaçtır. Ad ne derse desin bağımlılık oradadır. Fowler bu tuzağı, pasif agresif command olarak kullanılan event diye tarif eder. Ona göre alıcının bir iş yapmasını isteyen gönderen, bu niyeti göstermek için command mesajı kullanmalıdır. Dudycz de aynı sonuca varır: “Usually, it’s just better to ask someone to do it.” (Çoğu zaman birinden o işi yapmasını istemek daha iyidir.)

Çözümlerden biri mesajı olguya göre yeniden adlandırmaktır. Sales CustomerRegistered yayınlar, hoş geldin e-postası gönderme kuralının sahibi Notifications olur. Diğer çözüm, alıcı context bu işi bir yetenek olarak sunduğunda geçerlidir. O durumda gönderen, alıcının kendi SendEmail command’ini kullanır.

Lutz Hühnken, reactivesystems.eu’daki Not All Commands Are Equal (And Neither Are Events) (yeni sekmede açılır) yazısında farklı bir yanıt önerir. Yanıt bekleyen command’leri gönder ve unut türündeki command’lerden ayırır. Şemasını yayıncının tanımladığı event’leri de conformist event’lerden ayırır. Conformist event’in şemasını alıcı, örneğin bir bildirim servisi tanımlar. Yayıncılar bu şemaya uyar. Hühnken’e göre şema sahipliği ya da gönderen ve alıcı oranı, bir ekibi command kullanmaya zorlamamalıdır. Gönder ve unut türündeki bir command’i conformist event olarak modellemeyi tercih eder.

O terimi benimsemiyorum. Şemanın sahibi alıcı olduğunda her gönderen alıcının modeline bağımlıdır. Bu, command’in taşıdığı bağımlılıkla aynıdır. Context map üzerinde bir event gören okur, sahibi yayıncı olan bir olgu varsayar. Dolayısıyla mesaja event demek, gönderenden alıcıya uzanan bağımlılığı gözden uzak tutar. Teslim biçiminin gönder ve unut ya da istek ve yanıt olması, kimin kime uymak zorunda olduğunu değiştirmez.

Sınır Ötesi Command’in Adını Koymak#

Sınırı geçen bir command az sayıda durumda doğru seçimdir. İlki, adımları sırayla yürütmesi ve hata durumunda telafi etmesi gereken bir process manager ya da saga orkestratörüdür. Enterprise Integration Patterns, process manager’ı sıranın durumunu tutan ve bir sonraki işlem adımını belirleyen merkezî bir birim olarak tarif eder. AWS’nin saga orkestrasyon pattern’inde bir Step Functions iş akışı katılımcı servisleri çağırır ve telafi adımlarını çalıştırır. İkinci durum, dışa açık API’si bir istek olan yetenek context’leridir: ödeme, bildirim, belge üretimi. Üçüncüsü, alıcının reddedebilmesi gereken her istektir. Telafi tasarımını saga pattern yazısı ele alıyor.

Böyle bir command’i açık tutan dört kural var:

  • Command şemasını alıcı tanımlar ve sürümler, gönderen buna uyar.
  • Command, context map üzerinde ve alıcının kataloğunda yer alır.
  • Gönderen, reddi ve alıcının kesintisini kendi akışında ele alır.
  • Orkestratör, iş sürecinin sahibi olan context’te durur. Herkes adına orkestrasyon yapan ortak bir ara katmanda sürecin sahibi olmaz.

Bunun bedeli çalışma zamanı bağımlılığıdır. Alıcıdaki bir kesinti gönderenin sürecini durdurur. Gönderen, sahibi olmadığı bir şemayı izlemek zorundadır ve iki ekip bu bağımlılığı incelemelere taşır. İş süreci bir adımın gerçekleşmesine ve gerçekleşmediğinde bunu bilmeye ihtiyaç duyuyorsa bu bedel kabul edilebilir.

Kuyrukta üç sonuç birbirinden ayrıdır. SendMessage çağrısının başarılı olması, yalnızca kuyruğun mesajı kabul ettiğini gösterir. Alıcı, command’i işlerken yine de reddedebilir. Ödemenin alınması ya da reddedilmesi gibi iş sonucu ise gönderene daha sonra, alıcıdan gelen bir yanıt mesajı ya da event ile ulaşır. Bu yüzden gönderenin süreci bu sonucu bir zaman aşımıyla bekler.

Aşağıdaki diyagram varsayılanı ve istisnayı tek bir yol üzerinde gösteriyor.

Hayır

Evet

Evet

Hayır

Evet

Hayır

Evet

Hayır

Mesaj bir bounded context sınırını geçiyor mu?

Command ya da domain event, ekibin tercihi

Gönderen bunu hiç tüketici yokken de yayınlar mıydı?

Integration event, şemanın sahibi yayıncı, outbox üzerinden

Adı belli tek bir alıcı işi yapmak zorunda mı ve reddedebilir mi?

Alıcı bu command'i kendi API'sinde sunuyor mu?

Henüz modellenmemiş. İsteğin ardındaki olguyu bulun

Alıcının sahibi olduğu kanalda adı konmuş command, context map'e kaydedilir

Alıcı ekipten tanımlamasını isteyin ya da olguyu yayınlayın

AWS’de Kanalın Sahibi#

AWS’de kural, kanalı hangi stack’in oluşturduğu sorusuna dönüşür. Sözleşmenin sahibi olan ekip, onu taşıyan kaynağın da sahibi olmalıdır.

MesajSözleşmenin sahibiKanalKim oluştururErişim nasıl verilir
Sınırı geçen commandAlıcıSQS kuyruğu ya da alıcının API’siAlıcının stack’iKuyruk policy’si adı belli gönderenlere sqs:SendMessage izni verir
Integration eventYayıncıEventBridge bus’ı ya da SNS topic’iYayıncının stack’iAboneler rule’lar üzerinden alır. Hesaplar arasında alıcı bus’ın policy’si gönderen hesabı kabul eder
Domain eventContext’in kendisiSüreç içi dağıtım ya da iç kuyrukAynı ekipSınırı hiçbir şey geçmez
Orkestrasyon adımıAlıcıAlıcının API’sini ya da kuyruğunu çağıran Step Functions task’iİş akışı, sürecin sahibi olan context’e aittirAlıcı, iş akışının rolüne izin verir

SQS dokümantasyonu ilk satırı temel bir policy örneği olarak gösterir: kuyruğun sahibi başka bir hesaba mesaj gönderme izni verir. Hesaplar arası event’ler için EventBridge dokümantasyonu üç parça tarif eder. Alıcı hesap kendi bus’ında gönderene izin verir, gönderen o bus’ı hedefleyen bir rule oluşturur, alıcı da gelen event’lerle eşleşen rule’lar tanımlar. Bu topolojiyi event fan-out yazısı adım adım anlatıyor. SQS, SNS ve EventBridge arasındaki seçim ise AWS mesajlaşma karşılaştırmasında ele alınıyor.

Hata sahipliği de aynı ayrımı izler. Command kuyruğunun dead-letter queue’su ve alarmı alıcıya aittir. Yeniden deneme ve telafi, gönderenin process manager’ındadır.

Sınırda Sık Yapılan Hatalar#

Gönderenin tanımladığı command sahipliği ters çevirir. Biçimi gönderen ekip uydurur, alıcı bunu kabul etmek zorunda kalır ve kendi girdisini geliştiremez. Command şemasını alıcı yayınlamalıdır.

SendWelcomeEmailRequested ardından EmailSent bekleyen yayıncı, yanıtlı bir command çalıştırıyordur. Bunu command olarak ya da istek ve yanıt olarak modelleyin.

Bir sürüm içinde yalnızca ekleme türündeki değişiklikler güvenlidir. Yerinde yapılan kırıcı değişiklik, yeni sürümlü bir tipe dönüşmelidir. Bu tip, tüketiciler geçerken eskisiyle yan yana yayınlanır.

Anti-corruption layer yalnızca çeviri yapmalıdır. İş kuralı biriktirmeye başladığında bu policy’leri alıcı context’in uygulama katmanına taşıyın. O ekip onları orada test edip değiştirebilir.

Context map’te yer almayan sınır ötesi command, ancak alıcı çalışmadığında fark edilen bir bağımlılıktır. Sınırı geçen command kanallarını saymak ve her birini haritayla karşılaştırmak kısa bir inceleme çalışmasıdır.

Varsayılan Ne Zaman Geçerli#

Bir mesaj bounded context sınırını geçiyorsa ve gönderenin hiç tüketici yokken de yayınlayacağı bir olguyu anlatıyorsa, varsayılan şeması yayıncıya ait bir integration event’tir. Tek bir context içinde ya da tek ekibin aynı tempoyla deploy ettiği context’ler arasında dönüşüm adımı atlanabilir ve command ucuzdur. Adı belli tek bir alıcının işi yapması gerektiğinde ve bunu reddedebildiğinde, yani orkestrasyonla yürüyen bir süreçte ya da bir yetenek API’sinde, sınırı geçen command’e başvurulur. O durumda şemanın ve kanalın sahibi alıcıdır, bağımlılık da context map’e kaydedilir. İyi bir ilk adım, context sınırını geçen bütün mesajları listelemek ve her birinin yanına şema sahibini yazmaktır.

Kaynaklar#

İlgili yazılar