When to Extract a Module Into a Microservice

When to Extract a Module Into a Microservice

6 min read··

dotnetmicroservicesmodular-monolith

Extract a module into a microservice only when you have a concrete driver: independent scaling, a separate deployment cadence, a different technology stack, or hard fault isolation. Without one of those, extraction adds operational cost for no benefit. The module also has to be ready: a clean public API, isolated data, and event-based communication. Here are the signals, the readiness checklist, and an extraction process that keeps a rollback path open.

The Extraction Promise

"We can always extract it into a microservice later" is the promise that sells the modular monolith. It's a real promise, but it depends on knowing when "later" has arrived, and how to extract without breaking what already works. Extracting a module means taking code that runs in-process inside the monolith and deploying it as a separate service that communicates over the network.

Extract too early, before the module boundaries are stable, and you end up with a distributed monolith. Wait too long, and extraction becomes a multi-month rewrite.

The key is understanding the signals that tell you extraction is the right move.

Decision flowchart: if a module lacks a clean API, isolated data, and event-based communication, fix the monolith first; if it has them but no concrete driver, keep it an in-process module; only with a concrete driver like scaling or fault isolation do you extract it via strangler fig

When Should You Extract a Module?

Independent Scaling Requirements

If one module needs 10x the compute resources of the rest, deploying everything together wastes resources. A notification module sending millions of emails shouldn't force you to scale the entire application.

Before reaching for extraction, check whether scaling the whole monolith horizontally is cheaper. It often is, until the load asymmetry gets extreme.

Module                   | Avg Load      | Peak Load
-------------------------|---------------|----------
Catalog                  | Low           | Low
Ordering                 | Medium        | Medium
NotificationProcessing   | Low           | Very High  ← extraction candidate
UserManagement           | Low           | Low

Different Deployment Cadences

If one team needs to deploy their module five times a day while the rest deploys weekly, they're blocked by the monolith's deployment cycle. Independent deployment is a microservice benefit that matters here.

Technology Requirements

A module might benefit from a different technology stack. Maybe the search module needs Elasticsearch-native code, or the ML pipeline needs Python. In a monolith, you're locked to .NET for everything.

Fault Isolation

If a bug in one module crashes the entire application, extraction provides fault isolation. The notification module throwing an out-of-memory exception shouldn't take down the ordering module.

Signs You Should NOT Extract

The Boundaries Are Still Shifting

If module boundaries are still changing - endpoints move between modules, shared types keep growing - extraction will lock in the wrong boundaries as network contracts.

Strong Data Coupling

If two modules frequently join each other's data, separating them means replacing SQL joins with API calls. That's a performance and reliability hit. Fix the data isolation first.

You Want to "Try Microservices"

Extracting a module to learn microservices is expensive learning. The modular monolith gives you most of the modularity benefits without the operational complexity.

Small Team

A modular monolith suits teams of roughly 2-15 developers. If your engineering team is still inside that range, the coordination overhead of microservices usually outweighs the benefits. A modular monolith with clear module communication is simpler to operate.

Readiness Checklist

Before extracting, verify these preconditions:

// Your module should already have:

// 1. Clean public API (no direct database access from other modules)
public interface ICatalogModule
{
    Task<ProductResponse> GetProductAsync(Guid productId);
    Task<bool> CheckAvailabilityAsync(Guid productId, int quantity);
    Task ReserveStockAsync(Guid orderId, List<OrderItem> items);
}

// 2. Own database/schema (data isolation enforced)
public class CatalogDbContext : DbContext
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("catalog");
    }
}

// 3. Event-based communication (not synchronous method calls)
public class OrderPlacedEventHandler
    : IIntegrationEventHandler<OrderPlacedIntegrationEvent>
{
    public async Task HandleAsync(
        OrderPlacedIntegrationEvent @event,
        CancellationToken cancellationToken = default) { /* ... */ }
}

The IIntegrationEvent contracts and handler abstractions come from the shared kernel.

If any of these are missing, fix them first. Extraction without these foundations creates a distributed monolith.

The Extraction Process

Step 1: Verify Data Isolation

Ensure the module uses its own database or schema. Query the database to find any cross-module references:

-- Find foreign keys crossing module boundaries (PostgreSQL)
SELECT
    con.conname AS constraint_name,
    child_ns.nspname AS child_schema,
    child.relname AS child_table,
    parent_ns.nspname AS referenced_schema,
    parent.relname AS referenced_table
