Mert Özen Her Satırda Daha İleri
Yazılım Mimarisi Modular Monolith — #08

Module'ü Service'e Çıkarmak: Ne Zaman, Hangi Sırayla ve Geri Dönüşü Olan Adımlar

Mert Özen 7 Kas 2026 12 dk 3 görüntülenme
Module'ü Service'e Çıkarmak: Ne Zaman, Hangi Sırayla ve Geri Dönüşü Olan Adımlar

Bir module'ü service'e çıkarmanın gerekçesi "çıkarılabilir olması" değil, ölçülebilir bir baskı olmalı (bağımsız scaling, ayrı ekip, compliance, farklı availability); çıkarma da veri izolasyonu, HTTP implementation, broker ve ayrı deploy sırasıyla, her adımı geri alınabilir şekilde yapılır. Bookstore'un Payments module'ünü Ordering'e dokunmadan ayrı process'e taşıyor ve tek ayarla monolith'e geri dönüşü gösteriyoruz.

Sürüm notu: Bu yazıdaki kodlar C# 14, .NET 10, Entity Framework Core (EF Core) 10, SQL (Structured Query Language) Server 2025 ve RabbitMQ 4.4 (RabbitMQ.Client 7.2.2) ile derlenip denenmiştir.

Önce Tahmin Et

Bookstore büyüdü. Kampanya dönemlerinde ödeme sağlayıcısının yavaşladığı anlar oluyor, yeni ödeme yöntemleri için ayrı bir ekip kuruldu ve şirketin güvenlik ekibi kart verisine yakın çalışan kodun ayrı bir ortamda, ayrı erişim kurallarıyla çalışmasını istiyor. Biri "Payments'ı microservice yapalım" diyor.

İki soru. Payments'ı ayrı bir process'e taşıdığın gün, Ordering'in "bu siparişin ödeme durumu ne?" sorusunu soran kodunda kaç satır değişmeli? Ve Payments service'i bir deploy sırasında iki dakikalığına kapanırsa, bu iki dakikada verilen siparişlerin ödeme kayıtlarına ne olur?

Geçerli ve Geçersiz Gerekçeler

Bir module'ü service'e çıkarmak, ilk yazıda konuştuğumuz Microservice Premium'u o module için ödemek demek: network çağrıları, kısmi hatalar, ayrı deploy hattı, ayrı izleme. Bu bedeli ödemek için somut bir karşılık gerekiyor. Milan Jovanović'in modular monolith yazısı bunu net bir cümleyle söylüyor: çıkarılabilir olmak tek başına bir gerekçe değil. Geçerli gerekçeler ölçülebilir bir baskıdan gelir:

  • Bağımsız scaling: module'ün yükü diğerlerinden çok farklı ve onu ayrı ölçeklemek ciddi bir maliyet farkı yaratıyor.
  • Ayrı ekip ve release ritmi: bir ekip, diğerlerinin haftalık release'ini beklemekten ölçülebilir şekilde yavaşlıyor.
  • Compliance ya da güvenlik sınırı: module'ün verisi ve kodu ayrı bir ortamda, ayrı erişim kurallarıyla çalışmak zorunda.
  • Farklı availability ihtiyacı: module'ün çökmesi diğerlerini düşürmemeli ya da tam tersi, diğerlerinin bakımı onu durdurmamalı.

Geçersiz gerekçeler de en az bu kadar tanıdık: "microservice daha modern", "özgeçmişte iyi durur", "nasılsa çıkarılabilir durumda" ve "diğer module'lerin kodu karışık, bunu ayıralım". Sonuncusu bir refactoring ihtiyacı; çözümü network değil. Bookstore'un Payments için iki geçerli gerekçesi var: compliance sınırı ve ayrı ekip. Bağımsız scaling ise yok; kampanyada yük checkout'ta, ödeme sağlayıcısının cevap süresi Bookstore'un kontrolünde değil.

Çıkarmaya Hazır mı?

Önceki yazılarda kurduğumuz her şey bu adım için bir ön koşuldu. Payments'ın kendi schema'sı ve kendi veritabanı kullanıcısı var (06); başka hiçbir module onun tablolarına dokunmuyor. Diğer module'ler onu yalnız Contracts üzerinden tanıyor ve architecture test'leri bunu her build'de doğruluyor (07). Payments'a giden olgular outbox'tan geçen integration event'ler (05) ve handler'ları idempotent. Ordering'in Payments'a sorduğu tek şey bir contract:

