Bounded Context: Aynı Kelimenin Farklı Anlamları ve Model Sınırları
Bounded context, bir modelin ve dilinin geçerli olduğu açıkça çizilmiş sınırdır; "kitap" Catalog'da satıştaki eser, Inventory'de depodaki nüsha, Ordering'de satın alınan satır, Shipping'de koli olduğu için tek bir dev Book class'ı yerine dört model gerekir. Evans'ın tanımı, .NET'in Timer'larıyla CS0104, bounded context ile subdomain ve microservice farkı, boyutlandırma, Ordering için Bounded Context Canvas ve EF Core'da context başına schema.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un ilk yıllarında yazılmış Book class'ı bu. Catalog ekranı, depo ekranı, sipariş akışı ve kargo entegrasyonu hep bu class'ı kullanıyor ve değiştiriyor:
namespace Bookstore.Legacy;
// Herkese tek Book: Catalog, Inventory, Ordering ve Shipping hep bu class'ı değiştiriyor
public class Book
{
public int Id { get; set; }
public string Isbn { get; set; } = "";
public string Title { get; set; } = "";
public string Author { get; set; } = "";
public string Description { get; set; } = "";
public decimal Price { get; set; } // liste fiyatı mı, siparişteki fiyat mı?
public decimal? DiscountedPrice { get; set; }
public int Stock { get; set; } // depodaki mi, satılabilir mi?
public int ReservedStock { get; set; }
public string? ShelfCode { get; set; }
public decimal WeightKg { get; set; }
public bool IsFragile { get; set; }
public bool IsActive { get; set; } // satışta mı? stokta mı? baskısı bitmemiş mi?
public DateTime? LastShippedAt { get; set; }
}
Üç soru. Depo sorumlusu "Dune stokta 5 tane var" dediğinde Stock 5 mi olur, yoksa 2'si bir siparişe ayrılmışsa 3 mü? Kampanya sorumlusu bir kitabı satıştan kaldırdığında IsActive false olur; peki depo, nüshası bitmiş bir kitap için aynı property'yi false yaparsa, kitap stoğa geri girdiğinde satışa da kendiliğinden açılır mı? Ve bu class'ta yapılan son on değişikliği kaç farklı ekip ya da kişi istemiştir? Cevaplarını tahmin et.
Tek Dev Book Class'ının Çöküşü
Sorulara verilecek dürüst cevap: kimse emin değil. Stock'u depo ekranı "elimizdeki nüsha", checkout ise "satılabilir nüsha" olarak okuyor. IsActive üç farklı anlam taşıyor ve her biri farklı bir ekranda set ediliyor; kampanya sorumlusunun kapattığı bir kitabı, stok girişi yapan depo ekranı yeniden açabiliyor. Price kataloğun liste fiyatı mı, yoksa geçen hafta verilmiş bir siparişteki fiyat mı? Fiyat değişince eski siparişlerin tutarı da değişiyor mu?
Bu class bir kötü niyet ürünü değil; her ekip kendi ihtiyacını en yakın yere eklemiş. Sorun, "kitap" kelimesinin Bookstore'un farklı köşelerinde farklı şeyler anlatması. Martin Fowler 2014 tarihli BoundedContext yazısında aynı durumu bir elektrik şirketinden örnekle anlatır: "sayaç" kelimesi bir birimde şebeke ile bir konum arasındaki bağlantıyı, başka bir birimde şebeke ile bir müşteri arasındaki bağlantıyı, bir başkasında ise değiştirilebilen fiziksel cihazı anlatıyormuş. Fowler bu tür kelimelere polysemy (çok anlamlılık) der; konuşmada bağlamdan anlaşılırlar, ama kesinlik isteyen yazılımda karışıklık yaratırlar.
Tek modelde ısrar etmenin bedeli zamanla artar. Her yeni ihtiyaç class'a bir property daha ekler, her property birinin kuralını diğerine sızdırır ve bir ekibin değişikliği başka bir ekibin akışını bozar. Evans'ın DDD (Domain-Driven Design) Reference'ı bunu açıkça söyler: büyük projelerde birden fazla model kaçınılmazdır, ama farklı modellere dayanan kod birleştirildiğinde yazılım hatalı, güvenilmez ve anlaşılması zor hâle gelir.
Evans'ın Tanımı: Modelin Geçerli Olduğu Sınır
Reference'taki tanım kısa: bounded context, belirli bir modelin tanımlı ve geçerli olduğu sınırın (tipik olarak bir alt sistemin ya da belirli bir ekibin işinin) tarifidir. Pattern'in önerisi ise üç parçalı. Modelin geçerli olduğu context'i açıkça tanımla. Sınırları üç düzlemde açıkça çiz: ekip organizasyonu, uygulamanın belirli parçalarındaki kullanım ve kod tabanı ile veritabanı şeması gibi fiziksel karşılıklar. Sınırın içinde model kavramlarını ve terimleri sıkı biçimde tutarlı tut, ama sınırın dışındaki konularla dikkatini dağıtma.
Son parça sık kaçırılır. Bounded context bir izin de verir: Catalog ekibi kitabın ağırlığını modellemek zorunda değil, Shipping ekibi kitabın açıklamasını. Her context kendi sorusuna yetecek kadar model kurar ve "her şeyi bilen tek model" hedefinden vazgeçer. Fowler'ın yazısında alıntıladığı Evans cümlesi de bunu söyler: büyük bir sistem için domain modelinin tamamen birleştirilmesi ne uygulanabilir ne de maliyet açısından mantıklıdır.
Bookstore'da "Kitap"ın Dört Hâli
Dev class'ı, her context'in kendi sorusuna göre dört modele ayıralım:
namespace Bookstore.Catalog
{
// Catalog: kitabın sunulduğu ve satıldığı hâli
public sealed record Book(Guid Id, string Isbn, string Title, string Author, decimal ListPrice, string Description);
}
namespace Bookstore.Inventory
{
// Inventory: bir ISBN'in depodaki fiziksel nüshaları
public sealed class StockItem(string isbn, int onHand)
{
public string Isbn { get; } = isbn;
public int OnHand { get; private set; } = onHand;
public int Reserved { get; private set; }
public int Available => OnHand - Reserved;
public bool Reserve(int quantity)
{
if (quantity > Available) return false;
Reserved += quantity;
return true;
}
}
}
namespace Bookstore.Ordering
{
// Ordering: müşterinin satın aldığı, o anki fiyatıyla
public sealed record OrderItem(Guid BookId, string Title, decimal UnitPrice, int Quantity);
}
namespace Bookstore.Shipping
{
// Shipping: ağırlığı olan bir koli; kitabın adı burada önemsiz
public sealed record Parcel(string OrderReference, int Books, decimal WeightKg);
}