FROM pg_constraint con
JOIN pg_class child ON con.conrelid = child.oid
JOIN pg_namespace child_ns ON child.relnamespace = child_ns.oid
JOIN pg_class parent ON con.confrelid = parent.oid
JOIN pg_namespace parent_ns ON parent.relnamespace = parent_ns.oid
WHERE con.contype = 'f'
  AND child_ns.nspname <> parent_ns.nspname;

If you find cross-module foreign keys, remove them and replace them with eventual consistency through integration events.

Step 2: Replace In-Process Communication With HTTP/Messaging

Module interfaces that were resolved via dependency injection now need to go over the network.

// Before: In-process call
public class OrderService
{
    private readonly ICatalogModule _catalog;

    public async Task PlaceOrder(PlaceOrderCommand command)
    {
        var available = await _catalog.CheckAvailabilityAsync(
            command.ProductId, command.Quantity);
    }
}

// After: HTTP client call
public class CatalogApiClient : ICatalogModule
{
    private readonly HttpClient _httpClient;

    public CatalogApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<bool> CheckAvailabilityAsync(
        Guid productId, int quantity)
    {
        var response = await _httpClient.GetAsync(
            $"/api/catalog/products/{productId}/availability?quantity={quantity}");

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<bool>();
    }

    // GetProductAsync and ReserveStockAsync follow the same pattern
}

The key insight: the ICatalogModule interface doesn't change. Only the implementation switches from in-process to HTTP. This is why clean module APIs matter.

The new network hop also brings network failure modes. Wrap the HTTP client with retries and a circuit breaker, and set explicit timeouts. An in-process call could never time out; this one can.

Step 3: Replace In-Memory Events With a Message Broker

If you were using an in-memory event bus, switch to a real broker (RabbitMQ, Azure Service Bus) through a library like MassTransit. The publishing call barely changes (_publishEndpoint is MassTransit's IPublishEndpoint):

// Before: in-process event bus
await _eventBus.PublishAsync(integrationEvent, ct);

// After: message broker publish with MassTransit
await _publishEndpoint.Publish(integrationEvent, ct);

The outbox pattern becomes critical here since network calls to the broker can fail.

Step 4: Deploy Independently

Create a separate deployment pipeline for the extracted module. It gets its own:

  • Repository (or folder in a monorepo)
  • CI/CD pipeline
  • Container/hosting environment
  • Database instance

Strangler Fig Approach

Don't extract everything at once. Use the strangler fig pattern:

  1. Deploy the new microservice alongside the monolith
  2. Route traffic for the extracted module to the new service
  3. Keep both running until the new service is proven stable
  4. Remove the module from the monolith

This gives you a rollback path if the extraction causes issues.

I walk through this migration in detail in Breaking It Down: How to Migrate Your Modular Monolith to Microservices.

Key Takeaways

  1. Extract when you have concrete scaling, deployment, or fault-isolation needs - not because microservices are trendy.
  2. Verify your module has clean APIs, isolated data, and event-based communication before extracting.
  3. The module interface stays the same; only the implementation changes from in-process to network calls.
  4. Replace in-memory events with a message broker and use the outbox pattern for reliability.
  5. Use the strangler fig approach for safe, incremental extraction.
  6. If boundaries are unstable or data is tightly coupled, fix the monolith first.

Thanks for reading, and stay awesome!


Frequently Asked Questions

When should you extract a module into a microservice?

Extract when you have a concrete driver: the module needs independent scaling, a separate deployment cadence, a different technology stack, or hard fault isolation. Without one of these, extraction adds operational cost for no benefit.

What must be in place before extracting a module?

The module needs a clean public API, its own database or schema with no cross-module foreign keys, and event-based communication with other modules. If any of these are missing, extraction produces a distributed monolith.

Does extracting a module require changing its public interface?

No, and that is the point. Consumers keep calling the same module interface; only the implementation changes from an in-process call to an HTTP or messaging client. Clean contracts make the transport swappable.

What is a distributed monolith and how do you avoid it?

A distributed monolith is a set of services that share data, deploy together, and fail together. You avoid it by extracting only modules with stable boundaries, isolated data, and asynchronous communication.

Can you roll back a module extraction?

Yes, if you use a strangler fig approach: run the extracted service alongside the monolith, route traffic gradually, and keep the in-process implementation until the service is proven. Rollback is then just a routing change.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.