Mert Özen Further With Every Line
Software Architecture Modular Monolith — #08

Extracting a Module into a Service: When, In What Order and With Reversible Steps

Mert Özen Nov 7, 2026 12 min 4 views
Extracting a Module into a Service: When, In What Order and With Reversible Steps

The reason to extract a module into a service must be measurable pressure (independent scaling, a separate team, compliance, different availability), not "it's extractable"; the extraction goes through data isolation, an HTTP implementation, a broker and separate deployment, with every step reversible. We move Bookstore's Payments module into its own process without touching Ordering and show one setting bringing it back into the monolith.

Version note: The code in this post was built and run with C# 14, .NET 10, Entity Framework Core (EF Core) 10, SQL (Structured Query Language) Server 2025 and RabbitMQ 4.4 (RabbitMQ.Client 7.2.2).

Guess First

Bookstore has grown. During promotions there are moments when the payment provider slows down, a separate team has been formed for new payment methods, and the company's security team wants the code that works close to card data to run in a separate environment with separate access rules. Someone says "let's make Payments a microservice".

Two questions. On the day you move Payments into a separate process, how many lines must change in the Ordering code that asks "what is the payment status of this order?" And if the Payments service goes down for two minutes during a deploy, what happens to the payment records of orders placed in those two minutes?

Valid and Invalid Reasons

Extracting a module into a service means paying, for that module, the Microservice Premium we discussed in the first post: network calls, partial failures, a separate deploy pipeline, separate monitoring. Paying that price needs a concrete return. Milan Jovanović's modular monolith article says it in one clear sentence: being extractable alone is not a reason. Valid reasons come from measurable pressure:

  • Independent scaling: the module's load is very different from the others and scaling it separately makes a real cost difference.
  • A separate team and release cadence: one team is measurably slowed down by waiting for everyone else's weekly release.
  • A compliance or security boundary: the module's data and code have to run in a separate environment with separate access rules.
  • Different availability needs: the module crashing must not bring the others down, or conversely, maintenance on the others must not stop it.

The invalid reasons are just as familiar: "microservices are more modern", "it looks good on a CV", "it's extractable anyway" and "the other modules' code is messy, let's split this one off". The last is a refactoring need; its solution is not the network. Bookstore has two valid reasons for Payments: a compliance boundary and a separate team. Independent scaling is not one of them; during promotions the load is at checkout, and the payment provider's response time is not under Bookstore's control.

Is It Ready to Extract?

Everything we built in the earlier posts was a precondition for this step. Payments has its own schema and its own database user (06); no other module touches its tables. The other modules know it only through Contracts, and the architecture tests verify that on every build (07). The facts that reach Payments are integration events going through the outbox (05), and its handlers are idempotent. The one thing Ordering asks Payments is a contract:

namespace Bookstore.Payments.Contracts;

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

// The one thing that does not change during extraction is this interface
public interface IPaymentsModuleApi
{
    /// <exception cref="Bookstore.BuildingBlocks.ModuleUnavailableException">Payments cannot be reached.</exception>
    Task<PaymentSummary?> GetPaymentAsync(Guid orderId, CancellationToken ct = default);
}

The in-process implementation of this interface lives inside Payments:

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's endpoint asks this interface for the payment status:

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)
    {
        // A case that never happened in-process: the module exists but cannot answer
        return Results.Problem(title: "payments.unavailable", detail: ex.Message, statusCode: 503);
    }
});

In the starting state, with everything in one process:

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

Extracting in Four Steps

08-extraction-steps-en.png

Step 1: Finish Data Isolation

Extraction starts with data. If Payments' tables appear in another module's query, the first job is to find those queries. The schema permissions from post 06 and the rules from post 07 already ensure this, but a final check is worthwhile: run the application in a test environment with Payments' database user for a while and look for a permission error where some other user touched the payments schema. Deployment doesn't change in this step; no rollback plan is needed.

We are not moving the data into a separate database yet. In the first phase the Payments service will connect to the payments schema on the same SQL Server. Moving code and data at the same time makes it harder to tell which one caused a problem when something goes wrong.