namespace Bookstore.Payments.Contracts;

public sealed record PaymentSummary(Guid OrderId, decimal Amount, string Currency, string Status);

// Çıkarma boyunca değişmeyen tek şey bu interface
public interface IPaymentsModuleApi
{
    /// <exception cref="Bookstore.BuildingBlocks.ModuleUnavailableException">Payments cannot be reached.</exception>
    Task<PaymentSummary?> GetPaymentAsync(Guid orderId, CancellationToken ct = default);
}

Bu interface'in in-process implementation'ı Payments'ın içinde:

internal sealed class PaymentsModuleApi(PaymentsDbContext db) : IPaymentsModuleApi
{
    public Task<PaymentSummary?> GetPaymentAsync(Guid orderId, CancellationToken ct = default) =>
        db.Payments.AsNoTracking().Where(p => p.OrderId == orderId)
            .Select(p => new PaymentSummary(p.OrderId, p.Amount, p.Currency, p.Status))
            .SingleOrDefaultAsync(ct);
}

Ordering'in endpoint'i ödeme durumunu bu interface'ten soruyor:

group.MapGet("/orders/{orderId:guid}/payment", async (Guid orderId, IPaymentsModuleApi payments, CancellationToken ct) =>
{
    try
    {
        return await payments.GetPaymentAsync(orderId, ct) is { } payment
            ? Results.Ok(new { payment.Status, payment.Amount, payment.Currency })
            : Results.NotFound();
    }
    catch (ModuleUnavailableException ex)
    {
        // In-process'te hiç olmayan bir durum: module çalışıyor ama cevap veremiyor
        return Results.Problem(title: "payments.unavailable", detail: ex.Message, statusCode: 503);
    }
});

Başlangıç durumunda, her şey tek process'teyken:

$ curl -s localhost:5401/api/ordering/orders/01a1b89a-6969-746a-b2dc-a721b4a32241/payment
{"status":"Pending","amount":20.00,"currency":"EUR"}

Dört Adımda Çıkarmak

08-extraction-steps-tr.png

Adım 1: Veri İzolasyonunu Tamamla

Çıkarma, veriden başlar. Payments'ın tabloları başka bir module'ün sorgusunda geçiyorsa, ilk iş o sorguları bulmak. 06. yazıdaki schema yetkileri ve 07'deki kurallar bunu zaten sağlıyor, ama son bir kontrol değerli: Payments'ın veritabanı kullanıcısıyla uygulamayı bir süre test ortamında çalıştır ve başka bir kullanıcının payments schema'sına eriştiği bir yetki hatası görüp görmediğine bak. Bu adımda deploy değişmiyor; geri dönüş planı gerekmiyor.

Veriyi henüz ayrı bir veritabanına taşımıyoruz. Payments service'i ilk aşamada aynı SQL Server'daki payments schema'sına bağlanacak. Kod ve veriyi aynı anda taşımak, bir şey ters gittiğinde hangisinin sebep olduğunu anlamayı zorlaştırır.

Adım 2: Aynı Interface, HTTP Implementation'ı

İkinci adımda Payments'ın endpoint'i zaten var olan contract'ı HTTP (Hypertext Transfer Protocol) üzerinden sunuyor ve monolith'te aynı interface'in ikinci bir implementation'ı yazılıyor:

using System.Net;
using System.Net.Http.Json;
using Bookstore.BuildingBlocks;
using Bookstore.Payments.Contracts;
using Microsoft.Extensions.DependencyInjection;

namespace Bookstore.Payments.Client;

// Aynı interface, farklı implementation: çağrı artık network'ten geçiyor
internal sealed class PaymentsHttpClient(HttpClient http) : IPaymentsModuleApi
{
    public async Task<PaymentSummary?> GetPaymentAsync(Guid orderId, CancellationToken ct = default)
    {
        try
        {
            using HttpResponseMessage response = await http.GetAsync($"/api/payments/by-order/{orderId}", ct);
            if (response.StatusCode == HttpStatusCode.NotFound)
                return null;
            response.EnsureSuccessStatusCode();
            return await response.Content.ReadFromJsonAsync<PaymentSummary>(ct);
        }
        catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException && !ct.IsCancellationRequested)
        {
            // Network hatası, contract'ın kendi hata diline çevriliyor
            throw new ModuleUnavailableException("Payments", ex);
        }
    }
}

