Subdomain'ler: Core, Supporting ve Generic Ayrımıyla Yatırımı Doğru Yere Yapmak
Core subdomain şirketi rakiplerinden ayıran, karmaşık ve sık değişen parçadır ve en iyi ekiple zengin modelle yazılır; supporting basit tutulur, generic ise karmaşık olsa da satın alınır. Evans'ın Core Domain ve Generic Subdomains pattern'leri, Khononov'un ölçütleri, Bookstore'un sınıflaması, aynı uygulamada domain model, transaction script ve ince gateway, .NET'in kriptografiyi işletim sistemine bırakması ve subdomain ile bounded context farkı.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore'un 3 kişilik ekibi önümüzdeki altı ayın planını yapıyor. Masada sekiz iş alanı var: katalog yönetimi ve arama, sepet ve sipariş (iptal, kısmi iptal, iade), kampanya fiyatlandırması (okul açılışı, yıl sonu, paket indirimleri), stok ve rezervasyon, ödeme alma ve para iadesi, kullanıcı hesabı ve giriş, kargo firmasıyla entegrasyon, sipariş ve kargo e-postaları.
Ekibin en deneyimli developer'ı Ada. Onu hangi alana koyarsın? Sekiz alandan hangilerini hazır bir ürünle ya da servisle çözersin, hangilerini basit tutup geçersin, hangisine yıllarca yatırım yaparsın? Bir de şu: ödeme alma karmaşık bir iş; 3D Secure, iade, mutabakat, sahtecilik kontrolü. Karmaşık olduğu için mi en çok yatırımı hak ediyor? Cevaplarını not et.
Evans: Core Domain ve Generic Subdomain
Evans'ın DDD (Domain-Driven Design) Reference'ındaki Distillation bölümü şu sorunu tarif eder: büyük bir sistemde birçok parça karmaşıktır ve hepsi gereklidir; bu kalabalıkta modelin asıl değeri, yani işin gerçek varlığı, gözden kaybolur. Üstelik yetenekli developer'lar teknik altyapıya ya da domain bilgisi gerektirmeyen düzgün tanımlı problemlere çekilme eğilimindedir. Evans'ın çözümü Core Domain pattern'i: modeli damıt, core domain'i tanımla ve onu destekleyen kod kütlesinden kolayca ayırt edilebilir yap. Core'u küçük tut, en iyi yetenekleri oraya koy ve diğer her yatırımı core'u ne kadar desteklediğiyle gerekçelendir.
Generic Subdomains pattern'i bunun tersini anlatır. Bazı parçalar, uzmanlık bilgisi taşımadan modele karmaşıklık ekler: herkesin bildiği genel kurallar ya da asıl odağın olmayan uzmanlık alanları. Evans'ın önerisi, bunları ayrı module'lere çıkarmak, içlerinde kendi uzmanlığından iz bırakmamak, önceliklerini core'un altına koymak, core developer'larını bu işlere vermemek ve hazır çözümleri ya da yayınlanmış modelleri değerlendirmek.
Reference bir pattern daha önerir: Domain Vision Statement. Core domain'in ne olduğunu ve ne değer getireceğini anlatan, yaklaşık bir sayfalık kısa bir metin. Bookstore için ilk taslak şöyle olabilir: "Bookstore'u rakiplerinden ayıran şey, okul açılışı ve yıl sonu gibi dönemlerde hızlıca kurulabilen kampanyalar ve müşteriye esnek iptal ve iade imkânı veren sipariş süreci. Kampanya kuralları her dönem değişir ve sipariş süreci bu kuralları hatasız uygulamak zorunda." İki cümle, ekibin neye odaklanacağını söylüyor.
Khononov'un Üç Türü ve Ölçütleri
Evans iki uçtan bahseder; aradaki kategori, Vaughn Vernon'ın Implementing Domain-Driven Design (2013) kitabıyla ve özellikle Vlad Khononov'un Learning Domain-Driven Design (2021) kitabıyla yaygınlaştı. Khononov subdomain'leri üç türe ayırır ve her türü birkaç ölçütle karşılaştırır:
| Ölçüt | Core | Supporting | Generic |
|---|---|---|---|
| Rekabet avantajı | Var; şirketi rakiplerinden ayıran şey | Yok; işi destekler | Yok; herkes aynı şekilde yapar |
| İş mantığının karmaşıklığı | Yüksek | Düşük, çoğu zaman bariz | Yüksek, ama başkaları çoktan çözmüş |
| Değişkenlik | Yüksek; sürekli evrilir | Düşük | Düşük |
| Nasıl uygulanır | Şirket içinde, en iyi ekiple | Şirket içinde basitçe ya da dış kaynakla | Satın al ya da açık kaynak kullan |
| Problemin doğası | Henüz çözülmemiş, yenilik ister | Bariz | Çözülmüş |
Tablonun en öğretici satırı karmaşıklık. Generic subdomain'ler de karmaşıktır; ödeme alma, kimlik doğrulama, e-posta teslimi kolay işler değil. Ama bu karmaşıklık senin rekabet avantajın değil ve başkaları onu senden çok daha iyi çözmüş durumda. Tahmin sorusundaki ödeme tuzağı buydu: karmaşık olması yatırım yapılacağı anlamına gelmiyor. Karmaşık ve farklılaştırıcı olan core'dur; karmaşık ama herkesle aynı olan generic.
Khononov core ile supporting arasında kararsız kalınca işe yarayan bir soru da önerir: bu parçayı ayrı bir ürün olarak satabilir miydin, biri ona tek başına para öder miydi? Bookstore'un kampanya kurgusu bir rakibe satılabilecek bir bilgi; stok sayımı ekranı değil.
Bookstore'u Sınıflamak
Tahmin sorusundaki sekiz alanı bu ölçütlerle sınıflayalım:
- Core: sipariş süreci (iptal, kısmi iptal, iade kuralları) ve kampanya fiyatlandırması (Pricing & Promotions). Vision statement'taki farklılaşma burada; kurallar her kampanya döneminde değişiyor.
- Supporting: katalog yönetimi, stok ve rezervasyon, kargo firmasıyla entegrasyon. Olmazsa olmaz, ama Bookstore'u rakibinden ayırmıyor; kuralları basit ve seyrek değişiyor.
- Generic: ödeme alma ve para iadesi (ödeme sağlayıcısı), kullanıcı hesabı ve giriş (hazır kimlik çözümü), e-posta gönderimi (e-posta sağlayıcısı).
Ada'nın yeri bu listeden çıkıyor: sipariş ve kampanya. Katalog araması ilginç bir ara durum: arama altyapısının kendisi generic (hazır bir arama motoru), ama Bookstore aramada kendine özgü bir sıralama mantığı geliştirirse, o mantık zamanla core'a aday olabilir. Sınıflama bir alanın tamamına değil, içindeki parçalara yapılır; Khononov da core subdomain'leri, içlerindeki generic ve supporting parçaları ayırana kadar inceltmeyi önerir.
Bu sınıflama serinin geri kalanını da belirliyor. Tactical DDD'nin zengin modeli yalnız core'da: 07–13 arasındaki yazılarda Order, OrderItem, Money ve kampanya kurallarını modelliyoruz. Envanter ve kargo için o kadar törene gerek yok.
Üç Tür, Üç Uygulama Biçimi
Subdomain türü, kodu nasıl yazacağını da etkiler. Aynı Bookstore'dan üç parça, üç farklı yaklaşım. Önce core: kampanya kuralları kendi tipleri olan, isimleri iş dilinden gelen ve iş kararlarını açıkça taşıyan bir model.
namespace Bookstore.Ordering.Pricing;
public sealed record CartLine(string Title, decimal ListPrice, int Quantity);
public sealed record PriceQuote(decimal Subtotal, decimal Discount, string? AppliedPromotion)
{
public decimal Total => Subtotal - Discount;
}
public abstract record Promotion(string Name)
{
public abstract decimal DiscountFor(IReadOnlyList<CartLine> lines);
}
// Okul açılışı: sepette 3 veya daha fazla kitap varsa %10 indirim
public sealed record SchoolSeasonPromotion() : Promotion("School season 10%")
{
public override decimal DiscountFor(IReadOnlyList<CartLine> lines) =>
lines.Sum(l => l.Quantity) >= 3 ? lines.Sum(l => l.ListPrice * l.Quantity) * 0.10m : 0m;
}
// Yıl sonu: her 3 kitaplık grupta en ucuzu bedava
public sealed record ThreeForTwoPromotion() : Promotion("Year end 3 for 2")
{
public override decimal DiscountFor(IReadOnlyList<CartLine> lines)
{
decimal[] prices = [.. lines.SelectMany(l => Enumerable.Repeat(l.ListPrice, l.Quantity)).OrderDescending()];
return prices.Where((_, index) => index % 3 == 2).Sum();
}
}
public sealed class PricingPolicy(IEnumerable<Promotion> activePromotions)
{
// İş kararı: kampanyalar birleşmez; müşteri en avantajlısını alır
public PriceQuote Quote(IReadOnlyList<CartLine> lines)
{
decimal subtotal = lines.Sum(l => l.ListPrice * l.Quantity);
(Promotion? best, decimal discount) = activePromotions
.Select(p => (Promotion: (Promotion?)p, Discount: p.DiscountFor(lines)))
.Where(x => x.Discount > 0)
.OrderByDescending(x => x.Discount)
.FirstOrDefault();
return new PriceQuote(subtotal, discount, best?.Name);
}
}
Supporting tarafta envanter için bir transaction script yeterli; bir method, bir kontrol, bir güncelleme:
namespace Bookstore.Inventory;
// Supporting: transaction script yeterli
public sealed class StockService(Dictionary<string, int> onHand)
{
public bool TryReserve(string isbn, int quantity)
{
if (onHand.GetValueOrDefault(isbn) < quantity)
return false;
onHand[isbn] -= quantity;
return true;
}
}
Generic tarafta ödeme sağlayıcısının SDK'sını (Software Development Kit) kullanıyoruz ve önüne yalnız kendi ihtiyacımız kadar ince bir interface koyuyoruz. Bu örnekte sahte bir gateway sağlayıcının yerini tutuyor:
namespace Bookstore.Payments;
// Generic: satın al, önüne kendi ince interface'ini koy
public interface IPaymentGateway
{
Task<PaymentResult> CaptureAsync(string orderReference, decimal amount, string currency, CancellationToken cancellationToken);
}
public sealed record PaymentResult(bool Succeeded, string ProviderReference);
// Bu örnekte ödeme sağlayıcısının SDK'sının yerini tutar
public sealed class FakeProviderGateway : IPaymentGateway
{
public Task<PaymentResult> CaptureAsync(string orderReference, decimal amount, string currency, CancellationToken cancellationToken) =>
Task.FromResult(new PaymentResult(true, $"pp_{orderReference}_{amount:0.00}{currency}"));
}
using System.Globalization;
using Bookstore.Inventory;
using Bookstore.Ordering.Pricing;
using Bookstore.Payments;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
List<CartLine> cart =
[
new("Dune", 20.00m, 1),
new("Emma", 10.00m, 1),
new("The Hobbit", 15.00m, 1),
new("Middlemarch", 12.00m, 1),
];
PricingPolicy pricing = new([new SchoolSeasonPromotion(), new ThreeForTwoPromotion()]);
PriceQuote quote = pricing.Quote(cart);
Console.WriteLine($"Core subtotal {quote.Subtotal:0.00}, discount {quote.Discount:0.00} ({quote.AppliedPromotion}), total {quote.Total:0.00}");
PriceQuote twoBooks = pricing.Quote(cart[..2]);
Console.WriteLine($"Core 2 books: total {twoBooks.Total:0.00}, promotion: {twoBooks.AppliedPromotion ?? "none"}");
StockService stock = new(new() { ["9780441013593"] = 2 });
Console.WriteLine($"Supporting reserve 3 x Dune: {stock.TryReserve("9780441013593", 3)}, reserve 2 x Dune: {stock.TryReserve("9780441013593", 2)}");
IPaymentGateway gateway = new FakeProviderGateway();
PaymentResult payment = await gateway.CaptureAsync("ORD-1001", quote.Total, "EUR", CancellationToken.None);
Console.WriteLine($"Generic captured: {payment.Succeeded}, provider reference: {payment.ProviderReference}");
Core subtotal 57.00, discount 12.00 (Year end 3 for 2), total 45.00
Core 2 books: total 30.00, promotion: none
Supporting reserve 3 x Dune: False, reserve 2 x Dune: True
Generic captured: True, provider reference: pp_ORD-1001_45.00EUR
Dört kitaplık sepette okul açılışı kampanyası 5,70 EUR, yıl sonu kampanyası 12,00 EUR indirim veriyor ve politika ikincisini seçiyor. "Kampanyalar birleşmez, müşteri en avantajlısını alır" cümlesi kampanya sorumlusunun bir kararı ve kodda tek bir yerde, adıyla duruyor. Yarın "okul açılışı kampanyası diğerleriyle birleşebilir" kararı gelirse değişecek yer belli. Yeni bir kampanya türü de yeni bir Promotion record'u demek; bu, Design Patterns serisindeki Strategy Pattern yazısında gördüğümüz yaklaşımın domain dilindeki karşılığı.
Envanter tarafındaki StockService'te ise bu yatırımın hiçbiri yok ve olmaması doğru. Stok ayırma kuralı "yeterli nüsha varsa ayır" kadar basit ve kampanya dönemlerinde değişmiyor. Bu kodu zengin modele çevirmek, kimseye fayda sağlamadan tip sayısını artırır. Ödeme tarafında ise iş mantığı bizim kodumuzda değil, sağlayıcıda; bizim sorumluluğumuz yalnız onu doğru çağırmak ve modelinin kendi kodumuza sızmasını engellemek. Bu sızıntıyı engelleyen çeviri katmanını beşinci yazıda anti-corruption layer adıyla göreceğiz.
.NET'in Kendi Generic Kararı: Kriptografi
"Generic olanı satın al" ilkesinin en net örneği .NET'in kendisinde. Microsoft Learn'deki Cross-platform cryptography sayfası, .NET'teki kriptografi işlemlerinin işletim sisteminin kütüphanelerine yaptırıldığını söylüyor: Windows'ta CNG (Cryptography Next Generation), Linux'ta OpenSSL, Apple platformlarında işletim sisteminin kendi kütüphaneleri. Gerekçe de bir generic subdomain gerekçesi: güvenlik açıklarını kapatmak işletim sistemi üreticisinin yüksek öncelikli işi, güncellemeler onlardan geliyor ve işletim sisteminin kütüphanesi FIPS (Federal Information Processing Standards) onaylıysa .NET uygulaması da onaylı algoritmalara erişiyor.
Bedeli de aynı sayfada yazıyor: .NET yalnız işletim sisteminin desteklediği özellikleri kullanabiliyor ve bazı algoritmalar bazı platformlarda yok. Generic bir çözümü satın almak, onun sınırlarını da satın almak demek. Bookstore için de aynısı geçerli: ödeme sağlayıcısının desteklemediği bir ödeme yöntemini sunamazsın. Kimlik tarafında ASP.NET Core Identity ya da harici bir kimlik sağlayıcısı aynı mantıkla seçilir; giriş, parola sıfırlama ve iki adımlı doğrulama, Bookstore'un rakiplerinden farklı yapacağı şeyler değil.
Subdomain Türü Zamanla Değişir
Sınıflama bir kere yapılıp unutulmaz, çünkü iş stratejisi değişir. Khononov da subdomain türünün iş stratejisine bağlı olduğunu ve strateji değiştikçe türün de değişebileceğini vurgular. Bookstore için böyle bir senaryo düşünmek zor değil. Şirket kampanya dönemlerinde "aynı gün kargo" sözü vermeye karar verirse, birden fazla depo arasında stok ayırma ve kargoya hazırlama sırası rekabet avantajının parçası olur ve envanter supporting'den core'a kayar.
Ters yön de mümkün. Bir zamanlar şirketlerin kendi yazdığı öneri motorları, bugün hazır servis olarak alınabiliyor; dünün core'u bugünün generic'i olabilir. Bu yüzden subdomain haritasını yılda bir ya da büyük bir strateji değişikliğinde yeniden gözden geçirmek iyi bir alışkanlık. Değişen sınıflama, ekip yapısını ve yatırımı da değiştirir; Mimariye Giriş serisindeki Conway's Law yazısında gördüğümüz ekip ve mimari ilişkisi burada da geçerli.
Problem Space ile Solution Space'i Karıştırmamak
Subdomain problem space'in kavramıdır: işin parçası, sen tasarlamazsın, iş stratejisinden keşfedersin. Bounded context ise solution space'in kavramıdır: bir modelin geçerli olduğu sınırı sen tasarlarsın. Khononov bunu "subdomain'ler keşfedilir, bounded context'ler tasarlanır" diye anlatır. İkisi arasında bire bir eşleşme zorunlu değil.
Bookstore'da Ordering bounded context'i iki subdomain'i birden barındırıyor: sipariş süreci ve kampanya fiyatlandırması. Bugün ikisini aynı modelde tutmak mantıklı, çünkü kampanya hesabı siparişin toplamına doğrudan dokunuyor ve aynı ekip ikisine de bakıyor. Kampanya kuralları büyüyüp ayrı bir ekibe geçerse, Pricing ayrı bir bounded context olabilir. Tersi de olur: ödeme generic bir subdomain ve Bookstore'da kendi küçük Payment context'inde, sağlayıcının önündeki ince bir katmanla çözülüyor.
Karıştırmanın tipik belirtisi, subdomain listesini doğrudan solution'daki project listesine ya da microservice listesine çevirmek. Önce problemi anla ve türlerini belirle; sınırları bir sonraki adımda, dil ve ekip gibi başka güçleri de hesaba katarak çiz. Dördüncü yazı tam olarak bu adım.
Sık Düşülen Tuzaklar
Her yere tactical DDD uygulamak. Aggregate, value object ve domain event core'da kendini amorti eder; envanter ve bildirim gibi supporting ve generic alanlarda yalnız tören ekler. Yatırımı subdomain türüne göre ayarla.
Karmaşıklığı core sanmak. Ödeme, kimlik doğrulama ve e-posta teslimi karmaşıktır ama generic'tir. Bunları kendin yazmaya kalkmak, en iyi developer'larını rakiplerinin çoktan satın aldığı bir problemin üzerine koymak demektir.
Core'u kod miktarıyla ölçmek. En çok satırın olduğu yer genellikle en eski ya da en dağınık yerdir, en değerli yer değil. Core, iş stratejisinin söylediği yerdir; bunu kodda değil, domain uzmanıyla konuşarak bulursun.
Supporting'i ihmal etmek. Supporting basit tutulur ama kötü yazılmaz. Stok ayırma kuralındaki bir hata, kampanyanın ortasında olmayan kitabı sattırır ve core'un bütün emeğini boşa çıkarır.
Problem space ile solution space'i karıştırmak. "Altı subdomain var, o hâlde altı microservice" çıkarımı yanlış. Subdomain'ler yatırım kararını, bounded context'ler model sınırını belirler; ikisi farklı sorulara cevaptır.
Küçük Bir Deneme
★☆☆ Kendi projenin iş alanlarını listele ve her biri için bu yazıdaki tablonun beş ölçütünü doldur. Ekibindeki en deneyimli kişi şu anda hangi alanda çalışıyor? Core'da mı?
★★☆ Örnekteki PricingPolicy'ye "okul açılışı kampanyası yalnız Catalog'da okul kitabı olarak işaretlenmiş kitaplara uygulanır" kuralını ekle. CartLine'a ne eklemen gerekti? Bu bilgi hangi bounded context'ten geliyor?
★★★ Kendi şirketin için bir sayfalık Domain Vision Statement yaz. Sonra ekibindeki iki kişiye ve bir domain uzmanına ayrı ayrı okut ve "bu metne göre core neresi?" diye sor. Cevaplar aynıysa core'u buldun; farklıysa, metin değil anlayış farklı demektir.
Bu yazıda problem space'i parçalara ayırdık ve yatırımın nereye yapılacağına karar verdik. Ama sınıflama yaparken bir şey göze çarptı: "kitap" kelimesi Catalog'da, Inventory'de ve Shipping'de hep geçiyor ve her seferinde başka bir şeyi anlatıyor. Bir sonraki yazıda bu kelimeyi takip ederek bounded context'e geçiyoruz: tek dev bir Book class'ının neden çöktüğünü ve model sınırlarını nasıl çizeceğimizi.