Module'ler Arası Sync İletişim: Public Contract, In-Process API ve Entity Sızıntısını Önlemek
Module'ler birbirini Contracts project'indeki bir interface üzerinden, record döndüren ve tüketicinin senaryosuna göre şekillenmiş method'larla çağırmalı; başka module'ün DbContext'ini inject etmek o module'ün iç modelini public contract'a çevirir. 20 kitaplık sepette satır başına ve sepet başına çağrının maliyetini ölçüyor, runtime döngüsel bağımlılığı ve Catalog'daki bir refactoring'in Ordering'i nasıl kırdığını gösteriyoruz.
Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10 ve Entity Framework Core (EF Core) 10 ile derlenip denenmiştir.
Önce Tahmin Et
Okul açılışı kampanyası başladı ve bir öğretmen sepetine 20 farklı kitap koydu. Ordering checkout sırasında her satır için iki şey öğrenmek zorunda: kitabın Catalog'daki güncel fiyatı ve Inventory'deki kullanılabilir stok. Önceki yazıdaki contract'larda her iki module'ün de "tek kitap" method'u var, ilk akla gelen de onları bir foreach içinde çağırmak.
Bu checkout kaç contract çağrısı yapar, Catalog'un veritabanına kaç SQL (Structured Query Language) komutu gider? Aynı iş sepet başına tek çağrıyla yapılırsa bu sayılar ne olur? Süre farkı sence ne kadar, ve bu fark Catalog bir gün ayrı bir service'e taşınırsa nasıl değişir?
İkinci bir soru: Catalog da bir kural istiyor, "açık siparişi olan bir kitabın fiyatı değişmesin". Bunun için Ordering'in contract'ını çağırırsa ve Ordering zaten Catalog'u çağırıyorsa, derleyici bir şey der mi?
Module'ün Dışarıya Açtığı Interface
Modular monolith'te module'ler arası sync iletişim, sıradan bir method çağrısı: Ordering, Catalog'un Contracts project'indeki bir interface'i DI (Dependency Injection) container'ından alır ve çağırır. Network yok, serialization yok, aynı process ve çoğu zaman aynı thread. Bu yüzden konu teknik değil, tasarım: o interface'in şekli neyi mümkün kılıyor, neyi engelliyor?
Catalog'un contract'ının bu yazıdaki hâli:
using Bookstore.SharedKernel;
namespace Bookstore.Catalog.Contracts;
public readonly record struct BookId(Guid Value);
public sealed record BookSummary(BookId Id, string Isbn, string Title, Money ListPrice);
public interface ICatalogModuleApi
{
Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default);
// Sepet tek seferde sorulur: N kitap, tek çağrı
Task<IReadOnlyDictionary<BookId, BookSummary>> GetBooksAsync(IReadOnlyCollection<BookId> ids, CancellationToken ct = default);
}
İyi bir module contract'ının birkaç özelliği var. Kullanım senaryosuna göre adlandırılır, tablolara göre değil: GetBooksAsync Ordering'in "bu sepetteki kitaplar" sorusuna cevap veriyor. Dönen tip bir record ya da record koleksiyonu; entity, IQueryable ya da EF Core'un tracking'e bağlı bir object'i asla dışarı çıkmıyor. Her method CancellationToken alıyor, çünkü çağıranın HTTP (Hypertext Transfer Protocol) isteği iptal olursa Catalog'un sorgusu da durmalı. Ve beklenen hataları exception yerine tipin kendisiyle söylüyor: bulunamayan kitap null, sözlükte olmayan anahtar.
.NET'te bu şeklin bilinen bir örneği authorization. IAuthorizationService.AuthorizeAsync çağrıldığında policy'lerin, requirement'ların ve handler'ların hiçbiri dışarı çıkmıyor; çağıran yalnız bir AuthorizationResult alıyor: başarılı mı, değilse hangi requirement'lar tutmadı. İçeride bir policy'nin nasıl değerlendirildiği değişebilir, çağıran kodun tek satırı değişmez.
Catalog'un implementation'ı entity'yi contract'ın record'una çeviriyor:
using Bookstore.Catalog.Contracts;
using Bookstore.SharedKernel;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Catalog.Core;
internal sealed class CatalogModuleApi(CatalogDbContext db) : ICatalogModuleApi
{
public async Task<BookSummary?> GetBookAsync(BookId id, CancellationToken ct = default) =>
await db.Books.AsNoTracking()
.Where(b => b.Id == id.Value)
.Select(b => new BookSummary(new BookId(b.Id), b.Isbn, b.Title, new Money(b.ListPrice, b.Currency)))
.SingleOrDefaultAsync(ct);
public async Task<IReadOnlyDictionary<BookId, BookSummary>> GetBooksAsync(IReadOnlyCollection<BookId> ids, CancellationToken ct = default)
{
Guid[] keys = [.. ids.Select(i => i.Value)];
// Entity dışarı çıkmaz; contract'ın record'una çevrilir
List<BookSummary> books = await db.Books.AsNoTracking()
.Where(b => keys.Contains(b.Id))
.Select(b => new BookSummary(new BookId(b.Id), b.Isbn, b.Title, new Money(b.ListPrice, b.Currency)))
.ToListAsync(ct);
return books.ToDictionary(b => b.Id);
}
}
Select ile doğrudan BookSummary'ye projection yapmak iki iş görüyor: EF Core yalnız gereken kolonları okuyor ve entity hiçbir zaman Catalog'un dışına çıkmıyor.
Ordering Contract Üzerinden Soruyor
Ordering'in checkout handler'ı her module'e sepet başına bir kez gidiyor:
using Bookstore.BuildingBlocks;
using Bookstore.Catalog.Contracts;
using Bookstore.Inventory.Contracts;
using Bookstore.SharedKernel;
namespace Bookstore.Ordering.Core;
internal sealed record CartLine(Guid BookId, int Quantity);
internal sealed record PlaceOrderRequest(IReadOnlyList<CartLine> Lines);
internal sealed record OrderConfirmation(Guid OrderId, int Lines, decimal Total, string Currency);
internal sealed class PlaceOrderHandler(ICatalogModuleApi catalog, IInventoryModuleApi inventory, OrderStore orders)
{
public async Task<Result<OrderConfirmation>> HandleAsync(PlaceOrderRequest request, CancellationToken ct)
{
BookId[] ids = [.. request.Lines.Select(l => new BookId(l.BookId)).Distinct()];
// İki module'e birer çağrı, sepet ne kadar büyük olursa olsun
IReadOnlyDictionary<BookId, BookSummary> books = await catalog.GetBooksAsync(ids, ct);
IReadOnlyDictionary<BookId, int> stock = await inventory.GetAvailabilityAsync(ids, ct);
List<OrderItem> items = [];
foreach (CartLine line in request.Lines)
{
BookId id = new(line.BookId);
if (!books.TryGetValue(id, out BookSummary? book))
return new Error("order.unknown-book", $"Book {line.BookId} does not exist");
if (stock[id] < line.Quantity)
return new Error("order.out-of-stock", $"Only {stock[id]} of '{book.Title}' available");
items.Add(new OrderItem(line.BookId, book.Title, book.ListPrice, line.Quantity));
}
Order order = new(Guid.CreateVersion7(), items);
orders.Add(order);
Money total = order.Total;
return new OrderConfirmation(order.Id, items.Count, total.Amount, total.Currency);
}
}
$ curl -s -X POST localhost:5107/api/ordering/orders -H "Content-Type: application/json" \
-d '{"lines":[{"bookId":"0199b1a0-0000-7000-8000-000000000001","quantity":2},{"bookId":"0199b1a0-0000-7000-8000-000000000002","quantity":1}]}'
{"orderId":"01a1b87d-eaa9-7ca9-b7bc-44a5115843ed","lines":2,"total":30.0,"currency":"EUR"}
Sipariş satırına yazılan fiyat, ikinci yazıdaki snapshot kuralının ta kendisi: OrderItem, Catalog'un o anki ListPrice'ını kopyalıyor ve sonrasında Catalog'daki değişikliklerden etkilenmiyor.
Chatty İletişimin Maliyeti
Açılıştaki ilk soruyu ölçtük. Module'leri bir composition root gibi ayağa kaldıran küçük bir program, aynı 20 kitaplık sepeti iki şekilde sordu ve EF Core'un yayınladığı diagnostic event'lerden çalışan SQL komutlarını saydı:
using System.Diagnostics;
using System.Globalization;
using Bookstore.Catalog.Contracts;
using Bookstore.Catalog.Core;
using Bookstore.Inventory.Contracts;
using Bookstore.Inventory.Core;
using Microsoft.Extensions.DependencyInjection;
CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;
// EF Core'un yayınladığı diagnostic event'lerden çalışan SQL komutlarını say
int sqlCommands = 0;
using IDisposable subscription = DiagnosticListener.AllListeners.Subscribe(new Observer<DiagnosticListener>(listener =>
{
if (listener.Name == "Microsoft.EntityFrameworkCore")
listener.Subscribe(new Observer<KeyValuePair<string, object?>>(e =>
{
if (e.Key == "Microsoft.EntityFrameworkCore.Database.Command.CommandExecuted") Interlocked.Increment(ref sqlCommands);
}));
}));
ServiceCollection services = new();
services.AddCatalogModule("Data Source=probe.db").AddInventoryModule();
using ServiceProvider provider = services.BuildServiceProvider();
await provider.SeedCatalogAsync(books: 20);
using IServiceScope scope = provider.CreateScope();
ICatalogModuleApi catalog = scope.ServiceProvider.GetRequiredService<ICatalogModuleApi>();
IInventoryModuleApi inventory = scope.ServiceProvider.GetRequiredService<IInventoryModuleApi>();
BookId[] cart = [.. Enumerable.Range(1, 20).Select(n => new BookId(DemoIds.Book(n)))];
await Measure("warm-up", async () => { await catalog.GetBooksAsync(cart); await catalog.GetBookAsync(cart[0]); return 0; });
await Measure("chatty (per line)", async () =>
{
int calls = 0;
foreach (BookId id in cart)
{
await catalog.GetBookAsync(id); calls++;
await inventory.GetAvailableAsync(id); calls++;
}
return calls;
});
await Measure("batch (per cart)", async () =>
{
await catalog.GetBooksAsync(cart);
await inventory.GetAvailabilityAsync(cart);
return 2;
});
async Task Measure(string name, Func<Task<int>> run)
{
int before = sqlCommands;
long start = Stopwatch.GetTimestamp();
int calls = await run();
TimeSpan elapsed = Stopwatch.GetElapsedTime(start);
if (name != "warm-up")
Console.WriteLine($"{name,-18} contract calls: {calls,2} SQL commands: {sqlCommands - before,2} elapsed: {elapsed.TotalMilliseconds,6:F2} ms");
}
sealed class Observer<T>(Action<T> onNext) : IObserver<T>
{
public void OnNext(T value) => onNext(value);
public void OnCompleted() { }
public void OnError(Exception error) { }
}
chatty (per line) contract calls: 40 SQL commands: 20 elapsed: 2.02 ms
batch (per cart) contract calls: 2 SQL commands: 1 elapsed: 0.94 ms
Satır başına çağrıda 40 contract çağrısı ve 20 SQL komutu var; Inventory bu örnekte bellekten okuyor, kendi veritabanı olsaydı 20 komut daha eklenecekti. Sepet başına çağrıda 2 contract çağrısı ve 1 SQL komutu. Süre farkı bu makinede yaklaşık iki kat, çünkü SQLite aynı process'te çalışıyor ve her komut mikrosaniyeler sürüyor. Gerçek bir SQL Server'da her komut bir network round trip'i demek; 20 kitaplık sepet 20 round trip'e dönüşüyor.
Asıl fark, ilk yazıdaki ölçümü hatırlayınca ortaya çıkıyor. Orada aynı makinedeki bir HTTP çağrısının in-process çağrıdan bin kattan fazla yavaş olduğunu görmüştük. Catalog bir gün ayrı bir service olursa, satır başına çağıran kod 40 network çağrısı yapar; sepet başına çağıran kod ise 2. Chatty bir contract, bugün göze batmayan ama module'ü service'e çıkardığın gün patlayan bir borç. Contract'ı tasarlarken "bu çağrı network'ten geçseydi bu şekli seçer miydim?" sorusunu sormak, bugünden HTTP eklemeden o güne hazırlanmanın en ucuz yolu.
Çağrı Yönü ve Döngüsel Bağımlılık
İkinci soru. Catalog, açık siparişi olan kitabın fiyatının değişmemesi için Ordering'in yeni bir contract'ını kullanıyor; Ordering'in o contract'ı da cevabı üretirken Catalog'u çağırıyor. Project seviyesinde döngü yok: Catalog.Core → Ordering.Contracts, Ordering.Core → Catalog.Contracts. Build başarılı. Uygulama Production ortamında ayağa kalkıyor ve ilk kitap isteğinde 500 dönüyor:
System.InvalidOperationException: A circular dependency was detected for the service of type 'Bookstore.Catalog.Contracts.ICatalogModuleApi'.
Bookstore.Catalog.Contracts.ICatalogModuleApi(Bookstore.Catalog.Core.CatalogModuleApi) -> Bookstore.Catalog.Core.PriceChangeGuard -> Bookstore.Ordering.Contracts.IOrderingModuleApi(Bookstore.Ordering.Core.OrderingModuleApi) -> Bookstore.Catalog.Contracts.ICatalogModuleApi
Development ortamında ASP.NET Core, DI container'ının ValidateOnBuild kontrolünü açtığı için aynı hata uygulama başlarken geliyor:
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the
Contracts'ı Core'dan ayırmak project'ler arasındaki döngüyü engelliyor ama module'ler arasındaki döngüyü engellemiyor; döngü sadece derleme zamanından çalışma zamanına taşınıyor. Kural şu olmalı: module'ler arası sync çağrılar tek yönde akar. Bookstore'un context map'inde Catalog upstream, Ordering downstream. Ordering Catalog'a sorar, Catalog Ordering'e sormaz.
Catalog'un kuralı yine de meşru, ama çözümü sync çağrı değil. Ya Catalog, Ordering'in yayınladığı OrderPlaced ve OrderShipped event'lerinden "bu kitabın açık siparişi var" bilgisini kendi tablosunda tutar (bir sonraki yazının konusu), ya da kural aslında Catalog'un değil Ordering'in kuralıdır: fiyat değişse bile açık sipariş zaten kendi snapshot'ını tutuyor. İkinci cevap çoğu zaman doğru olanı; döngüsel bağımlılık genelde bir sorumluluğun yanlış module'de durduğunu söyler.
Tuzak: Başka Module'ün DbContext'ini Inject Etmek
Contract'ın en büyük rakibi, "tablo zaten orada" düşüncesi. Bunu görmek için Catalog'un CatalogDbContext'ini ve Book'unu public yaptık, Ordering'e Catalog.Core referansı ekledik ve sepeti contract yerine doğrudan Catalog'un tablosundan fiyatlandıran bir class yazdık:
using Bookstore.Catalog.Core;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Ordering.Core;
// Catalog'un DbContext'i doğrudan inject edilmiş: "contract'a gerek yok, tablo orada"
internal sealed class CartPricing(CatalogDbContext catalogDb)
{
public async Task<decimal> TotalAsync(IReadOnlyCollection<CartLine> lines, CancellationToken ct)
{
Guid[] ids = [.. lines.Select(l => l.BookId)];
Dictionary<Guid, decimal> prices = await catalogDb.Books
.Where(b => ids.Contains(b.Id))
.ToDictionaryAsync(b => b.Id, b => b.ListPrice, ct);
return lines.Sum(l => prices[l.BookId] * l.Quantity);
}
}
Kod derleniyor ve çalışıyor. Birkaç hafta sonra Catalog ekibi kendi içinde makul bir refactoring yapıyor: ListPrice ve Currency kolonlarını EF Core'un complex type desteğiyle tek bir Money value object'inde topluyor.
using Bookstore.SharedKernel;
using Microsoft.EntityFrameworkCore;
namespace Bookstore.Catalog.Core;
public sealed class Book
{
public Guid Id { get; init; }
public required string Isbn { get; init; }
public required string Title { get; set; }
public Money Price { get; set; } // ListPrice + Currency yerine tek value object
}
public sealed class CatalogDbContext(DbContextOptions<CatalogDbContext> options) : DbContext(options)
{
public DbSet<Book> Books => Set<Book>();
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Book>().ComplexProperty(b => b.Price);
}
Catalog'un contract implementation'ında değişen tek şey projection'daki bir ifade: new Money(b.ListPrice, b.Currency) yerine b.Price. Catalog derleniyor, contract üzerinden gelen istekler aynı cevabı alıyor. Ama Ordering kırılıyor:
src/Modules/Ordering/Bookstore.Ordering.Core/CartPricing.cs(14,50): error CS1061: 'Book' does not contain a definition for 'ListPrice' and no accessible extension method 'ListPrice' accepting a first argument of type 'Book' could be found (are you missing a using directive or an assembly reference?)
Bu hata derleme zamanında yakalandığı için şanslı bir durum. Daha kötüsü, Ordering'in Catalog'un tablosuna raw SQL ile ya da bir raporlama view'ı üzerinden gitmesi: o zaman kırılma production'da bir runtime exception olarak, belki de yanlış bir toplam olarak ortaya çıkar. Başka bir module'ün DbContext'ini inject etmek, o module'ün iç modelini kendi public contract'ın yapmak demek; Catalog artık kendi tablosunu Ordering'e sormadan değiştiremez. EF Core'da iki ayrı context'in tablolarını tek bir sorguda join'lemek de zaten mümkün değil, bu yüzden bu yol genelde "tek, dev bir DbContext" ile sonuçlanır. Module başına DbContext ve schema 06. yazıda.
Tuzak: Her Şeyi Döndüren Tek Method
Chatty contract'ın tersi de bir hata. "Her ihtiyaca yetsin" diye tasarlanmış tek bir GetBookDetailsAsync(BookId) düşün: başlık, fiyat, yazar biyografisi, kategori ağacı, kapak görseli adresleri, son yorumlar. Ordering checkout'ta yalnız başlık ve fiyat istiyor ama her çağrıda Catalog'un altı tabloya join attığı bir sorgu çalışıyor. Daha önemlisi, bu record'a eklenen her field bir contract değişikliği ve onu kullanan her module'ü potansiyel olarak etkiliyor.
Benzer bir kılık, generic bir giriş noktası: Task<object> ExecuteAsync(string operation, object payload). Derleyici artık hiçbir şeyi kontrol edemiyor; contract, string'lerden oluşan gizli bir API (Application Programming Interface) hâline geliyor. Grzybek'in örneğindeki ExecuteCommandAsync/ExecuteQueryAsync giriş noktaları bundan farklı: orada her command ve query'nin kendi tipi var ve derleyici onları kontrol ediyor.
Ölçü basit: contract'taki her method'un bir tüketicisi ve o tüketicinin bir senaryosu olmalı. Ordering'in checkout'u için GetBooksAsync, Shipping'in etiket basması için belki ağırlığı da döndüren ayrı bir method. Contract'ı tüketicinin ihtiyacına göre küçük tutmak, Interface Segregation Principle'ın module seviyesindeki karşılığı.
Ne Zaman Sync, Ne Zaman Event?
Her module'ler arası etkileşim bir contract çağrısı olmak zorunda değil. Ayırt etmenin en pratik yolu, çağıranın cevaba şimdi ihtiyacı olup olmadığına bakmak:
| Durum | Seçim | Bookstore örneği |
|---|---|---|
| Çağıran devam etmek için cevaba şimdi ihtiyaç duyuyor | Sync contract çağrısı | Checkout'ta fiyat ve kullanılabilir stok |
| Bir şey oldu, başka module'ler kendi işini yapmalı | Integration event | OrderPlaced sonrası stok ayırma, kargo kaydı |
| Tepkinin gecikmesi kabul edilebilir | Integration event | Sipariş onayı e-postası |
| Sorunun cevabı başka module'ün verisinin bir kopyasıyla verilebilir | Event ile tutulan yerel read model | Catalog'da "açık siparişi var mı" bilgisi |
| Upstream, downstream'e bir şey sormak istiyor | Yeniden düşün: sorumluluk yerinde mi? | Catalog'un Ordering'e sorması |
Sync çağrının bir bedeli daha var: çağrılan module'ün o anki hatası çağıranın hatası olur. Inventory'nin contract'ı exception fırlatırsa checkout da başarısız olur. Bu, aynı process'te olduğumuz sürece kabul edilebilir bir bağımlılık; ama bir module'ün diğerinin "sonradan" yapabileceği işi sync çağrıyla beklemesi, iki module'ün availability'sini gereksiz yere birbirine bağlar.
Sık Düşülen Tuzaklar
Contract'tan entity ya da IQueryable döndürmek
IQueryable<Book> döndüren bir contract, Catalog'un tablosunu Ordering'in sorgusunun içine taşır. Ordering Where ve Join ekledikçe Catalog'un indeksleri, kolonları ve hatta veritabanı teknolojisi Ordering'in sorunu olur. Contract materyalize edilmiş record'lar döndürür.
Contract'ı Core'daki bir class'ın kopyası gibi büyütmek
Contract'a her property eklendiğinde "kim kullanıyor?" sorusu sorulmazsa, contract bir süre sonra entity'nin ta kendisine dönüşür. Yeni bir field için önce tüketicinin senaryosunu sor.
Module'ler arası çağrıyı transaction'ın içine almak
Ordering kendi transaction'ını açıp içinden Inventory'nin contract'ını çağırırsa, bugün aynı veritabanında olduğumuz için iş görüyor gibi görünür. Ama bu, iki module'ün verisini tek bir transaction'a bağlamak demek ve 06. yazıda göreceğimiz gibi, sınırın yanlış olabileceğinin işareti.
Contract'ı version'lamadan değiştirmek
Aynı solution'da derlenen module'ler için breaking change'i derleyici yakalar ve aynı commit'te düzeltilir. Bu bir avantaj, ama "nasılsa derleyici söyler" rahatlığı contract'ı sık değiştirmeye iter. Contract'ı, bir gün network'ten geçecekmiş gibi istikrarlı tut.
Küçük Bir Deneme
★☆☆ ChattyProbe'u 100 ve 500 kitaplık sepetlerle çalıştır. SQL komut sayısı ve süre nasıl ölçekleniyor?
★★☆ Catalog'a, Shipping'in kargo etiketi için ihtiyaç duyduğu bilgiyi (başlık ve gram cinsinden ağırlık) döndüren ayrı bir contract method'u ekle. BookSummary'ye ağırlık eklemek yerine neden ayrı bir record seçtiğini bir cümleyle yaz.
★★★ Döngüsel bağımlılık örneğini düzelt: Catalog'un "açık siparişi olan kitap" bilgisini, Ordering'i çağırmadan nasıl öğrenebileceğini tasarla. Hangi event'leri dinlemesi gerekiyor ve Catalog'un tablosunda hangi kolon tutulmalı? Kodu bir sonraki yazıdaki in-process bus ile yazacağız.
Bu yazıda module'lerin birbirine nasıl sorduğunu konuştuk. Bir sonraki yazıda module'lerin birbirine nasıl haber verdiğine geçiyoruz: integration event'ler, kütüphanesiz küçük bir in-process dispatcher, process çökünce kaybolan event'ler ve bunu çözen minimal bir outbox.