Her modelin cevapladığı soru farklı. Catalog "bu kitap ne, kaça satılıyor?" sorusunu cevaplıyor ve fiyatı liste fiyatı olarak adlandırıyor. Inventory'nin derdi nüsha: elde kaç tane var, kaçı ayrıldı, kaçı satılabilir. Stock belirsizliği burada OnHand, Reserved ve Available olarak üç ayrı isme dönüşüyor. Ordering'deki OrderItem, siparişin verildiği andaki birim fiyatı ve başlığı kopyalıyor; Catalog'da fiyat değişse de geçen haftanın siparişi değişmiyor. Shipping ise kitabın adını hiç bilmiyor; onun için önemli olan kolideki kitap sayısı ve ağırlık.
Context'leri birbirine bağlayan şey ortak bir class değil, kimlikler: BookId, ISBN (International Standard Book Number) ve sipariş numarası. Aynı kitabı farklı context'lerde takip etmek için bu yeterli. Çalıştıralım:
using System.Globalization;
using Bookstore.Persistence;
using Microsoft.EntityFrameworkCore;
using Catalog = Bookstore.Catalog;
using Inventory = Bookstore.Inventory;
using Ordering = Bookstore.Ordering;
using Shipping = Bookstore.Shipping;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// Aynı "Dune", dört context'te
Catalog.Book dune = new(Guid.CreateVersion7(), "9780441013593", "Dune", "Frank Herbert", 20.00m, "Desert planet, spice, politics.");
Inventory.StockItem stock = new(dune.Isbn, onHand: 5);
Ordering.OrderItem item = new(dune.Id, dune.Title, UnitPrice: 18.00m, Quantity: 2);
Shipping.Parcel parcel = new("ORD-1001", Books: item.Quantity, WeightKg: 0.45m * item.Quantity);
stock.Reserve(item.Quantity);
Console.WriteLine($"Catalog {dune.Title} by {dune.Author}, list price {dune.ListPrice:0.00}");
Console.WriteLine($"Inventory {stock.Isbn}: on hand {stock.OnHand}, reserved {stock.Reserved}, available {stock.Available}");
Console.WriteLine($"Ordering {item.Quantity} x {item.Title} at {item.UnitPrice:0.00}");
Console.WriteLine($"Shipping parcel for {parcel.OrderReference}: {parcel.Books} books, {parcel.WeightKg:0.00} kg");
Console.WriteLine();
// Tek veritabanı, her bounded context'e bir schema
const string connection = "Server=localhost;Database=Bookstore;Trusted_Connection=True";
using CatalogDbContext catalogDb = new(new DbContextOptionsBuilder<CatalogDbContext>().UseSqlServer(connection).Options);
using InventoryDbContext inventoryDb = new(new DbContextOptionsBuilder<InventoryDbContext>().UseSqlServer(connection).Options);
Console.WriteLine(catalogDb.Database.GenerateCreateScript());
Console.WriteLine(inventoryDb.Database.GenerateCreateScript());
Catalog Dune by Frank Herbert, list price 20.00
Inventory 9780441013593: on hand 5, reserved 2, available 3
Ordering 2 x Dune at 18.00
Shipping parcel for ORD-1001: 2 books, 0.90 kg
IF SCHEMA_ID(N'catalog') IS NULL EXEC(N'CREATE SCHEMA [catalog];');
GO
CREATE TABLE [catalog].[Books] (
[Id] uniqueidentifier NOT NULL,
[Isbn] nvarchar(13) NOT NULL,
[Title] nvarchar(200) NOT NULL,
[Author] nvarchar(200) NOT NULL,
[ListPrice] decimal(10,2) NOT NULL,
[Description] nvarchar(max) NOT NULL,
CONSTRAINT [PK_Books] PRIMARY KEY ([Id])
);
GO
IF SCHEMA_ID(N'inventory') IS NULL EXEC(N'CREATE SCHEMA [inventory];');
GO
CREATE TABLE [inventory].[StockItems] (
[Isbn] nvarchar(13) NOT NULL,
[OnHand] int NOT NULL,
[Reserved] int NOT NULL,
CONSTRAINT [PK_StockItems] PRIMARY KEY ([Isbn])
);
GO
Çıktının ilk dört satırı aynı kitabın dört farklı yüzü. Script kısmına birazdan, .NET'te sınırı somutlaştırırken döneceğiz.
Aynı İsim, Farklı Model: .NET'in Timer'ları
.NET'in kendisi de aynı kelimeyi farklı namespace'lerde farklı modellerle kullanıyor. System.Threading.Timer, bir callback'i thread pool üzerinde periyodik çalıştıran hafif bir timer; System.Timers.Timer ise Elapsed event'i ve AutoReset gibi property'leri olan, component dünyasından gelen başka bir model. Windows Forms'ta da UI (User Interface) thread'inde çalışan bir üçüncü Timer var. Kelime aynı, anlam ve davranış farklı. İkisini aynı dosyada açarsan derleyici sınırı hatırlatır:
using System.Threading;
using System.Timers;
Timer timer = new(1000);
Program.cs(4,1): error CS0104: 'Timer' is an ambiguous reference between 'System.Timers.Timer' and 'System.Threading.Timer'
Namespace burada bir sınır görevi görüyor ve C# bu sınırı geçerken açık olmanı istiyor. Yukarıdaki Program.cs'teki using Catalog = Bookstore.Catalog; gibi alias'lar da aynı işi görüyor: hangi context'in modelini kullandığını kodda açıkça söylüyorsun. Birinci yazıda andığımız dotnet/eShop'ta da benzer bir tablo var: sepetteki BasketItem miktarı Quantity diye, siparişteki OrderItem ise Units diye adlandırıyor; her service kendi modelini taşıyor. eShop'un CatalogItem'ı ise AvailableStock ve RestockThreshold gibi stok bilgilerini de içeriyor, yani orada katalog ve stok aynı modelde tutulmuş. Bir referans uygulama için makul bir sadeleştirme, ama Bookstore'un tahmin sorusundaki sorunların başlangıç noktası da tam olarak bu birleşme.
Bounded Context, Subdomain ve Microservice
Üç kavram sık karıştırılıyor, çünkü çoğu zaman aynı isimleri taşıyorlar:
| Kavram | Hangi alanda | Ne belirler | Bookstore'da örnek |
|---|---|---|---|
| Subdomain | Problem space | İşin parçası ve yatırım önceliği | Kampanya fiyatlandırması (core) |
| Bounded context | Solution space, model | Bir modelin ve dilinin geçerli olduğu sınır | Ordering: sipariş ve kampanya modeli |
| Microservice | Solution space, deployment | Bağımsız deploy edilen bir birim | Bugün yok; Bookstore tek bir monolith |
İlişkileri şöyle düşünebilirsin. Bir bounded context bir ya da birden fazla subdomain'i çözebilir: Ordering, hem sipariş sürecini hem kampanya fiyatlandırmasını barındırıyor. Bir microservice ise birden fazla bounded context'i kapsamamalı; aksi hâlde birbirine karışmasını istemediğin modelleri aynı deployment unit'e koymuş olursun. Vlad Khononov da Learning Domain-Driven Design kitabında her microservice'in bir bounded context olduğunu, ama her bounded context'in bir microservice olması gerekmediğini vurgular. Bookstore'da altı bounded context var ve hepsi tek bir uygulamanın içinde; sınırlar namespace, project ve veritabanı schema'sı düzeyinde. Bu sınırları module'e ya da service'e dönüştürmek ilerideki Modular Monolith ve Microservices serilerinin konusu.
Boyutlandırma ve Ekip Sahipliği
Bir bounded context ne kadar büyük olmalı? Tek bir doğru cevap yok, ama iki yönde de bedel var. Çok büyük bir context'te dil tutarlılığını koruyamazsın; "kitap" yine üç anlama gelmeye başlar ve tek dev class geri döner. Çok küçük context'lerde ise her iş akışı birkaç sınırı geçer ve sınırlar arası çeviri işin kendisinden pahalı hâle gelir. Evans, Reference'taki Continuous Integration pattern'inde bu dengeyi anar: sistemi gittikçe küçülen context'lere bölmek, değerli bir bütünlük ve tutarlılık düzeyini kaybettirir.
Pratikte üç ipucu işe yarar. Birincisi dil: aynı kelime aynı anlama geliyorsa muhtemelen aynı context'tesin, farklı anlama gelmeye başladığı yerde bir sınır vardır. İkincisi değişim: birlikte değişen kurallar aynı context'te durmalı; kampanya kuralı değiştiğinde sipariş toplamı da değişiyorsa, ikisini ayırmak her değişikliği iki yere bölmek demek. Üçüncüsü sahiplik: bir bounded context'in tek bir sahibi ekip olmalı, ama bir ekip birden fazla context'e sahip olabilir. Bookstore bugün 3 kişilik tek bir ekip ve altı context'in hepsine bakıyor. Mimariye Giriş serisinin Conway's Law yazısında gördüğümüz dört stream-aligned ekip kurulduğunda sahiplik dağılacak: Ordering ve Payment bir ekipte, Inventory ve Shipping başka bir ekipte.
Bounded Context Canvas ile Ordering
DDD Crew topluluğunun Bounded Context Canvas'ı, bir context'i tek sayfada tarif etmek için bir şablon; şu an sürüm 5'te. Ordering için doldurulmuş hâli:
| Alan | Ordering |
|---|---|
| Name | Ordering |
| Purpose | Müşterinin sepetini siparişe çevirmek, kampanya fiyatını uygulamak, iptal ve iade taleplerini kurallara göre yönetmek |
| Strategic Classification | Domain: core · Business model: revenue generator · Evolution: custom built |
| Domain Roles | Execution context: sipariş iş akışını uygular |
| Inbound Communication | Müşteriden PlaceOrder, CancelOrder, RequestReturn; Inventory'den StockReserved, OutOfStock; Payment'tan PaymentCaptured, PaymentFailed |
| Outbound Communication | OrderPlaced, OrderCancelled, ReturnRequested; Catalog'dan kitap bilgisi sorgusu |
| Ubiquitous Language | sipariş (Order), sipariş satırı (OrderItem), iptal, iade talebi (ReturnRequest), kampanya (Promotion), birim fiyat |
| Business Decisions | Kampanyalar birleşmez; kargodaki sipariş iptal edilmez; birim fiyat sipariş anında sabitlenir |
| Assumptions | Kampanya kuralları sipariş toplamına doğrudan dokunduğu için Pricing ayrı bir context değil |
| Verification Metrics | Ordering'deki değişikliklerin kaçı başka bir context'e de dokunuyor |
| Open Questions | Kampanya kuralları büyürse Pricing ne zaman ayrılmalı? |
Canvas'ın en değerli alanları Assumptions ve Open Questions. Bunlar sınırın neden orada olduğunu ve hangi koşulda değişeceğini yazılı hâle getiriyor; altı ay sonra "Pricing neden Ordering'in içinde?" sorusu geldiğinde cevap hazır. Inbound ve Outbound alanları ise bir sonraki yazının, context map'in hammaddesi.
.NET'te Sınırı Somutlaştırmak
Bounded context'i kodda görünür kılmanın en ucuz yolu namespace ve project. Bookstore'da her context'in kendi namespace'i var (Bookstore.Catalog, Bookstore.Ordering…) ve büyüdükçe her biri kendi project'ine geçebilir. Project'e geçmenin artısı, derleyicinin sınırı korumaya başlaması: Ordering project'i Inventory'yi referans etmiyorsa, Ordering kodu StockItem'a dokunamaz.
Veritabanı tarafında da aynı fikir geçerli. Tek bir SQL (Structured Query Language) Server veritabanında kalıp her context'e kendi schema'sını ve kendi DbContext'ini vermek, ayrı veritabanlarına geçmeden önce iyi bir ara adım. Yukarıdaki kodda CatalogDbContext yalnız catalog schema'sını, InventoryDbContext yalnız inventory schema'sını biliyor; Entity Framework Core'un (EF Core) GenerateCreateScript çıktısı da bunu gösteriyor. Bu script'i üretmek için veritabanına bağlanmak gerekmiyor; EF Core modeli kendi içinden SQL'e çeviriyor.
Bu yapının kuralı basit: bir context başka bir context'in tablosuna doğrudan sorgu atmaz ve başka bir context'in entity'sine navigation property ile bağlanmaz. Ordering kitap bilgisine ihtiyaç duyduğunda Catalog'a sorar ve cevabı kendi modeline (OrderItem'daki başlık ve fiyat kopyası) çevirir. Bu sorma ve çevirme işinin nasıl yapılacağı, yani context'ler arasındaki ilişkiler, bir sonraki yazının konusu. Module'lerin ayrı deploy edilmesi, schema'lar arası foreign key'lerin kaldırılması gibi derin uygulama ayrıntıları ise ilerideki Modular Monolith serisinde.
Sık Düşülen Tuzaklar
Tek bir kanonik model aramak. "Şirketin tek bir doğru Customer modeli olmalı" hedefi, dev class'ı ve her değişiklikte toplantıyı geri getirir. Farklı context'lerde farklı modeller bir eksiklik değil, bir tasarım kararıdır.
Bounded context'i microservice ile eşitlemek. Altı context altı service demek değil. Önce model sınırlarını doğru çiz; deployment kararı ayrı bir karar ve çoğu ekip için monolith içinde başlar.
Entity başına bounded context açmak. Book için bir context, Author için bir context, Category için bir context: dil aynı, kurallar birlikte değişiyor, ama her değişiklik üç sınırı geçiyor. Bu, sınır değil parçalanmadır.
Ortak tabloyla sınırı delmek. Kodda context'leri ayırıp veritabanında herkesin aynı Books tablosunu okuyup yazmasına izin vermek, dev class'ı bu sefer tabloda yeniden üretir.
Sınırı yazmamak. Hangi context'in hangi kelimeyi nasıl kullandığı ve hangi ekibin neye sahip olduğu bir yerde yazılı değilse, ilk acil değişiklikte sınır sessizce kaybolur. Canvas ya da kısa bir README bunun için yeterli.
Küçük Bir Deneme
★☆☆ Kendi projende en çok property'si olan üç entity'yi bul. Her property'yi en çok hangi ekranın ya da hangi ekibin değiştirdiğine göre grupla. Kaç grup çıktı?
★★☆ Örnekteki Shipping.Parcel'e "kırılabilir" bilgisini ekle. Bu bilgi Catalog'daki Book'ta mı tutulmalı, Shipping'de mi? Shipping bu bilgiye nasıl ulaşır? Kararını Bounded Context Canvas'ın Inbound Communication alanına yaz.
★★★ Kendi projendeki bir context için Bounded Context Canvas'ı doldur. Assumptions ve Open Questions alanlarını boş bırakma; sonra canvas'ı bir domain uzmanına göster ve Purpose ile Ubiquitous Language alanlarına itiraz edip etmediğine bak.
Bu yazıda sınırları çizdik ve her context'e kendi modelini verdik. Ama sınırlar tek başına yaşamıyor: Ordering kitap bilgisini Catalog'dan alıyor, Shipping eski bir kargo sistemine bağlanıyor, Notification herkesin event'ini dinliyor. Bir sonraki yazıda context mapping ile bu ilişkileri adlandırıyoruz: upstream ve downstream, Shared Kernel, Customer/Supplier, Conformist ve kargo firmasının eski SOAP (Simple Object Access Protocol) servisine karşı yazacağımız bir anti-corruption layer.