Modulith: Mikroservise Gitmeden Durmak
Process bir, sahiplik çok

Mikroservise giden yolun yarısında durmak, cesaretsizlik diye okunuyor bazen. Ben onu tam tersi okuyorum: process maliyetini ödemeden sahipliği denemek. Deneme tutarsa, zaten kazanmışsınızdır. Tutmazsa, taşıdığınız şey bir host değil, bir klasör.
Modulith kelimesi süs olmasın. Kastettiğim: tek deploy birimi, içeride sert sınırlar. Sert, 'lütfen dokunmayın' yorumu değil. Başka modülün tablosuna erişmeyen bir derleme referansı, dışarıya açık bir arayüz, içeriye kapalı bir şema.
Bunu yapmayan bir monolith, tek process olduğu için suçlu değil. Suçlu olan, {Internal} 'in herkese açık olması. Herkesin her DbSet'i görmesi. O monolith'i bölmek, aynı karmaşayı HTTP ile çoğaltır. Önce {Internal} 'i gerçek kılın.
Sınırın üç hali
Bir: klasör. {Billing/} altında handler'lar. Bu, niyet. Derleyici niyeti umursamaz. İki: proje. {Billing} yalnızca {Billing.Contracts} 'i yayınlar. Sipariş, fatura tablosunu görmez. Üç: process. Artık ağ, sözleşme versiyonu, ayrı yürüyüş. Üçü de sınır; bedelleri farklı.
Çoğu ürün, uzun süre ikide kalabilir. İki, modulith. Bir, dilek. Üç, operasyon kararı. Kararı ikiden üçe geçirmek için ilk yazıdaki sorular duruyor: yazan kim, hata sınırı, deploy, işlem, ekip. Sorular evet demeden üç, tören.
Proje sınırının bir hilesi var: yine tek {DbContext}. Context her tabloyu görüyorsa, proje sınırı bir {using} uzaklıkta aşılır. Bu yüzden ya context bölünür ya da şema ve kayıt kuralı gözle tutulur. Ben ikisini birden istiyorum; göz yorulur.
İçeride olay, dışarıda kuyruk değil
Modüller konuşacak. Sipariş, {OrderPlaced} der. Faturalandırma dinler. Aynı process'te bu bir in-memory bus olabilir. Commit'ten sonra, aynı transaction hayali kurmadan. Transaction içi 'olay' çoğu zaman fonksiyon çağrısıdır; onu bus diye süslemeyin.
public sealed class InProcessBus(IServiceProvider sp)
{
public async Task PublishAsync(T ev, CancellationToken ct)
{
foreach (var h in sp.GetServices>())
await h.HandleAsync(ev, ct);
}
}
// PlaceOrder handler, SaveChanges sonrası:
await bus.PublishAsync(new OrderPlaced(order.Id), ct);
Commit sonrası yayın, dinleyen patlarsa ne olur? Sipariş vardır, fatura yoktur. Bu, baştaki outbox yazısının konusu. Modulith, outbox'ı iptal etmez. Process içi bus, outbox rölesinin yerini 'her şey aynı bellekte' diye almasın. Çökme, process içini de böler.
O yüzden ciddi yan etkilerde yine outbox. Bus, sadece aynı transaction'da kalamayacak ucuz işler için — cache invalidation, metrik. Fatura, stok, mail: satır. Satır duruyorsa, yarın bus'ı kuyrukla değiştirmek sıkıcı bir iş. Sıkıcı iş, doğru sınırın ödülü.
Şema, sözleşme, tablo
Aynı veritabanı günah değil. Aynı tablolar günah. {billing.invoices}, {ordering.orders}. Sipariş, fatura satırına FK koymuyorsa, restore hâlâ ortak, yazma değil. Ortak restore, modulith'in kabul ettiği fatura. Kabul etmiyorsanız zaten process konuşuyorsunuz.
CREATE SCHEMA ordering;
CREATE SCHEMA billing;
CREATE TABLE ordering.orders (
id uuid PRIMARY KEY,
buyer_id uuid NOT NULL,
status text NOT NULL,
placed_at timestamptz NOT NULL
);
CREATE TABLE billing.invoices (
id uuid PRIMARY KEY,
order_id uuid NOT NULL UNIQUE,
status text NOT NULL,
issued_at timestamptz NOT NULL
);
EF'de iki context, iki şema. Migration'lar ayrı. Biri gecikince diğeri çalışır — aynı cluster'da bile bu disiplin, 'herkes her kolonu bilir' alışkanlığını kırar. Alışkanlık kırılmadan process bölmek, alışkanlığı network'e taşır.
Paylaştığım, paylaşmadığım
Paylaşılan: zaman, kimlik tipi, para tipi, log. Paylaşılmayan: User entity, genel Status enum, her işe yarayan Result. Result masum durur, sonra her modül kendi hata kodunu aynı tipe tıkıştırır. Tıkışınca sınır, bir switch olur.
{SharedKernel} klasörü şişiyorsa modulith zayıflıyordur. Şişman kernel, eski monolith'in yeni adı. Zayıflatmak: bir tipi geri taşımak, kopyalamak, veya bir olaya çevirmek. Kopya, günah listesinde paylaşılan tablodan daha aşağıdadır. Kopya yaşlanır; paylaşım kilitler.
Ne zaman yalan söyler
Modulith, sınır ihlali PR'da görünmüyorsa yalan söyler. Görünmesi için: mimari test, veya en azından {InternalsVisibleTo} listesinin kısa olması. 'Bir kerelik join' yorumu, bir kere olmaz. Join, ikinci bağlamın şemasını okumaktır.
İkinci yalan, tek ekibin altı modülü törenle konuşturması. Modül, ekip sınırına yakınsa yaşar. Aynı üç kişi her modüle her gün giriyorsa, altı proje yürüyüş maliyetidir. O zaman klasöre dönmek utanç değil, dürüstlük.
Üçüncü yalan, 'ileride kolayca böleriz' deyip sözleşmeyi şişirmek. Kolay bölünen şey, küçük yüzey. Yüzey {OrderDto} 'nun 40 alanıysa, process'e taşımak 40 karar. Modulith, o 40'ı bugün de taşımaz; taşıyorsa zaten sınır yok.
Proje düzeni, niyet değil kilit
Klasör adı {Billing} bir niyet. {Billing.csproj} 'in yalnızca {Billing.Contracts} 'i referans etmesi bir kilit. Kilit yoksa niyet, ilk acelede aşılır. Aceleyi suçlamıyorum; derleyici susuyorsa aşılmaması için bir neden yoktur.
// Billing.csproj
// ProjectReference: Billing.Contracts
// ProjectReference yok: Ordering, Support
// Ordering.csproj
// ProjectReference: Billing.Contracts
// çağrı: IBillingModule.IssueForOrderAsync
// test: Billing.ArchitectureTests
public void Billing_does_not_reference_ordering()
{
var result = Types.InAssembly(typeof(BillingModule).Assembly)
.Should().NotHaveDependencyOn("Ordering")
.GetResult();
Assert.True(result.IsSuccessful);
}
Bu test, slayttan kısa, PR'dan hızlı. Kırmızı olunca tartışma 'bir kerelik join' olmaz; referans ya kalkar ya karar değişir. Karar değişecekse ADR. Test, kararın bekçisi; kararın yerine geçmez.
Worker ile servisi karıştırmamak
CPU yiyen işi ayırmak çoğu zaman process ister, servis değil. Görüntü, PDF, indeks. Bunlar bir worker host'u olabilir: aynı sözleşmeyi okur, aynı şemaya yazmaz, sonucu bir olayla bırakır. Yeni bir bounded context doğmuş olmaz. Doğan şey, bir kaynak sınıfı.
Worker'ı 'mikroservis' diye saymak, kutu sayısını şişirir. Şişen sayı, toplantıda olgunluk gibi durur. Olgunluk, PDF host'unun kendi {users} tablosunu açması değildir. Açarşa, o artık başka bir yazıdır; paylaşım anti-pattern'i. Açmazsa, modulith'in yanında duran bir kol.
Kolun düşmesi beni düşürmemeli. Düşürmemeliyse zaten async. Sync PDF üretimi checkout'a bağlıysa, önce o bağı kesin, host'u sonra konuşun. Bağ dururken host, timeout'u uzaklaştırır; kaldırmaz.
Tek host, iki kapı
Modulith, tek process demek; tek URL demek değil. İçeride {MapGroup("/billing")} durabilir. Dışarıya açılan yüzey küçükse, yarın o grubu başka host'a taşımak bir {Map} işi. Yüzey büyükse taşıma, 40 alanlık DTO göçüdür. Yüzeyi bugün de küçük tutmak, durmanın bir parçası.
İki kapı, iki yetki modeli doğurmasın. Kimlik hâlâ kenarda doğrulanır, kural hâlâ modülde. Gateway yazısındaki yığın, modulith'te de mümkün: {MapGroup} içine indirim yazmak. Yazmayın. Grup, routing. Kural, sınıf.
Üç ay kuralı
Yeni sınırı üç ay taşıyorum. Üç ay kimse acı çekmediyse, sınır doğru, process gerekmez. Acı çekildiyse, acının türüne bakıyorum. Derleme yavaşlığı: proje sayısı, modulith sorunu değil. Gece on-call karışıklığı: gözlemlenebilirlik. 'Şu tabloya giremiyoruz': sınır erken. 'Her feature iki repo': sınır geç, veya ekip henüz bölünmemiş.
Üç ayın sonunda otomatik extract yok. Otomatik olan, soruları tekrar sormak. Cevap değişmişse JSON'u güncellerim. Değişmemişse slaytı güncellemem; slayt zaten yalan söylemek için vardır.
Bu kuralı sevmemin nedeni, hevesi soğutması. Heves, ilk hafta en yüksek. Üçüncü ay, gerçek maliyet görünür. Görünür maliyetle bölmek, hevesle bölmekten daha az pişmanlık eder. Pişmanlık, geri alınan extract'tir. Onu bir kez yaşadım; takvimi birinci yazıda duruyor.
DI kaydı da sınırdır
Modülün host'a nasıl bağlandığı, sınırın dördüncü hali. {AddBilling()} bir uzantı, içerideki somut tipleri gizler. Sipariş, {IBillingModule} ister, {InvoiceRepository} istemez. İsterse, proje referansı olmasa bile çalışma anında sınır delinir. Delinen sınır, derleme testinin görmediği yerdir.
public static IServiceCollection AddBilling(this IServiceCollection services)
{
services.AddScoped();
services.AddDbContext(...);
return services;
}
public static IServiceCollection AddOrdering(this IServiceCollection services)
{
services.AddScoped();
services.AddDbContext(...);
return services;
}
İki {Add} , iki context. Host ikisini de çağırır. Test, birini çağırır. Billing'i tek başına ayağa kaldırmak, yarın ayrı host'un provasıdır. Prova yoksa, extract günü ilk kez {Program.cs} yazarsınız. İlk kez yazılan host, sürpriz ister.
{AddBilling} içine {IOrderingModule} almak, tersine bağımlılıktır. Fatura, siparişi okumak istiyorsa olay veya dar bir sorgu portu. Port, {GetOrderForInvoice} gibi tek metot. 'Madem aynı process, doğrudan oku' cümlesi, üç ay sonra join'e döner. Join, yalanın başlangıcı.
Aynı process'te test
Modulith'in görünmeyen kazancı, testin ucuz kalması. Sipariş + fatura senaryosu, iki host ayağa kaldırmadan bir test sunucusunda biter. Transaction, gerçek transaction'dır. Bunu kaybetmek, extract'in gizli faturasıdır. Faturayı ödemeye değecek bir nedenim yoksa ödemem.
Testin ucuzluğu, sınırı gevşetmesin. Entegrasyon testi her şeyi görüyorsa, birim test hâlâ modülün dışarıya verdiği arayüzden konuşmalı. Her testi entegrasyon yapmak, sınırı 'nasıl olsa hepsi ayakta' diye yok saymaktır. Yok sayılan sınır, production'da da yok sayılır.
Veri tarafında iki context'i aynı veritabanına, farklı şemaya bakacak şekilde test ederim. Migration sırası bozulursa test kırmızı olur. Kırmızı, extract'ten önce gelsin. Extract'ten sonra gelen kırmızı, iki pipeline ve bir gece olur.
Klasör ağacı bir diyagramdır
Tahta yerine repo'ya bakıyorum. Ağaç, kimin kimi gördüğünü söyler. {Contracts} ince, {Internal} kalınsa sınır duruyor. Tersi: {Contracts} şişmiş, her DTO orada, {Internal} ince — o zaman yüzey ürün olmuştur. Ürünleşmiş yüzey, process'e taşınmaz; taşınırsa her tüketicinin işidir.
src/
Ordering/
Ordering.csproj -> Ordering.Contracts
PlaceOrderHandler.cs
OrderingDbContext.cs
Ordering.Contracts/
IOrderingModule.cs
OrderPlaced.cs
Billing/
Billing.csproj -> Billing.Contracts
BillingModule.cs
BillingDbContext.cs
Billing.Contracts/
IBillingModule.cs
Host/
Host.csproj -> Ordering, Billing
Program.cs
{Host} her şeyi görür, o onun işi. {Ordering} , {Billing} 'i görmez. Görmesi gereken tek yer {Host} ve belki bir entegrasyon testi. Bu ağacı bozan PR, mimari PR'dır. İsimlendirme PR'ı diye geçmesin.
Durma cesareti
Durmak, 'asla bölmeyeceğiz' demek değil. Bu çeyrek bölmeyeceğiz, çünkü sorular evet demiyor. Gelecek çeyrek ekip ikiye bölünürse, o zaman process konuşulur. Konuşulduğunda taşınacak şey belli: {IBillingModule} ve {billing} şeması. Belirsiz olan, her şeyin birbirine girdiği bir çorba olmaz.
İlk yazıdaki JSON kontrol listesi burada da duruyor. {splitNow: false}, {writeOwner: billing-module}. Modulith, o {false} 'un yaşanabilir hali. {true} olunca yapılacak iş belli. Belli değilse, {true} demeyin.
Mikroservis bir varış noktası değil. Bazı sınırlar ağ ister, çoğu istemez. İstemeyen yere ağ koymak, haritayı olgunlaştırmak değil, haritayı gürültüye boğmak. Gürültüde durmak, bazen en net karar.
Yorumlar
Yorumlar (0)
Yorumlar üyelere açık. Üye ol · Giriş yap