Microservice İletişim Stilleri: Sync Request/Response, Async Event ve Temporal Coupling
Çağıran cevabı beklemek zorundaysa iletişim, kod ne kadar await'li olursa olsun senkrondur ve iki service'i temporal coupling ile birbirine bağlar; cevap şimdi gerekmiyorsa event ya da local kopya tercih edilir. REST, gRPC (deadline'ın server'a taşınması dahil) ve message karşılaştırması, Inventory deploy edilirken checkout simülasyonu, command ile event farkı ve bir karar tablosu.
Sürüm notu: Bu yazıdaki kodlar C# 14 ve .NET 10 ile derlenip denenmiştir.
Önce Tahmin Et
Bookstore service'lere ayrıldıktan sonraki ilk checkout tasarımı şöyle: Ordering'in checkout endpoint'i sırayla Catalog'dan fiyatı, Inventory'den stok rezervasyonunu, Payment'tan ödemeyi, Shipping'den gönderi kaydını istiyor ve hepsi başarılıysa müşteriye "siparişin tamamlandı" diyor. Hepsi HTTP (Hypertext Transfer Protocol) üzerinden, hepsi await ile.
Okul açılışı kampanyasında saniyede 2 checkout geliyor. Fulfillment ekibi tam o sırada Inventory'nin yeni sürümünü deploy ediyor ve bir şeyler ters gittiği için Inventory 20 saniye boyunca cevap vermiyor. Bir dakikalık kampanya penceresinde 120 checkout'tan kaçı hata alır? Aynı akış event'lerle kurulmuş olsaydı kaç hata olurdu ve bunun bedeli ne olurdu? Bir soru daha: bu kodda her çağrı await ile yapıldığına göre, bu iletişim senkron mu, asenkron mu?
İki Eksen: Kim Bekliyor, Kim Biliyor?
Son soru bir tuzak, çünkü "asenkron" kelimesi iki farklı şey için kullanılıyor. C#'taki async/await, bekleme sırasında thread'i bırakmakla ilgili bir uygulama detayı. Mimari açıdan önemli olan ise çağıranın cevabı beklemek zorunda olup olmadığı. Checkout, Inventory'nin cevabını almadan müşteriye dönemiyorsa, kod ne kadar await'li olursa olsun bu iletişim mimari olarak senkron.
Sam Newman, Building Microservices'in ikinci baskısında (O'Reilly, 2021) iletişim stillerini iki eksende ayırıyor. Birinci eksen zaman: synchronous blocking'de çağıran, cevap gelene kadar işine devam edemez; asynchronous non-blocking'de çağıran isteği bırakır ve kendi işine döner. İkinci eksen etkileşim biçimi: request-response'ta bir service diğerinden bir şey ister ve cevap bekler; event-driven'da bir service olan bir şeyi duyurur ve kimin dinlediğini bilmez; common data'da service'ler ortak bir veri kaynağı (paylaşılan bir dosya klasörü, bir data lake) üzerinden haberleşir. Request-response, asenkron da olabilir: Inventory'ye bir queue üzerinden "stok ayır" isteği gönderip cevabı başka bir queue'dan beklemek gibi.
Bu ayrımın pratik karşılığı iki soru. Çağıran cevabı beklemek zorunda mı? Beklemek zorundaysa iki service'in aynı anda ayakta olması gerekir. Çağıran, işi kimin yapacağını bilmek zorunda mı? Bilmek zorundaysa yeni bir consumer eklemek çağıranın kodunu değiştirir. Event-driven iletişim iki soruya da "hayır" diyebilen tek stil; bedelini birazdan göreceğiz.
Teknoloji: REST, gRPC ve Message
Stili seçtikten sonra teknoloji gelir. Request-response için .NET dünyasında iki yaygın seçenek var: JSON (JavaScript Object Notation) taşıyan HTTP API'leri (Application Programming Interface), yani REST (Representational State Transfer) tarzı endpoint'ler, ve gRPC. Microsoft'un ASP.NET Core dokümantasyonundaki karşılaştırma iki teknolojiyi şöyle ayırıyor: gRPC'de contract (.proto dosyası) zorunlu, HTTP/2 üzerinde çalışıyor, payload küçük ve binary Protobuf; JSON API'lerde contract (OpenAPI) isteğe bağlı, payload insan tarafından okunabilir. gRPC tarayıcıdan doğrudan çağrılamıyor (gRPC-Web ya da JSON transcoding gerekiyor); karşılığında client kodunu contract'tan üretiyor, streaming'i ve deadline'ı protokolün parçası olarak getiriyor. Dokümantasyon gRPC'yi microservice'ler arası, düşük latency ve yüksek throughput gerektiren iletişim için öneriyor. Bu ayrımın canlı bir örneği dotnet/eShop'ta var: Basket API'si contract'ını basket.proto ile tanımlayan bir gRPC service'i, Catalog ve Ordering ise HTTP API'leri sunuyor.
Bookstore'da Ordering'in Inventory'ye "bu kitaptan kaç nüsha var?" diye sorduğu bir sorguyu gRPC ile yazalım. Contract önce geliyor ve iki tarafın da kodu ondan üretiliyor:
syntax = "proto3";
option csharp_namespace = "Bookstore.Inventory.Grpc";
package bookstore.inventory.v1;
// Inventory'nin dışarıya açtığı contract: Ordering bu dosyadan client üretir
service StockQueries {
rpc GetAvailability (AvailabilityRequest) returns (AvailabilityReply);
}
message AvailabilityRequest {
string isbn = 1;
}
message AvailabilityReply {
string isbn = 1;
int32 available = 2;
}
using Bookstore.Inventory.Grpc;
using Grpc.Core;
namespace Bookstore.Inventory;
public sealed class StockQueriesService : StockQueries.StockQueriesBase
{
public override async Task<AvailabilityReply> GetAvailability(AvailabilityRequest request, ServerCallContext context)
{
// Kampanya günü yavaşlayan bir sorgu: deadline geçince context.CancellationToken iptal olur
if (request.Isbn == "978-0141439518")
{
try
{
await Task.Delay(TimeSpan.FromSeconds(2), context.CancellationToken);
}
catch (OperationCanceledException)
{
Console.WriteLine($"Inventory: stopped work for {request.Isbn}, deadline was {context.Deadline:HH:mm:ss.fff} UTC");
throw;
}
}
return new AvailabilityReply { Isbn = request.Isbn, Available = 17 };
}
}
using Bookstore.Inventory;
using Bookstore.Inventory.Grpc;
using Grpc.Core;
using Grpc.Net.Client;
using Microsoft.AspNetCore.Server.Kestrel.Core;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.Services.AddGrpc();
builder.WebHost.ConfigureKestrel(k => k.ListenLocalhost(5089, o => o.Protocols = HttpProtocols.Http2));
WebApplication inventory = builder.Build();
inventory.MapGrpcService<StockQueriesService>();
await inventory.StartAsync();
// Ordering tarafı: .proto'dan üretilmiş, strongly typed client
using GrpcChannel channel = GrpcChannel.ForAddress("http://localhost:5089");
StockQueries.StockQueriesClient stock = new(channel);
AvailabilityReply dune = await stock.GetAvailabilityAsync(
new AvailabilityRequest { Isbn = "978-0441013593" }, deadline: DateTime.UtcNow.AddMilliseconds(500));
string json = Google.Protobuf.JsonFormatter.Default.Format(dune);
Console.WriteLine($"Dune available: {dune.Available}");
Console.WriteLine($"Payload: protobuf {dune.CalculateSize()} bytes, same message as JSON {json.Length} bytes: {json}");
try
{
// Deadline server'a da taşınır: 500 ms içinde cevap yoksa iki taraf da işi bırakır
await stock.GetAvailabilityAsync(
new AvailabilityRequest { Isbn = "978-0141439518" }, deadline: DateTime.UtcNow.AddMilliseconds(500));
}
catch (RpcException ex)
{
Console.WriteLine($"Emma: {ex.StatusCode}");
}
await inventory.StopAsync();
Dune available: 17
Payload: protobuf 18 bytes, same message as JSON 45 bytes: { "isbn": "978-0441013593", "available": 17 }
Inventory: stopped work for 978-0141439518, deadline was 00:11:34.053 UTC
Emma: DeadlineExceeded
İki şey dikkat çekiyor. Aynı cevap Protobuf olarak 18 byte, JSON olarak 45 byte; küçük bir mesajda fark önemsiz görünür, saniyede binlerce çağrıda önemli olur. Daha önemlisi deadline: client 500 milisaniyelik bir süre verdi, bu süre server'a da iletildi ve Inventory süre dolunca işi kendisi bıraktı. Senkron bir zincirde her halkanın kendi timeout'unu ayrı ayrı uydurması yerine, isteğin kalan süresi zincir boyunca taşınabiliyor. HTTP API'lerde aynı disiplin CancellationToken ve HttpClient timeout'larıyla elle kurulur; ASP.NET Core Web API serisinin "ASP.NET Core'da Async/Await ve CancellationToken: İptali Veritabanına Kadar Taşımak" yazısı bunun service içindeki hâlini anlatıyor.
Message tarafında teknoloji bir broker: RabbitMQ, Azure Service Bus, Kafka ya da benzeri. Burada contract, message'ın şeması; taşıma ise broker'ın garantileri (teslim, sıra, tekrar). Hangi broker'ın neyi garanti ettiği Messaging ve Event-Driven Architecture serisinin konusu; bu yazının sorusu ne zaman message'a ihtiyaç duyduğun.
| Özellik | HTTP + JSON | gRPC | Message (broker) |
|---|---|---|---|
| Çağıran cevabı bekliyor mu? | Evet | Evet (streaming hariç) | Hayır |
| Alıcı o an ayakta olmalı mı? | Evet | Evet | Hayır, message bekler |
| Contract | İsteğe bağlı (OpenAPI) | Zorunlu (.proto) | Message şeması |
| Tarayıcıdan çağrılabilir mi? | Evet | gRPC-Web ya da transcoding ile | Hayır, arada bir API gerekir |
| Tipik kullanım | Dışa açık API, basit sorgular | Service'ler arası yoğun, düşük latency çağrılar | Bir şey oldu bildirimi, uzun süren işler |
Temporal Coupling ve Zincirin Bedeli
Senkron request-response'un mimari bedeli temporal coupling: çağıran ile çağrılan aynı anda ayakta olmak zorunda. Mimariye Giriş serisinin "Monolith'i Doğru Anlamak: Single-Process, Modular ve Distributed Monolith" yazısında beş service'lik senkron bir zincirin availability'sinin nasıl çarpıldığını bir milyon istekle simüle etmiştik; Messaging serisinin "Neden Messaging? Senkron Çağrı Zincirinden Asenkron İletişime" yazısı da aynı zinciri e-posta sağlayıcısının çökmesiyle göstermişti. Burada microservices'e özgü bir açıdan bakalım: arıza değil, deploy.
Independent deployability'nin bütün fikri, Inventory ekibinin istediği zaman deploy edebilmesi. Ama Inventory senkron bir zincirin ortasındaysa, her deploy penceresi checkout'un kesinti penceresi oluyor ve "bağımsız deploy" fiilen "müşteriye görünen deploy" hâline geliyor. Açılıştaki senaryoyu hesaplayalım: bir dakika, saniyede 2 checkout, Inventory 20. ile 40. saniye arasında yok, kampanya kitabından 100 nüsha var.
// Kampanya dakikası: saniyede 2 checkout, 60 saniye; Inventory 20-40. saniyeler arasında deploy ediliyor
const int seconds = 60, perSecond = 2, initialStock = 100;
static bool InventoryUp(int t) => t is < 20 or >= 40;
// 1) Sync: checkout, Inventory'nin cevabını bekliyor
int stock = initialStock, syncOk = 0, syncErrors = 0;
for (int t = 0; t < seconds; t++)
for (int i = 0; i < perSecond; i++)
{
if (!InventoryUp(t)) { syncErrors++; continue; } // kullanıcı 503 görüyor
if (stock > 0) { stock--; syncOk++; }
}
Console.WriteLine($"Sync : confirmed {syncOk}, errors shown to customers {syncErrors}, unsold copies {stock}");
// 2) Async: checkout OrderPlaced yayınlayıp dönüyor; Inventory ayaktayken queue'yu eritiyor
stock = initialStock;
Queue<int> pending = new(); // her message için sipariş anı (saniye)
int reserved = 0, outOfStock = 0, maxDelay = 0;
for (int t = 0; t < seconds; t++)
{
for (int i = 0; i < perSecond; i++) pending.Enqueue(t); // her checkout 202 Accepted
if (!InventoryUp(t)) continue;
while (pending.TryDequeue(out int placedAt))
{
maxDelay = Math.Max(maxDelay, t - placedAt);
if (stock > 0) { stock--; reserved++; } else outOfStock++; // sonradan iptal + özür e-postası
}
}
Console.WriteLine($"Async: accepted {seconds * perSecond}, reserved {reserved}, cancelled later (OutOfStock) {outOfStock}, " +
$"max wait for confirmation {maxDelay} s");
Sync : confirmed 80, errors shown to customers 40, unsold copies 20
Async: accepted 120, reserved 100, cancelled later (OutOfStock) 20, max wait for confirmation 20 s