public static class PaymentsClientRegistration
{
    public static IServiceCollection AddPaymentsClient(this IServiceCollection services, Uri baseAddress)
    {
        services.AddHttpClient<IPaymentsModuleApi, PaymentsHttpClient>(client =>
        {
            client.BaseAddress = baseAddress;
            client.Timeout = TimeSpan.FromSeconds(5);   // 5 sn'den uzun bekleme yok
        });
        return services;
    }
}

Bu class'ın en önemli satırı catch bloğu. In-process implementation hiçbir zaman "Payments'a ulaşılamıyor" demezdi; process ayaktaysa Payments da ayaktaydı. Network'ün getirdiği bu yeni hata, contract'ın kendi diline, ModuleUnavailableException'a çevriliyor. Böylece Ordering HttpRequestException'ı, timeout'u ya da HTTP status kodlarını tanımak zorunda kalmıyor. Bu exception'ı contract'ın dokümantasyonunda baştan tanımlamak, "bu çağrı bir gün network'ten geçebilir" sorusunun 04. yazıdaki cevabıydı. Timeout'u da Bookstore'un mimari hedefindeki beş saniyeyle sınırladık. Retry, circuit breaker ve resilience pipeline'ları ayrıntılı olarak Backend kategorisinde ilerideki External Service Integration ve Resilience serisinin konusu olacak.

Ordering'in kodu değişmedi. Değişen tek şey Host'taki kayıt:

using Bookstore.BuildingBlocks.Infrastructure.Messaging;
using Bookstore.BuildingBlocks.RabbitMq;
using Bookstore.Catalog.Core;
using Bookstore.Inventory.Core;
using Bookstore.Ordering.Contracts;
using Bookstore.Ordering.Core;
using Bookstore.Payments.Client;
using Bookstore.Payments.Core;
using Bookstore.Shipping.Core;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
string db = builder.Configuration.GetConnectionString("Bookstore")!;
bool paymentsRemote = builder.Configuration["Payments:Mode"] == "Remote";

builder.Services
    .AddInProcessMessaging()
    .AddCatalogModule(db)
    .AddInventoryModule(db)
    .AddOrderingModule(db, useOutbox: true)
    .AddShippingModule(db);

if (paymentsRemote)
{
    // Payments ayrı bir process'te: aynı contract'ın HTTP implementation'ı ve broker'a köprü
    builder.Services
        .AddPaymentsClient(new Uri(builder.Configuration["Payments:BaseAddress"]!))
        .AddRabbitMq(new Uri(builder.Configuration["RabbitMq:Uri"]!))
        .ForwardToBroker<OrderPlaced>();
}
else
{
    builder.Services.AddPaymentsModule(db);
}

WebApplication app = builder.Build();
await app.Services.InitializeCatalogModuleAsync(books: 20);
await app.Services.InitializeInventoryModuleAsync(books: 20);
await app.Services.InitializeOrderingModuleAsync();
await app.Services.InitializeShippingModuleAsync();
if (!paymentsRemote)
    await app.Services.InitializePaymentsModuleAsync();
app.MapCatalogEndpoints();
app.MapOrderingEndpoints();
app.Run();

Payments:Mode ayarı, bu adımın geri dönüş planı. InProcess değeri, eski kaydı ve eski davranışı geri getiriyor; kod deploy etmeden, yalnız configuration değiştirerek.

Bu adımın sessiz bir yan etkisi de oldu. Ordering'in 07. yazıdaki tek başına test host'u, Payments contract'ını kaydetmediği için ilk koşuda kırıldı:

failed Bookstore.Ordering.Tests.PlaceOrderTests.Order_is_saved_with_its_outbox_message (25ms)
System.InvalidOperationException : Body was inferred but the method does not allow inferred body parameters.

Minimal API (Application Programming Interface), DI (Dependency Injection) container'ında bulamadığı IPaymentsModuleApi parametresini request body'si sandı. Test host'una sahte bir Payments ekleyince 22 test yine geçti. Kırılma rahatsız edici ama değerli: Ordering'in yeni bir komşusu olduğunu, onu tek başına ayağa kaldırmaya çalışan ilk yer haber verdi.

Adım 3: In-Memory Bus'tan Broker'a

OrderPlaced event'i hâlâ monolith'in outbox'ından çıkıyor ve in-process bus'a gidiyor. Payments ayrı bir process'te olduğuna göre event'in bir şekilde oraya ulaşması gerekiyor. Burada outbox'ı ve bus'ı değiştirmiyoruz; bus'a, event'i RabbitMQ'ya aktaran bir handler ekliyoruz:

// Monolith tarafı: outbox'tan gelen event'i olduğu gibi broker'a aktaran bir handler
internal sealed class ForwardToBroker<TEvent>(RabbitMqConnection connection) : IIntegrationEventHandler<TEvent>
    where TEvent : IIntegrationEvent
{
    public async Task HandleAsync(TEvent @event, CancellationToken ct)
    {
        await using IChannel channel = await connection.CreateChannelAsync(ct);
        BasicProperties props = new() { MessageId = @event.EventId.ToString(), Persistent = true, Type = typeof(TEvent).FullName };
        byte[] body = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(@event));
        await channel.BasicPublishAsync(RabbitMqConnection.Exchange, typeof(TEvent).Name, mandatory: false, props, body, ct);
    }
}

// Service tarafı: kuyruktan okuyup service içindeki in-process bus'a veren consumer
internal sealed class BrokerConsumer<TEvent>(RabbitMqConnection connection, IEventBus bus, string queue, ILogger<BrokerConsumer<TEvent>> logger)
    : BackgroundService where TEvent : IIntegrationEvent
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        IChannel channel = await connection.CreateChannelAsync(stoppingToken);
        await channel.QueueDeclareAsync(queue, durable: true, exclusive: false, autoDelete: false, cancellationToken: stoppingToken);
        await channel.QueueBindAsync(queue, RabbitMqConnection.Exchange, typeof(TEvent).Name, cancellationToken: stoppingToken);

        AsyncEventingBasicConsumer consumer = new(channel);
        consumer.ReceivedAsync += async (_, delivery) =>
        {
            try
            {
                TEvent @event = JsonSerializer.Deserialize<TEvent>(delivery.Body.Span)!;
                await bus.PublishAsync(@event, stoppingToken);
                await channel.BasicAckAsync(delivery.DeliveryTag, multiple: false);
                logger.LogInformation("Consumed {Event} {EventId}", typeof(TEvent).Name, @event.EventId);
            }
            catch (Exception ex)
            {
                logger.LogWarning("Handling failed, message requeued: {Message}", ex.Message);
                await channel.BasicNackAsync(delivery.DeliveryTag, multiple: false, requeue: true);
            }
        };
        await channel.BasicConsumeAsync(queue, autoAck: false, consumer, stoppingToken);
        await Task.Delay(Timeout.Infinite, stoppingToken);
    }
}

Monolith tarafında ForwardToBroker<OrderPlaced> sıradan bir integration event handler'ı; outbox processor onu Inventory ve Shipping'in handler'larıyla birlikte çağırıyor. Broker'a yazamazsa exception fırlatıyor ve 05. yazıdaki outbox mekanizması mesajı yeniden deniyor. Payments service tarafında BrokerConsumer, kuyruktan aldığı event'i service'in kendi in-process bus'ına veriyor ve aynı RequestPaymentWhenOrderPlaced handler'ı çalışıyor. Handler'ın tek satırı değişmedi; idempotent olduğu için RabbitMQ'nun at-least-once teslimatı da bir sorun değil.

RabbitMQ client'ını bu örnekte doğrudan kullandık, çünkü konu module'ün nasıl çıkarıldığı. Gerçek bir sistemde broker'la çalışmanın ayrıntıları (topology, dead letter, retry, schema evolution) Messaging ve Event-Driven Architecture serisinde; bir kütüphane seçeceksen 05. yazıdaki lisans notunu da hatırla.

Adım 4: Ayrı Deploy

Payments'ın yeni Host'u, monolith'teki Host'un küçük bir kopyası:

using Bookstore.BuildingBlocks.Infrastructure.Messaging;
using Bookstore.BuildingBlocks.RabbitMq;
using Bookstore.Ordering.Contracts;
using Bookstore.Payments.Core;

// Yeni Host: aynı Payments module'ü, kendi process'inde
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddInProcessMessaging()
    .AddPaymentsModule(builder.Configuration.GetConnectionString("Payments")!)
    .AddRabbitMq(new Uri(builder.Configuration["RabbitMq:Uri"]!))
    .ConsumeFromBroker<OrderPlaced>(queue: "payments.order-placed");

WebApplication app = builder.Build();
await app.Services.InitializePaymentsModuleAsync();
app.MapPaymentsEndpoints();
app.Run();