Step 2: Same Interface, an HTTP Implementation

In the second step Payments' endpoint serves the existing contract over HTTP (Hypertext Transfer Protocol), and a second implementation of the same interface is written in the monolith:

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

namespace Bookstore.Payments.Client;

// Same interface, different implementation: the call now crosses the network
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)
        {
            // A network failure is translated into the contract's own failure language
            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);   // never wait longer than 5 s
        });
        return services;
    }
}

The most important line in this class is the catch block. The in-process implementation never said "Payments cannot be reached"; if the process was up, Payments was up. This new failure that the network brings is translated into the contract's own language, ModuleUnavailableException. That way Ordering doesn't have to know about HttpRequestException, timeouts or HTTP status codes. Defining this exception in the contract's documentation up front was the answer from post 04 to "this call may cross the network one day". We also capped the timeout at the five seconds from Bookstore's architecture goals. Retries, circuit breakers and resilience pipelines will be covered in detail in the upcoming External Service Integration and Resilience series in the Backend category.

Ordering's code didn't change. The only thing that changed is the registration in the Host:

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 in a separate process: the HTTP implementation of the same contract and a bridge to the broker
    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();

The Payments:Mode setting is this step's rollback plan. The value InProcess brings back the old registration and the old behaviour, without deploying code, just by changing configuration.

This step also had a quiet side effect. Ordering's standalone test host from post 07 broke on its first run because it didn't register the Payments contract:

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 APIs (Application Programming Interfaces) took the IPaymentsModuleApi parameter, which it couldn't find in the DI (Dependency Injection) container, for the request body. After adding a fake Payments to the test host, all 22 tests passed again. The break is annoying but valuable: the first place that tries to boot Ordering on its own told us Ordering has a new neighbour.

Step 3: From the In-Memory Bus to a Broker

The OrderPlaced event still leaves the monolith's outbox and goes to the in-process bus. Since Payments now lives in a separate process, the event has to reach it somehow. We don't change the outbox or the bus here; we add a handler to the bus that forwards the event to RabbitMQ:

// Monolith side: a handler that forwards the event coming from the outbox to the broker as is
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 side: a consumer that reads from the queue and hands the event to the in-process bus inside the service
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);
    }
}

On the monolith side ForwardToBroker<OrderPlaced> is an ordinary integration event handler; the outbox processor calls it alongside Inventory's and Shipping's handlers. If it cannot write to the broker it throws, and the outbox mechanism from post 05 retries the message. On the Payments service side, BrokerConsumer hands the event it takes from the queue to the service's own in-process bus, and the same RequestPaymentWhenOrderPlaced handler runs. Not a single line of the handler changed; because it is idempotent, RabbitMQ's at-least-once delivery isn't a problem either.

We used the RabbitMQ client directly in this example because the subject is how the module is extracted. In a real system the details of working with a broker (topology, dead letters, retries, schema evolution) are in the Messaging and Event-Driven Architecture series; if you are going to pick a library, remember the licensing note in post 05 as well.

Step 4: Separate Deployment

Payments' new Host is a small copy of the monolith's Host:

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

// A new Host: the same Payments module, in its own process
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();

The Payments module's code (Bookstore.Payments.Core) didn't change at all; the same AddPaymentsModule() is called, this time in another process. Because we kept the Host as nothing but the composition root in post 03, this file is under twenty lines. We started the monolith with Payments:Mode=Remote, started the Payments service as a separate process and placed an order:

$ 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

This time the payment record was created in the Payments service, and Ordering's question was answered over HTTP. The number of lines that changed in Ordering's code, the answer to the opening question: zero.

New Costs

Now the second opening question. We stopped the Payments service and placed an order:

$ 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"}

The order was accepted; checkout does not wait for Payments. OrderPlaced left the outbox, reached the broker and is waiting in Payments' queue:

name	messages
payments.order-placed	1

But asking for the order's payment status can now fail:

$ 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"}

We restarted the Payments service; the queued event was processed and the same question got an answer:

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"}

