Module Boundary'lerini Bulmak: Business Capability, Bounded Context ve Veri Sahipliği
Module sınırı teknik katmana göre değil, business capability'ye ve veri sahipliğine göre çizilir: her bounded context bir module, her tablonun tek bir yazarı olur. Bookstore'da bir EF Core interceptor'ıyla Books tablosuna üç, Orders tablosuna iki module'ün yazdığını ölçüyor, Order'ın içindeki Book'un neden ayrı bir snapshot modeli olması gerektiğini ve yanlış sınırın belirtilerini gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un bugünkü veritabanında bir Books tablosu var: başlık, liste fiyatı ve "pratik olsun" diye eklenmiş StockOnHand kolonu. Siparişler Orders ve OrderItems tablolarında, sipariş durumu Orders.Status'ta duruyor. Kod bir BookstoreDbContext üzerinden her tabloya erişebiliyor.
Sıradan bir hafta geçiyor: Catalog ekibi Dune'un fiyatını düşürüyor, bir müşteri iki Dune sipariş ediyor, depoya on nüsha geliyor, sipariş kargoya veriliyor ve müşteriye onay e-postası gidiyor. Bu bir haftada Books tablosuna kaç farklı module yazar? Orders tablosuna kaç?
İkinci soru: geçen ay 12,50 EUR'ya (Euro) iki Dune satıldı. Bugün Catalog fiyatı 15,00 EUR'ya çıkardı. Müşteri "Siparişlerim" sayfasını açınca o siparişin toplamını kaç görmeli, kodumuz ona kaç gösterir? Cevap, siparişin içindeki "kitap"ın hangi module'e ait olduğuna bağlı.
Sınırı Teknik Katmana Değil Capability'ye Göre Çizmek
Bir module sınırını çizerken ilk refleks çoğu zaman teknik oluyor: "Data module'ü, API (Application Programming Interface) module'ü, Reporting module'ü." Mimariye Giriş serisi bunun adını koymuştu: technical partitioning. Modular monolith ise domain partitioning'i ciddiye alıyor; üst seviye bölünme iş alanına göre yapılıyor, teknik roller her module'ün içine iniyor.
İş alanını tarif etmenin en kullanışlı yolu business capability: işletmenin ne yaptığı, nasıl yaptığından bağımsız olarak. Bookstore kitap kataloğu tutar, sipariş alır, stok yönetir, ödeme tahsil eder, kargo gönderir, müşteriye haber verir. Bu fiiller yıllar içinde pek değişmez; değişen, onları nasıl yaptığı (hangi ödeme sağlayıcısı, hangi kargo firması, hangi ekran). Sınırı stabil olan şeyin etrafına çizersen, sık değişen ayrıntılar sınırın içinde kalır.
.NET'in kendisinde bunun temiz bir örneği var: ASP.NET Core'da authentication (kimlik doğrulama) ve authorization (yetkilendirme) iki ayrı capability ve iki ayrı assembly ailesi. Kimin kimi tanıdığını görmek için referanslarına bakalım:
using System.Reflection;
// Authentication ve authorization ayrı capability'ler: kim kimi tanıyor?
string[] assemblies =
[
"Microsoft.AspNetCore.Authentication",
"Microsoft.AspNetCore.Authentication.Abstractions",
"Microsoft.AspNetCore.Authorization",
"Microsoft.AspNetCore.Authorization.Policy"
];
foreach (string name in assemblies)
{
IEnumerable<string?> refs = Assembly.Load(name).GetReferencedAssemblies()
.Select(r => r.Name).Where(n => n!.Contains("Auth")).Order();
Console.WriteLine($"{name,-50} -> [{string.Join(", ", refs)}]");
}
Microsoft.AspNetCore.Authentication -> [Microsoft.AspNetCore.Authentication.Abstractions, Microsoft.AspNetCore.Authentication.Core]
Microsoft.AspNetCore.Authentication.Abstractions -> []
Microsoft.AspNetCore.Authorization -> []
Microsoft.AspNetCore.Authorization.Policy -> [Microsoft.AspNetCore.Authentication.Abstractions, Microsoft.AspNetCore.Authorization]
Authorization'ın çekirdeği authentication'a hiç referans vermiyor; yalnız ClaimsPrincipal ile çalışıyor ve kullanıcının cookie ile mi token ile mi geldiğini bilmiyor. İkisini birleştiren Authorization.Policy bile authentication'ın implementation'ına değil, yalnız Abstractions assembly'sine bağlı. Capability başına sınır, aralarında küçük bir contract: bu serinin Bookstore'da kuracağı yapının ta kendisi.
Bounded Context ile İlişkisi
Domain-Driven Design (DDD) serisinin "Bounded Context: Aynı Kelimenin Farklı Anlamları ve Model Sınırları" yazısında bounded context'i bir modelin geçerli olduğu sınır olarak tanımlamış, EventStorming ve context map gibi keşif tekniklerini de o seride anlatmıştık. Burada onları tekrar etmiyoruz; soru, keşfedilmiş context'lerin module'e nasıl dönüştüğü.
Başlangıç için en sağlam kural: bir bounded context, bir module. Bookstore'un altı module'ü DDD serisindeki altı context'le aynı. Ama birebir eşleşme zorunlu değil. DDD serisinde Ordering context'inin içinde iki core subdomain'in (sipariş yaşam döngüsü ile kampanya fiyatlandırması) birlikte çözüldüğünü görmüştük. Bugün ikisi aynı module'de; Pricing bir gün kendi kuralları ve kendi ekibiyle büyürse ayrı module olur. Tersi yanlış: bir bounded context'i iki module'e bölmek, aynı modelin iki yerde yaşaması demek ve her değişiklikte iki module'ü birlikte değiştirmeni gerektirir.
"Order'ın İçindeki Book" Sorusu
Açılıştaki ikinci soru, sınırların neden modelle başladığını gösteriyor. İki yolu yan yana yazdık. Birincisinde sipariş, Catalog'un Book'unu doğrudan tutuyor. İkincisinde sipariş satırı, sipariş anında ihtiyaç duyduğu bilgiyi kopyalıyor:
using System.Globalization;
using Bookstore.Catalog;
using Bookstore.Ordering;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
Book dune = new(Guid.CreateVersion7(), "978-0441013593", "Dune", 12.50m);
// Yol 1: sipariş Catalog'un Book'unu tutuyor
SharedModelOrder shared = new();
shared.Add(dune, 2);
// Yol 2: sipariş satırı, sipariş anındaki bilgiyi kopyalıyor
Order order = new();
order.Add(new OrderItem(dune.Id, dune.Title, dune.ListPrice, 2));
Console.WriteLine($"Before price change: shared {shared.Total}, snapshot {order.Total}");
dune.ChangePrice(15.00m); // yıl sonu kampanyası bitti, liste fiyatı arttı
Console.WriteLine($"After price change : shared {shared.Total}, snapshot {order.Total}");
namespace Bookstore.Catalog
{
// Catalog'un kitabı: aranır, fiyatı değişir
public sealed class Book(Guid id, string isbn, string title, decimal listPrice)
{
public Guid Id { get; } = id;
public string Isbn { get; } = isbn;
public string Title { get; } = title;
public decimal ListPrice { get; private set; } = listPrice;
public void ChangePrice(decimal newPrice) => ListPrice = newPrice;
}
}
namespace Bookstore.Ordering
{
using Bookstore.Catalog;
public sealed class SharedModelOrder
{
private readonly List<(Book Book, int Quantity)> _lines = [];
public void Add(Book book, int quantity) => _lines.Add((book, quantity));
public decimal Total => _lines.Sum(l => l.Book.ListPrice * l.Quantity);
}
// Ordering'in "kitap"ı: satıldığı andaki başlık ve fiyat
public sealed record OrderItem(Guid BookId, string Title, decimal UnitPrice, int Quantity);
public sealed class Order
{
private readonly List<OrderItem> _items = [];
public void Add(OrderItem item) => _items.Add(item);
public decimal Total => _items.Sum(i => i.UnitPrice * i.Quantity);
}
}
Before price change: shared 25.00, snapshot 25.00
After price change : shared 30.00, snapshot 25.00
Paylaşılan modelde geçen ayki siparişin toplamı, Catalog'un fiyat değişikliğiyle sessizce 25,00'ten 30,00'a çıktı. Muhasebe, iade ve fatura bu sayıya bakarsa hata artık bir rapor problemi değil, bir para problemi. Snapshot'lı modelde sipariş, satıldığı andaki gerçeği tutuyor.
Bu bir "kopyalama kötüdür" kuralının istisnası değil, iki farklı kavramın doğru modellenmesi. Catalog'daki Book "şu an satışta olan eser": arama, açıklama, güncel liste fiyatı. Ordering'deki OrderItem ise "bu siparişte satılan şey": BookId, o anki başlık, kampanya uygulanmış UnitPrice ve adet. Inventory'deki StockItem aynı kitabın depodaki fiziksel nüshalarını sayıyor, Shipping'deki koli ise ağırlığıyla ilgileniyor. Dört module, aynı ISBN (International Standard Book Number) için dört farklı model ve aralarındaki tek ortak şey kimlik: BookId. Bir module'ün başka bir module'ün entity'sine ihtiyaç duyduğunu düşündüğünde çoğu zaman aslında o entity'nin bir id'sine ve birkaç field'ının o anki kopyasına ihtiyacın vardır.
Veri Sahipliği: Her Tablonun Tek Bir Yazarı
Bir module'ün sınırını en net gösteren şey verisi. Pratik kural: her tabloya tek bir module yazar. Okuyan çok olabilir (ilerideki yazılarda bunu da contract'lar ve event'lerle sınırlayacağız), ama bir kaydın doğruluğundan tek bir module sorumludur.
Bugünkü Bookstore'da bu kural ne kadar çiğneniyor? Bunu tahmin etmek yerine ölçtük. Legacy BookstoreDbContext'e bir EF (Entity Framework) Core interceptor'ı ekledik; her SaveChanges'ten önce hangi tabloya hangi module'ün kodunun yazdığını kaydediyor. Module'ü bilmek için her service, method'unun başında ModuleScope.Enter(...) çağırıyor:
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace Bookstore.Legacy;
// Şu an hangi module'ün kodu çalışıyor?
public static class ModuleScope
{
private static readonly AsyncLocal<string?> Current = new();
public static string Name => Current.Value ?? "?";
public static IDisposable Enter(string module)
{
string? previous = Current.Value;
Current.Value = module;
return new Exit(() => Current.Value = previous);
}
private sealed class Exit(Action undo) : IDisposable { public void Dispose() => undo(); }
}
// SaveChanges'ten hemen önce hangi tabloya kimin yazdığını not eder
public sealed class WriterLog : SaveChangesInterceptor
{
public HashSet<(string Table, string Module)> Writes { get; } = [];
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
foreach (var entry in eventData.Context!.ChangeTracker.Entries())
if (entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
Writes.Add((entry.Metadata.GetTableName()!, ModuleScope.Name));
return result;
}
}
Checkout'un bugünkü hâli, stok düşmeyi de kendisi yapıyor:
namespace Bookstore.Ordering
{
public sealed class CheckoutService(BookstoreDbContext db)
{
public int PlaceOrder(int customerId, int bookId, int quantity)
{
using IDisposable _ = ModuleScope.Enter("Ordering");
Book book = db.Books.Find(bookId)!;
book.StockOnHand -= quantity; // "stoktan düş" checkout'un içinde
Order order = new() { CustomerId = customerId, Items = [new() { BookId = bookId, Quantity = quantity, UnitPrice = book.ListPrice }] };
db.Orders.Add(order);
db.SaveChanges();
return order.Id;
}
}
}
Açılıştaki haftayı oynatıp tabloları yazarlarına göre grupladık:
new PriceService(db).ChangePrice(1, 11.00m);
int orderId = new CheckoutService(db).PlaceOrder(customerId: 1, bookId: 1, quantity: 2);
new GoodsReceiptService(db).Receive(bookId: 1, quantity: 10);
new DispatchService(db).Ship(orderId, "TRK-1001");
new OrderEmailService(db).SendConfirmation(customerId: 1);
foreach (var table in log.Writes.GroupBy(w => w.Table).OrderBy(g => g.Key))
{
string[] writers = [.. table.Select(w => w.Module).Order()];
string flag = writers.Length > 1 ? " <- more than one writer" : "";
Console.WriteLine($"{table.Key,-11} {string.Join(", ", writers),-30}{flag}".TrimEnd());
}
Books Catalog, Inventory, Ordering <- more than one writer
Customers Notification
OrderItems Ordering
Orders Ordering, Shipping <- more than one writer
İlk sorunun cevabı: Books tablosuna üç module yazıyor. Fiyat Catalog'un, stok düşme Ordering'in, mal kabul Inventory'nin işi ama hepsi aynı satırda buluşuyor. Bu, sınırın yanlış yerden geçtiğinin en güçlü işareti: stok bir kitabın özelliği değil, depodaki bir kaydın özelliği. Doğru hamle StockOnHand'i Inventory'nin kendi StockItem kaydına taşımak ve Ordering'in stok düşmek yerine stok ayırma isteğini Inventory'ye iletmesi.
Orders tablosunda Shipping, siparişin durumunu ve takip numarasını kendisi yazıyor. Kargo süreci Shipping'in verisi (Shipment, TrackingNumber); Ordering kendi durumunu, Shipping'in yayınladığı OrderShipped event'ine bakarak kendisi güncellemeli. Customers tablosu daha incelikli: tek yazarı var ama yanlış yazar. Müşteri kaydı Ordering'in, "son e-posta ne zaman gitti" bilgisi ise Notification'ın kendi EmailMessage kayıtlarının bir parçası. Tek yazar kuralı, yazarın doğru module olduğunu garanti etmiyor; matris sadece soruyu sormanı sağlıyor.
Bu interceptor'ı kendi uygulamanın test ortamında, gerçek senaryolarla bir hafta çalıştırmak, sınır tartışmalarını fikir çatışmasından veriye taşımanın ucuz bir yolu.
Cohesion ve Coupling Sinyalleri
Sınırın doğru olup olmadığını tek bir ölçüt söylemiyor; birkaç sinyalin aynı yönü göstermesine bakıyorsun:
- Birlikte değişen birlikte durur: aynı iş isteği hep aynı iki module'e dokunuyorsa, sınır muhtemelen yanlış. Mimariye Giriş serisinin "Conway's Law: Ekip Yapısı Mimariyi Nasıl Şekillendirir?" yazısında commit geçmişinden bunu ölçen bir araç yazmıştık; aynı aracı ekip yerine module sahipliğiyle çalıştırabilirsin.
- Dil sınırı: aynı kelime iki yerde farklı anlam taşıyorsa ("kitap" Catalog'da eser, Inventory'de nüsha), arada bir sınır var.
- Invariant sınırı: aynı anda, tek transaction'da tutarlı olması gereken kurallar aynı module'de olmalı. "Bir siparişte aynı kitaptan en fazla 10 adet" Ordering'in içinde kalır; stok ile sipariş arasındaki tutarlılık ise birkaç saniyelik gecikmeye dayanabilir.
- Veri sınırı: her tablonun tek yazarı var mı?
- Ekip sınırı: 12–18 ay sonraki dört ekipten her biri bir module'ün tek sahibi olabiliyor mu?
"Hangi Değişiklik Hangi Module'lere Dokunuyor?" Testi
Sinyallerin en pratik olanını bir kâğıt testine çevirebilirsin: son birkaç ayın gerçek iş isteklerini al ve önerdiğin sınırlarla her birinin hangi module'lere dokunacağını yaz. Bookstore'un backlog'undan altı istek:
| İş isteği | Kodu değişen module | Yalnız event'i dinleyen |
|---|---|---|
| Yeni ödeme yöntemi | Payments | — |
| Arama sayfasına yayın yılı filtresi | Catalog | — |
| Okul açılışı kampanyası (3+ kitapta %10) | Ordering | — |
| Kısmi sipariş iptali | Ordering | Inventory, Payments |
| Kargo takip sayfası | Shipping | — |
| Kargo çıkınca müşteriye SMS (Short Message Service) | Notification | — |
İsteklerin çoğu tek bir module'ün kodunu değiştiriyor. Kısmi iptal Ordering'de başlıyor; Inventory ve Payments yalnız OrderCancelled gibi bir event'e tepki veriyor ve onların tarafındaki değişiklik bir handler eklemekten ibaret. Bookstore'un mimari hedeflerinden biri tam olarak buydu: yeni bir ödeme yöntemi yalnız Payments module'ünde değişiklikle, en fazla 3 developer-günde eklenebilmeli. Tabloda bir istek dört module'ün koduna birden dokunsaydı, ya sınır yanlış ya da istek aslında birkaç ayrı istekti.