Payments module'ünün kodu (Bookstore.Payments.Core) hiç değişmedi; aynı AddPaymentsModule() bu kez başka bir process'te çağrılıyor. Üçüncü yazıda Host'u yalnız composition root olarak tuttuğumuz için bu dosya yirmi satırdan kısa. Monolith'i Payments:Mode=Remote ile, Payments service'ini de ayrı bir process olarak başlatıp bir sipariş verdik:

$ curl -s localhost:5403/api/ordering/orders/01a1b89a-a650-7143-8686-4b4f3d904e4c/payment
{"status":"Pending","amount":20.00,"currency":"EUR"}
info: Bookstore.BuildingBlocks.RabbitMq.BrokerConsumer[0]
Consumed OrderPlaced 01a1b89a-a650-7b15-b57b-2f4bb85851d7

Ödeme kaydı bu kez Payments service'inde oluştu, Ordering'in sorusu HTTP üzerinden cevaplandı. Ordering'in kodunda değişen satır sayısı, açılıştaki sorunun cevabı: sıfır.

Yeni Maliyetler

Şimdi açılıştaki ikinci soru. Payments service'ini durdurduk ve bir sipariş verdik:

$ curl -s -X POST localhost:5403/api/ordering/orders -H "Content-Type: application/json" \
    -d '{"lines":[{"bookId":"0199b1a0-0000-7000-8000-000000000003","quantity":1}]}'
{"orderId":"01a1b89b-6441-7350-8078-4019c7984378","lines":1,"total":10.00,"currency":"EUR"}

Sipariş alındı; checkout Payments'ı beklemiyor. OrderPlaced outbox'tan çıkıp broker'a ulaştı ve Payments'ın kuyruğunda bekliyor:

name	messages
payments.order-placed	1

Ama siparişin ödeme durumunu sormak artık başarısız olabiliyor:

$ curl -s localhost:5403/api/ordering/orders/01a1b89b-6441-7350-8078-4019c7984378/payment
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"payments.unavailable","status":503,"detail":"Module 'Payments' is unavailable"}

Payments service'ini yeniden başlattık; kuyruktaki event işlendi ve aynı soru cevap verdi:

info: Bookstore.BuildingBlocks.RabbitMq.BrokerConsumer[0]
Consumed OrderPlaced 01a1b89b-6441-748b-8d3d-bca92acc947e
$ curl -s localhost:5403/api/ordering/orders/01a1b89b-6441-7350-8078-4019c7984378/payment
{"status":"Pending","amount":10.00,"currency":"EUR"}

Bu dört çıktı, bir module'ü service'e çıkarmanın gerçek bedelini özetliyor. Kısmi hata artık mümkün: monolith ayakta, Payments değil. Eventual consistency artık gözle görülür: sipariş var ama ödeme kaydı birkaç saniye, hatta dakikalar sonra oluşuyor. Ve observability zorunlu: bir siparişin hikâyesi artık iki process'in log'unda ve bir broker'ın kuyruğunda; bunları birbirine bağlayan bir correlation id ve distributed tracing olmadan "bu siparişin ödemesi neden yok?" sorusu saatler sürer. Mimariye Giriş serisinin "Monolith'i Doğru Anlamak: Single-Process, Modular ve Distributed Monolith" yazısındaki availability çarpımı da artık Bookstore'un gerçeği: ödeme durumu ekranının availability'si, monolith'inkiyle Payments service'ininkinin çarpımı.

Geri Dönüş Planı

Her adım bir öncekine dönülebilir olmalı. Payments service'ini ve monolith'i durdurup monolith'i Payments:Mode=InProcess ile yeniden başlattık:

$ curl -s localhost:5404/api/ordering/orders/01a1b89b-6441-7350-8078-4019c7984378/payment
{"status":"Pending","amount":10.00,"currency":"EUR"}

Payments service'inin oluşturduğu kayıt, monolith'in içindeki Payments module'ü tarafından okundu. Bunun mümkün olmasının iki nedeni var: Payments'ın kodu monolith'ten silinmedi ve verisi henüz aynı veritabanında, aynı schema'da duruyor. Bu yüzden adımların sırası önemli. Payments'ın kodunu monolith'ten ancak service bir süre sorunsuz çalıştıktan sonra sil; verisini ayrı bir veritabanına ancak bu geri dönüş seçeneğinden vazgeçmeye hazır olduğunda taşı. AWS (Amazon Web Services) Prescriptive Guidance'ın strangler fig pattern sayfası da büyük bir monolith'i parça parça dönüştürürken aynı yaklaşımı öneriyor: çağrıları yönlendiren bir katman, eski çağıranları yeni interface'e uyarlayan bir anti-corruption layer ve geçiş süresince veriyi senkron tutan bir mekanizma. Bizim durumumuzda module sınırları sayesinde bu katmanların ikisi zaten var: yönlendirme Payments:Mode, adaptör de PaymentsHttpClient.