Senkron tasarımda 120 checkout'un 40'ı hata aldı. Bunlar müşterinin gördüğü 503'ler; bir kısmı tekrar dener, bir kısmı vazgeçer. Event-driven tasarımda checkout'un hiçbiri hata almadı, ama tablonun öbür yarısı da gerçek: deploy sırasında gelen siparişler 20 saniyeye kadar onaysız bekledi ve stok bittiği için son 20 sipariş "kabul edildi" dendikten sonra iptal edilecek. Bu müşterilere bir özür e-postası gidecek. Senkron tasarımda ise aynı anda 20 nüsha satılmadan kaldı, çünkü onları almak isteyenler hata görüp gitti.
Simülasyon basit tutuldu (retry yok, kullanıcı davranışı yok), ama gösterdiği karar gerçek: event-driven iletişim hatayı ortadan kaldırmıyor, zamanı ve yeri değiştiriyor. Hata checkout anında müşterinin ekranında değil, birkaç saniye sonra bir compensation ve bir bildirim olarak ortaya çıkıyor. Bu, bir önceki yazıda konuştuğumuz eventual consistency'nin iletişim katmanındaki yüzü. Hangisinin daha iyi olduğunu iş tarafı söyler; Bookstore için "kampanyada checkout asla hata vermesin, nadir bir iptal kabul edilebilir" kararı event-driven tasarımı seçtiriyor.
Command mi, Event mi?
Asenkron tarafta da iki farklı message türü var ve karıştırılmaları sınırları bulandırıyor:
namespace Bookstore.Contracts;
// Command: tek bir alıcıya "şunu yap" der; alıcı reddedebilir, schema'nın sahibi alıcıdır
public sealed record ReserveStock(Guid OrderId, string Isbn, int Quantity);
// Event: "şu oldu" der; yayınlayan kimin dinlediğini bilmez, schema'nın sahibi yayınlayandır
public sealed record OrderPlaced(Guid OrderId, Guid CustomerId, string Isbn, int Quantity, decimal Total);
ReserveStock bir command: göndereni alıcıyı biliyor, alıcı tek, alıcı reddedebilir ("stok yok") ve schema'nın sahibi alıcı, çünkü "benden ne isteyebilirsin" sorusunu o cevaplıyor. OrderPlaced bir event: geçmiş zamanda bir olgu, yayınlayan kimin dinlediğini bilmiyor, dinleyenler sıfır da olabilir on da, ve schema'nın sahibi yayınlayan. Yeni bir consumer (örneğin bir öneri motoru) eklemek OrderPlaced için Ordering'e dokunmayı gerektirmiyor; ReserveStock gibi bir command'a yeni bir alıcı eklemek ise anlamsız. İsimlendirme ayrımı da buradan geliyor: command emir kipinde (ReserveStock), event geçmiş zamanda (OrderPlaced, StockReserved). Ayrıntısı Messaging serisinin "Message Türleri: Command, Event ve Query; Event Notification ve Event-Carried State Transfer" yazısında.
Service sınırları açısından seçimin anlamı şu: command, gönderenle alıcı arasında bir domain coupling kurar ve bunu açıkça söyler; event ise coupling'i yayınlayandan consumer'a doğru tek yönlü bırakır. Bir önceki yazıdaki choreography saga'sı tamamen event'lerle kuruluydu; orchestration ise doğal olarak command'larla çalışır. İkisi de meşru; önemli olan bir message'ın adı ile davranışının tutarlı olması. "Event" diye yayınlanıp tek bir alıcının belli bir şeyi yapmasını bekleyen message'lar (SendOrderConfirmationEmailEvent gibi), aslında kılık değiştirmiş command'lardır.
Checkout'u Dönüştürmek
Açılıştaki zinciri adım adım sökelim. Her çağrı için tek bir soru soruyoruz: müşteriye cevap vermeden önce bu bilgiye gerçekten ihtiyacım var mı?
Catalog'dan fiyat: hayır. Dördüncü yazıdaki gibi Ordering, BookPriceChanged ile beslenen kendi kopyasından okur. Fiyat bilgisi birkaç saniye eski olabilir; bu yüzden siparişe yazılan fiyat, müşterinin sepette gördüğü ve onayladığı fiyattır.
Inventory'den rezervasyon: müşteriye "stok var" demek için evet gibi görünür, ama kampanya senaryosu gösterdi ki çoğu zaman hayır. Ordering siparişi Placed olarak kaydeder ve OrderPlaced yayınlar; Inventory event'i işleyip StockReserved ya da OutOfStock yayınlar. Sepet sayfasında "stokta" bilgisini göstermek için Ordering, Inventory'nin yayınladığı stok event'lerinden beslenen yaklaşık bir görünüm tutabilir; kesin karar rezervasyonda verilir.
Payment'tan ödeme: burada bir iş kararı var. Kart reddedildiğinde müşterinin bunu checkout ekranında, kartı değiştirebileceği anda öğrenmesi değerli. Bookstore'un hedefi bu yüzden melez: ödeme yetkilendirmesi senkron denenir ama 5 saniyelik bir sınırla; sağlayıcı bu sürede cevap vermezse sipariş PaymentPending olur ve süreç asenkron devam eder. Senkron çağrı burada bir tercih, bir zorunluluk değil, ve süresi sınırlı.
Shipping'den gönderi kaydı ve Notification'dan e-posta: kesinlikle hayır. İkisi de PaymentCaptured'ı dinleyip kendi zamanında çalışır. Müşteri kargo etiketinin oluşmasını beklemek için checkout ekranında durmamalı.
Sonuçta checkout'un senkron kalan tek bağımlılığı, sınırlı süreyle ödeme sağlayıcısı. Inventory'nin, Shipping'in ve Notification'ın deploy'ları artık müşteriye görünmüyor. Bunun bedelleri bir önceki yazının konusuydu: ara durumlar, compensation, outbox ve kullanıcıya gösterilen "onay bekleniyor" ekranı. Bu dönüşümü uçtan uca, gerçek bir broker ve outbox ile kurmak ilerideki Backend serisinin ".NET ile Mikroservis Uygulaması" yazılarının işi.
Ne Zaman Hangisi?
| Durum | Tercih | Gerekçe |
|---|---|---|
| Kullanıcı cevabı şimdi görmeli ve cevap başka service'te | Sync request/response, kısa timeout | Beklemek zorunlu; süreyi sınırla, mümkünse kopyadan oku |
| Bir şey oldu, kimlerin ilgilendiği değişebilir | Event | Yayınlayan consumer'ları bilmez, yeni consumer eklemek ucuz |
| Belirli bir service'ten iş isteniyor, sonuç sonra gelebilir | Command (queue üzerinden) | Alıcı belli, ama ayakta olması gerekmiyor |
| Başka service'in verisi sık okunuyor | Event ile local kopya | Okuma yolundan network ve temporal coupling çıkar |
| Service'ler arası yoğun, düşük latency sorgu | gRPC | Zorunlu contract, küçük payload, deadline taşıma |
| Tarayıcıdan ya da dış dünyadan erişim | HTTP + JSON | Evrensel destek, okunabilirlik, araç ekosistemi |
Tablo bir kural kitabı değil, bir başlangıç noktası. Çoğu gerçek sistem melezdir: Bookstore'da dışarıya HTTP API, service'ler arası birkaç senkron sorgu ve iş akışının geri kalanı event'lerle.
Sık Düşülen Tuzaklar
await'i asenkron mimari sanmak. Senkron bir zinciri async method'larla yazmak thread'leri serbest bırakır, temporal coupling'i bırakmaz.
Her şeyi event'e çevirmek. Kullanıcının o anda görmesi gereken bir cevabı event'lerle üretmeye çalışmak, client'ı polling'e ve arayüzü belirsizliğe iter. Cevap gerçekten şimdi gerekiyorsa senkron çağrı dürüst bir seçimdir.
Kılık değiştirmiş command'lar. Adı event olan ama tek bir consumer'ın belirli bir işi yapmasını bekleyen message'lar, hem event'in gevşekliğini hem command'ın açıklığını kaybettirir.
Timeout'suz senkron çağrı. Varsayılan HttpClient timeout'u 100 saniye. Bir checkout zincirinde bu, yavaş bir service'in bütün thread pool'unu ve kullanıcının sabrını tüketmesi demek. Her senkron çağrının bir süresi, zincirin de bir toplam bütçesi olmalı.
Zincirleme fan-out. Bir isteğin A'yı, A'nın B'yi, B'nin C'yi çağırdığı derin zincirler, hem latency'yi toplar hem de en zayıf halkayı bütün sistemin availability'si yapar. Derinlik arttıkça, ya veriyi kopyala ya da akışı event'lere böl.
Küçük Bir Deneme
★☆☆ Simülasyonda Inventory'nin kesinti süresini 5 saniyeye indir, sonra 60 saniyeye çıkar. Senkron hata sayısı ve asenkron maksimum bekleme nasıl değişiyor? Hangi süreden sonra "onay bekleniyor" ekranı da müşteri için kabul edilemez olur?
★★☆ gRPC örneğinde deadline'ı 500 milisaniyeden 3 saniyeye çıkar ve Emma sorgusunun başarılı olduğunu gör. Sonra bu sorguyu bir checkout zincirinin üçüncü halkası olarak düşün: toplam bütçe 1 saniye ise ve ilk iki çağrı 400 milisaniye sürdüyse, bu çağrıya ne kadar deadline vermelisin?
★★★ Kendi sisteminden senkron çağrı içeren bir akış seç. Her çağrı için "müşteriye cevap vermeden önce bu bilgiye gerçekten ihtiyacım var mı?" sorusunu sor ve cevabı "hayır" olanları event ya da local kopya ile yeniden tasarla. Her değişikliğin yarattığı yeni ara durumu ve compensation ihtiyacını yaz.
Service'ler arasındaki iletişimi seçtik; sıradaki soru bu iletişimin contract'larını zaman içinde kırmadan değiştirmek. Serinin bir sonraki yazısı breaking ve non-breaking change ayrımını, expand-contract'ı, Tolerant Reader'ı, event schema evolution'ı ve consumer-driven contract test'i ele alıyor.