Yanlış Sınırın Belirtileri
Sınırı yanlış çizdiğini çoğu zaman kodu yazdıktan birkaç ay sonra anlarsın. Belirtiler şunlar: hemen her feature üç module'e birden dokunuyor; iki module birbirini sürekli, aynı istek içinde onlarca kez çağırıyor (chatty iletişim); module'ler arası tek bir transaction'a ihtiyaç duyulan yerler çoğalıyor; iki module arasında döngüsel bağımlılık var; ve en tanıdık olanı, "Common" ya da "Shared" adlı bir module her sprint biraz daha büyüyor.
Son belirti ayrıca dikkat istiyor. "Common" module'üne önce Money girer, bu makul. Sonra iki module'ün de kullandığı Customer girer, sonra OrderStatus, sonra bir BookDto. Bir süre sonra her module "Common" üzerinden birbirine bağlıdır ve "Common"'daki her değişiklik herkesi kırar. 03. yazıda Shared Kernel'e neyin girip neyin giremeyeceğini somut bir listeyle konuşacağız.
Sık Düşülen Tuzaklar
Entity başına module
CustomerModule, BookModule, OrderModule. Entity'ler capability değil; bir capability birden fazla entity'yi, bir entity ise farklı capability'lerde farklı modelleri barındırır. Entity'ye göre bölmek, "Order'ın içindeki Book" sorusunu her yerde yeniden üretir.
Teknik module'ler
Reporting, Integration, Persistence adlı module'ler teknik partitioning'in kılık değiştirmiş hâli. Raporlama gerçek bir capability olabilir (ayrı bir ekip, ayrı bir veri modeli), ama "her module'ün tablolarını okuyup join'leyen yer" olarak kurulursa bütün sınırları tek başına deler. 06. yazıda raporlamanın izolasyonu nasıl delmeden yapılacağını göreceğiz.
Sınırı bugün çizip hiç değiştirmemek
Fowler'ın MonolithFirst yazısındaki uyarı burada da geçerli: sınırları baştan doğru bulmak zor. Modular monolith'in avantajı, yanlış bir sınırı tek bir refactoring ile taşıyabilmen. Bu avantajı kullanmazsan, yani sınırları "bir kez karar verildi" diye dondurursan, en pahalı yanını alıp en ucuz yanını bırakmış olursun.
Notification'ı herkesin tablosuna erişen bir yardımcı sanmak
Notification gibi generic capability'ler, başka module'lerin verisine en çok ihtiyaç duyanlardır: müşterinin e-postası, siparişin içeriği, takip numarası. Bunları gidip tablolardan okumak yerine, event'in içinde taşınmasını ya da contract üzerinden sorulmasını iste. Notification'ın kendi verisi, gönderdiği mesajların kaydı olmalı.
Küçük Bir Deneme
★☆☆ Kendi uygulamanın son 20 iş isteğini al ve yukarıdaki tabloyu kendi module önerinle doldur. Kaç istek tek bir module'ün koduna dokunuyor?
★★☆ WriterLog interceptor'ını kendi uygulamanın integration test'lerine ekle. Module'ü AsyncLocal yerine çağıranın namespace'inden bulmayı dene (ipucu: System.Diagnostics.StackTrace). Hangi tablonun birden fazla yazarı çıktı?
★★★ Books.StockOnHand'i Inventory'ye taşıyan bir refactoring planı yaz: yeni StockItem tablosu, veri taşıma script'i, Ordering'in stok düşmek yerine Inventory'den rezervasyon istemesi. Geçiş süresince iki kolonun tutarlı kalmasını nasıl sağlarsın?
Bu yazıda sınırları nereden çizeceğimizi konuştuk. Bir sonraki yazıda bu sınırları .NET solution'ına döküyoruz: module başına Contracts ve Core project'leri, internal, her module'ün kendi registration extension'ları ve sadece composition root olan bir Host.