Sık Düşülen Tuzaklar

Kodu ve veriyi aynı anda taşımak

Service'i ayrı deploy ettiğin gün verisini de ayrı veritabanına taşırsan, bir sorun çıktığında geri dönüş, verinin de geri taşınması demektir. Önce kodu taşı, bir süre çalıştır, sonra veriyi.

Contract'ı HTTP'ye göre yeniden tasarlamak

"Artık REST (Representational State Transfer) API'si oldu" diye Ordering'in kullandığı interface'i HTTP kaynaklarına göre değiştirmek, çıkarmanın en değerli özelliğini, Ordering'in kodunun değişmemesini kaybettirir. HTTP endpoint'i contract'ın bir taşıyıcısı olmalı.

Network hatasını çağırana sızdırmak

HttpRequestException'ı Ordering'e kadar çıkarmak, Ordering'i Payments'ın nasıl çağrıldığına bağlar. Hata, client'ta contract'ın diline çevrilmeli.

Distributed monolith'e dönüşmek

Payments service'i her release'te monolith'le aynı anda deploy ediliyorsa ya da contract'taki her değişiklik iki tarafın birlikte deploy edilmesini gerektiriyorsa, elinde Mimariye Giriş serisinde anlattığımız distributed monolith var. Contract'ı geriye dönük uyumlu değiştirmeyi ve iki tarafı bağımsız deploy etmeyi baştan bir kural yap.

Sıradakini hemen çıkarmak

Bir module'ü başarıyla çıkarmak, bir sonrakini çıkarmak için gerekçe değil. Her module için aynı soruyu yeniden sor: bu module'ün ayrı bir process'e ihtiyaç duyduğu ölçülebilir bir baskı var mı?

Ne Zaman Kullanma

Modular monolith'in sana verdiği en büyük şey, bu adımları atabileceğin bir yapı; bu adımları atma zorunluluğu değil. Ekibin tek bir ekipse, haftalık deploy herkese yetiyorsa, module'lerin yük profili benzerse ve compliance ayrı bir ortam istemiyorsa, hiçbir module'ü çıkarma. Sınırlarını koru, test'lerini çalıştır ve bu yazıyı, gerçek bir baskı ortaya çıktığı güne sakla. Microservice'lere geçişin bütün bir sistem için nasıl planlanacağı, strangler fig ile legacy bir Big Ball of Mud'ın nasıl parçalanacağı ve distributed sistemin bedeli ilerideki Microservices serisinin, uçtan uca bir .NET uygulaması da Backend kategorisindeki .NET ile Mikroservis Uygulaması serisinin konusu olacak.

Küçük Bir Deneme

★☆☆ Payments service'i kapalıyken monolith'in ödeme durumu endpoint'ine 10 istek at ve her birinin ne kadar sürdüğünü ölç. Connection refused ile timeout arasındaki farkı görmek için service'in portunu dinleyen ama cevap vermeyen bir process başlat ve ölçümü tekrarla.

★★☆ PaymentsHttpClient'a, Payments'tan dönen 503 cevabını da ModuleUnavailableException'a çeviren bir dal ekle. Ordering'de hiçbir şeyin değişmediğini doğrula.

★★★ Payments'ın verisini ayrı bir veritabanına taşı: service'in connection string'ini değiştir, migration'ı orada uygula ve mevcut kayıtları taşıyan bir script yaz. Bu adımdan sonra Payments:Mode=InProcess geri dönüşü hâlâ çalışıyor mu? Çalışmıyorsa, geri dönüşü korumak için ne yapman gerekirdi?

Bu yazıyla Modular Monolith serisi tamamlandı. Tek bir deployment unit içinde sınırları gerçekten korunan module'ler kurduk: sınırları capability ve veri sahipliğiyle bulduk, .NET solution'ına döktük, module'leri sync contract'larla ve outbox'tan geçen event'lerle konuşturduk, verilerini schema seviyesinde ayırdık, sınırları build ve test seviyesinde zorladık ve sonunda bir module'ü, geri dönüş yolunu kapatmadan ayrı bir service'e taşıdık. Buradan sonraki doğal adım, Microservices serisinde bu kararın bütün bir sistem için ne anlama geldiğini incelemek.