These four outputs sum up the real cost of extracting a module into a service. Partial failure is now possible: the monolith is up, Payments isn't. Eventual consistency is now visible: the order exists but the payment record appears seconds, even minutes, later. And observability becomes mandatory: an order's story now lives in the logs of two processes and the queue of a broker; without a correlation id and distributed tracing to tie them together, "why does this order have no payment?" takes hours to answer. The availability multiplication from the Introduction to Architecture series post "Understanding the Monolith: Single-Process, Modular, and Distributed Monoliths" is now Bookstore's reality too: the availability of the payment status screen is the product of the monolith's and the Payments service's.

The Rollback Plan

Every step must be reversible to the previous one. We stopped the Payments service and the monolith, and restarted the monolith with Payments:Mode=InProcess:

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

The record created by the Payments service was read by the Payments module inside the monolith. Two things make this possible: Payments' code was not deleted from the monolith, and its data is still in the same database, in the same schema. That's why the order of the steps matters. Delete Payments' code from the monolith only after the service has run without problems for a while; move its data into a separate database only when you are ready to give up this rollback option. The AWS (Amazon Web Services) Prescriptive Guidance page on the strangler fig pattern recommends the same approach for transforming a large monolith piece by piece: a layer that routes calls, an anti-corruption layer that adapts old callers to the new interface, and a mechanism that keeps data in sync during the transition. In our case, thanks to module boundaries, two of these layers already exist: the routing is Payments:Mode and the adapter is PaymentsHttpClient.

Common Pitfalls

Moving code and data at the same time

If you move the service's data into a separate database on the day you deploy it separately, rolling back after a problem means moving the data back as well. Move the code first, run it for a while, then the data.

Redesigning the contract around HTTP

Changing the interface Ordering uses into HTTP resources because "it's a REST (Representational State Transfer) API now" loses the most valuable property of the extraction: Ordering's code not changing. The HTTP endpoint should be a carrier for the contract.

Leaking network failures to the caller

Letting HttpRequestException propagate up to Ordering ties Ordering to how Payments is called. The failure should be translated into the contract's language in the client.

Turning into a distributed monolith

If the Payments service is deployed together with the monolith on every release, or every change to the contract requires both sides to deploy together, you have the distributed monolith we described in the Introduction to Architecture series. Make backwards-compatible contract changes and independent deployment of both sides a rule from the start.

Extracting the next one right away

Successfully extracting one module is not a reason to extract the next. Ask the same question again for each module: is there measurable pressure for this module to need a separate process?

When Not to Use It

The biggest thing a modular monolith gives you is a structure in which you can take these steps, not an obligation to take them. If you are a single team, a weekly deploy works for everyone, the modules' load profiles are similar and compliance doesn't demand a separate environment, don't extract any module. Protect your boundaries, run your tests and keep this post for the day real pressure appears. How to plan a move to microservices for a whole system, how to break up a legacy Big Ball of Mud with the strangler fig and what a distributed system costs will be the subject of the upcoming Microservices series, and an end-to-end .NET microservices application the subject of an upcoming series in the Backend category.

A Small Experiment

★☆☆ With the Payments service stopped, send 10 requests to the monolith's payment status endpoint and measure how long each takes. To see the difference between connection refused and a timeout, start a process that listens on the service's port but never answers, and repeat the measurement.

★★☆ Add a branch to PaymentsHttpClient that also translates a 503 response from Payments into ModuleUnavailableException. Confirm that nothing changes in Ordering.

★★★ Move Payments' data into a separate database: change the service's connection string, apply the migration there and write a script that moves the existing records. After this step, does the Payments:Mode=InProcess rollback still work? If not, what would you have needed to do to keep it?

This post completes the Modular Monolith series. We built modules whose boundaries are genuinely protected inside a single deployment unit: we found the boundaries through capabilities and data ownership, poured them into a .NET solution, made the modules talk through synchronous contracts and events that go through an outbox, separated their data at schema level, enforced the boundaries at build and test level, and finally moved one module into a separate service without closing the way back. The natural next step is to examine, in the Microservices series, what this decision means for a whole system.