# When Microservices Cost More Than They Deliver

Microservices can be a sensible response to scale, independent teams and genuinely different deployment needs. They can also be an expensive starting assumption.

Imagine an online store that was divided into Catalog, Basket, Ordering, Payments, Stock and Notifications services before it had many customers. Each service has its own ASP.NET Core API, deployment pipeline, Azure hosting resource, database or schema, health checks, dashboards and alerts. Azure Service Bus carries events between them. An API gateway routes the public traffic. The design looks prepared for enormous growth. The actual store receives a few hundred orders on a good day, and one development team changes all six services. Adding a field to the checkout page now requires contract changes in several repositories. A failed order can take an afternoon to trace through API gateway logs, distributed traces, Service Bus deliveries and multiple databases. Developers run containers, emulators and half the cloud estate just to reproduce a bug. Meanwhile, the monthly Azure bill reflects the number of architectural pieces much more clearly than the amount of customer traffic.

At some point the team has to admit that the architecture is consuming more engineering capacity than it creates. That admission is useful. It gives the team permission to design for the system and organisation that actually exist, not one that might.

For this store, the appropriate destination is a modular monolith, one deployable .NET application, divided into strongly enforced business modules. The move can reduce infrastructure costs, shorten request paths, simplify debugging and make changes easier to deliver. It can also be completed incrementally, without replacing the whole system in one release.

This article explains how to recognise that position, why a modular monolith fits it better, and how to make the move with .NET 10, C# 14 and EF Core 10.

## The online store we are starting with

The original system has six services because those names describe sensible business capabilities. The error was assuming that every business boundary also needed a network, process and deployment boundary.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/c463cff5-1d11-410e-b7db-dfae493273fe.png align="center")

There is nothing inherently broken in this diagram. If separate teams owned Catalog and Ordering, if Stock had a sharply different load profile, or if Payments required process level isolation, these boundaries could be valuable. The trouble begins when the expected benefits never arrive. All services are owned by the same five developers. They are released together because most features cross service boundaries. Traffic is comfortably handled by one modest application instance. The databases are small. Independent scaling exists as a capability in the deployment platform, but nobody has ever needed to use it. The team is paying the distribution cost without using the independence it bought.

## How you know microservices are working against you

Regret alone is a poor reason to change an architecture. The team needs evidence that the current shape is making delivery, reliability or cost worse. The following symptoms are especially important when several appear together.

### Azure costs are high relative to the workload

Every service adds a collection of chargeable resources or consumes a share of them. There is compute for each API and worker, database capacity, message broker usage, private endpoints, container registries, gateways, log ingestion, retention and outbound traffic. Production like test environments multiply much of that cost.

Some Azure services can scale to zero or share capacity, so six services do not automatically cost six times as much as one. Even then, a distributed system tends to produce more billable activity. A checkout that crosses four services creates more network calls, logs, traces and message operations than the same work inside one process. Azure Monitor charges for log ingestion and retention, while Service Bus has operation and, depending on tier, base or capacity charges. The architecture has created more things that must be observed and kept available.

Look at cost by business transaction rather than only total spend. Divide the monthly platform cost by completed orders, active customers or another useful business measure. Then compare it with the traffic and availability the system actually handles. A large estate serving a tiny workload deserves examination even when each individual Azure resource appears inexpensive.

Dont build the migration business case from an invented percentage saving. Export the real Azure Cost Management data and model the proposed destination. Include production, staging, test, networking, monitoring and operational support. The likely saving comes from consolidation, but its size depends on the tiers and commitments already in use.

### The traffic never justified independent scaling

Microservices allow Catalog to scale independently from Ordering. That helps when browsing traffic is enormous and order traffic is comparatively small. It offers little when the entire store peaks at a level one ASP.NET Core process can handle with comfortable headroom.

Measure peak requests per second, CPU, memory, database utilisation, queue depth and latency. Pay attention to seasonal peaks rather than relying on monthly averages. Then load test a representative single process version. If one or two instances can handle the expected load with safe headroom, independent service scaling is a facility rather than a present requirement. A modular monolith can still scale horizontally. Azure can run several replicas of the same application behind a load balancer. The difference is that every module scales together. For a low traffic store, thats often a reasonable trade.

### One team owns almost every service

Independent deployability becomes valuable when teams can exercise it independently. If the same developers attend every planning meeting, review every repository and support every production service, process boundaries have not created team autonomy. They have spread one team across a larger operational surface. Microsoft's current microservices readiness guidance asks whether teams are cross functional, aligned to subdomains and capable of building and operating their services independently. Those questions expose a common mismatch. A six-service architecture owned by one small team often turns each change into coordination with itself.

Repository count can hide this. Six repositories do not amount to six autonomous capabilities when a single feature requires coordinated pull requests and an agreed release order across four of them.

### Debugging has become a distributed investigation

In a single process, a debugger can follow a checkout from the endpoint into ordering, stock and pricing code. In the current microservices design, the same journey may cross HTTP, a queue and several data stores. Reproducing it requires the correct versions of several services and the right messages, configuration and data.

Good distributed tracing improves this experience, but it has a cost of its own. Trace context must flow through HTTP and asynchronous messages. Logs need consistent fields. Sampling has to preserve useful traces without creating excessive ingestion charges. Clock differences and redeliveries complicate the timeline. A developer still has to reason about partial failure even when the tooling is excellent. If the team regularly asks which service failed first, searches several Application Insights resources, or cannot reproduce a production path locally, distribution is adding a meaningful tax to ordinary support work.

### Simple changes cross several boundaries

Suppose the business adds a delivery promise to the checkout response. Ordering needs stock availability and fulfilment lead time. The public contract changes, internal clients change, tests change and deployments may need to occur in a compatible sequence. If messages carry the new field, event versioning enters the work as well. Microservices are designed to limit coordinated change by placing stable boundaries around business capabilities. If most product work crosses those boundaries, either the boundaries are in the wrong places or the domain has not reached the level of independence the architecture assumed. Track how many repositories, pipelines and services are touched by a typical feature. Also track how often services are released together. Frequent coordinated releases show that the system behaves like a distributed monolith, regardless of what the deployment diagram says.

### Partial failure dominates otherwise simple workflows

An order request can succeed in Payments and time out before Ordering receives the response. Stock can be reserved while the order write fails. A message can be delivered twice. The team now needs idempotency, retries, timeouts, circuit breakers, dead letter handling, reconciliation and compensation.

Those patterns are essential in a distributed system. They are also additional product code and operational work. Microsoft describes partial failure as normal in microservices and recommends explicit handling for timeouts, retries, duplicate side effects and degraded dependencies. If the workload never needed distribution, much of this code exists to manage a property introduced by the architecture itself.

Some asynchronous behaviour remains valuable in a modular monolith. Sending a receipt should not hold up checkout, for example. The important change is that the team chooses asynchronous boundaries for business or resilience reasons instead of using them because every capability happens to live in another process.

### Local development is disproportionately difficult

A new developer should be able to run the store and place a test order without learning the production topology first. If local development needs six APIs, a broker, several databases, secrets and carefully seeded data, feedback becomes slow and fragile. Containers and orchestration can automate that setup. They cannot remove the underlying number of moving parts. When developers routinely point local services at shared cloud environments because running the complete system is too difficult, isolation and repeatability have already suffered.

### Operational work is crowding out product work

Count the resources maintained for this one store. Then look at the delivery backlog. If a small team spends a large part of its capacity keeping service scaffolding consistent, the architecture is influencing what the business can afford to build. This is one of the clearest reasons to consolidate. Engineering time is a platform cost even though it does not appear on the Azure invoice.

## Confirm the diagnosis with data

The decision becomes easier when the team writes down what it expected from microservices and whether those benefits were realised.

| Expected benefit | What to measure | Evidence that it has not materialised |
| --- | --- | --- |
| Independent deployment | Services changed and released per feature | Most releases require several services or a coordinated release window |
| Independent scaling | Per-service load and scaling events | Services have similar low utilisation and have never needed separate scaling |
| Team autonomy | Ownership and cross-team coordination | One team owns nearly everything and reviews changes across all services |
| Fault isolation | Incident propagation and recovery | A dependency failure breaks the whole checkout despite separate processes |
| Faster delivery | Lead time, deployment frequency and failure rate | Contract coordination and pipeline work have increased lead time |
| Technology freedom | Runtime and storage choices | Every service uses the same .NET and Azure stack, with duplicated setup |
| Lower operational risk | Incidents, alerts and mean time to restore | Troubleshooting requires several systems and recovery is slower |

Record this in an architecture decision record. Include the current traffic, growth forecast, service ownership, monthly Azure cost, deployment frequency and recent incident evidence. State what would cause the decision to be reviewed later. Architecture should respond to changed conditions, and that includes the possibility that a module may eventually deserve extraction again.

## What a modular monolith changes

A modular monolith runs the core application in one process and is deployed as one unit. Internally, it is divided by business capability. Catalog owns products and prices. Ordering owns orders. Stock owns inventory. Each module exposes a small contract and keeps the rest of its implementation inaccessible. The word *modular* carries most of the responsibility. Consolidating six services into one project full of controllers, repositories and shared entities would produce short-term simplicity followed by steadily increasing coupling. The target needs boundaries that can be understood, tested and enforced.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/21b73f0f-9b5d-439a-8a7d-8af98c856703.png align="center")

The business boundaries remain visible. The network boundaries are removed where they no longer pay for themselves.

Calls between modules can now be ordinary asynchronous C# method calls through explicit contracts. A request keeps the same trace and logging scope. Exceptions retain their original stack. DTO mapping may still be appropriate at module boundaries, but JSON serialisation and network transport disappear from the internal path.

The application can use one Azure hosting resource, one deployment pipeline and, when appropriate, one Azure SQL database with a schema per module. Service Bus remains available for work that genuinely needs durable asynchronous delivery or communication with external systems. It no longer has to carry every internal interaction.

### The deployment shape now matches the team shape

One team owns one deployable application. A feature spanning Catalog and Ordering can be changed atomically in one pull request. The compiler detects incompatible contract changes. One build verifies the complete combination, and one deployment moves it into production. Modules still allow developers to work in separate areas. Ownership can be expressed with CODEOWNERS, project references and architecture tests. The team gains code-level separation without pretending it has independent service teams.

### Calls become cheaper and easier to follow

An in-process call avoids DNS, TLS, serialisation, network queues, retries and an additional hosting hop. That usually reduces latency, although the amount should be measured in the real application. More importantly, it removes a failure boundary from a business operation.

OpenTelemetry spans can still be created around module operations where they add diagnostic value. The trace is now a description of business work rather than a reconstruction of a request scattered across processes.

### Infrastructure can be consolidated

The target may need one Container App or App Service, one database, one Application Insights resource and a much smaller messaging footprint. Non-production environments shrink in the same way. Private networking, managed identities, access policies and alert rules also become easier to manage.

The saving comes from the resources actually removed. Combining source repositories while leaving every database, broker and hosting plan untouched will have little effect on the bill.

### Data consistency becomes easier to choose

Separate services usually prevent a normal database transaction from covering the complete checkout. A modular monolith can place data in one physical database while retaining schema ownership. That makes a local transaction possible when a business invariant genuinely requires one. This does not mean every module should update every table. A shared database is an operational boundary, not an invitation to abandon ownership. Modules should continue to use their own `DbContext`, migrations and schema. Cross module changes should go through module contracts or a deliberately designed application workflow.

If two supposedly separate modules constantly need one atomic transaction, examine the boundary. Stock reservation for checkout may belong to a broader Checkout capability, while warehouse replenishment remains a distinct Inventory concern. Moving to a modular monolith gives the team room to correct boundaries that were previously hidden behind service names.

### Scaling remains available at the level the store needs

The Store API can run as two or more replicas for availability and peak load. All modules scale together, which uses some extra capacity if Catalog is busier than Payments. At this workload, the simplicity is worth more than fine grained scaling.

If image processing later consumes most of the CPU, that specific workload can run as a separate worker. If Catalog eventually serves thousands of times more traffic than checkout, it becomes a candidate for extraction. A modular design keeps that option open without paying for it today.

## What you give up by consolidating

The move has real trade-offs. One deployment means a defect in Catalog can require rolling back changes delivered to Ordering at the same time. A memory leak in one module can affect the complete process. All modules share the runtime, deployment cadence and horizontal scaling unit. Process level security isolation disappears for the modules brought inside the host. These costs need to be compared with the current system rather than an ideal microservices implementation. If services already release together, share one team and fail as a chain, much of the theoretical independence is not being used.

Keep a service separate when its boundary has concrete value. The store might leave a card payment component isolated because of compliance, secret handling or a third party certification process. A CPU-heavy product image pipeline might need independent scaling. A notification worker may stay separate if it has long running jobs and a different release cadence. Consolidation should follow evidence just as the original microservices decision should have done.

## Design the target before moving code

The target is one deployment, not necessarily one project. Separate projects provide a compile time boundary and make dependencies visible in the solution graph. The example below uses .NET 10, the current LTS release, with C# 14 and EF Core 10. At the time of writing, .NET 10 and EF Core 10 are supported until November 2028.

```text
src/
  Store.Api/
    Program.cs

  Modules/
    Catalog/
      Store.Catalog/
        Application/
        Domain/
        Infrastructure/
        CatalogModule.cs
      Store.Catalog.Contracts/

    Ordering/
      Store.Ordering/
        Application/
        Domain/
        Infrastructure/
        OrderingModule.cs
      Store.Ordering.Contracts/

    Stock/
      Store.Stock/
      Store.Stock.Contracts/

    Payments/
      Store.Payments/
      Store.Payments.Contracts/

tests/
  Store.ArchitectureTests/
  Store.Catalog.IntegrationTests/
  Store.Ordering.IntegrationTests/
  Store.Api.FunctionalTests/
```

`Store.Api` is the composition root. It references each module so it can register services and map endpoints. `Store.Ordering` can reference `Store.Catalog.Contracts`, but it cannot reference the Catalog implementation project. Catalog does not reference Ordering. The Contracts projects contain small integration-facing records and interfaces, not domain entities.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/9a8a0b45-1717-4cd0-a4ca-f7cf68f81e29.png align="center")

Avoid a large `Store.Shared` project. It tends to become a route around every boundary. A genuinely small shared kernel can hold stable concepts such as a strongly typed currency value, but shared request models, repositories and base services will quickly couple the modules again.

The application projects target .NET 10 and enable the current compiler defaults:

```xml
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <LangVersion>14.0</LangVersion>
    <AnalysisLevel>latest-recommended</AnalysisLevel>
  </PropertyGroup>
</Project>
```

Explicitly setting `LangVersion` is optional when targeting .NET 10, but it makes the example's intent clear. In a real repository, prefer the language version supported by the target framework and build environment over `preview` or an unbounded value.

## Compose modules in one ASP.NET Core host

The host should know which modules exist, while avoiding knowledge of their internal handlers and data access.

```csharp
using Store.Catalog;
using Store.Ordering;
using Store.Payments;
using Store.Stock;

WebApplicationBuilder builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();
builder.Services.AddOpenApi();
builder.Services.AddSingleton(TimeProvider.System);

builder.Services
    .AddCatalogModule(builder.Configuration)
    .AddStockModule(builder.Configuration)
    .AddPaymentsModule(builder.Configuration)
    .AddOrderingModule(builder.Configuration);

WebApplication app = builder.Build();

app.UseExceptionHandler();
app.UseHttpsRedirection();

app.MapOpenApi();
app.MapCatalogEndpoints();
app.MapOrderingEndpoints();

await app.RunAsync();
```

Each module supplies a small public registration surface. Everything registered behind it can remain internal.

```csharp
namespace Store.Catalog;

public static class CatalogModule
{
    public static IServiceCollection AddCatalogModule(
        this IServiceCollection services,
        IConfiguration configuration)
    {
        string connectionString = configuration.GetConnectionString("Store")
            ?? throw new InvalidOperationException(
                "The Store database connection string is missing.");

        services.AddDbContext<CatalogDbContext>(options =>
            options.UseSqlServer(
                connectionString,
                sql => sql.MigrationsHistoryTable(
                    "__EFMigrationsHistory",
                    CatalogDbContext.Schema)));

        services.AddScoped<IProductCatalogue, ProductCatalogue>();

        return services;
    }

    public static IEndpointRouteBuilder MapCatalogEndpoints(
        this IEndpointRouteBuilder endpoints)
    {
        RouteGroupBuilder group = endpoints
            .MapGroup("/api/catalog")
            .WithTags("Catalog");

        group.MapGet("/products/{productId:guid}", GetProductAsync);

        return endpoints;
    }

    private static async Task<IResult> GetProductAsync(
        Guid productId,
        IProductCatalogue catalogue,
        CancellationToken stopToken)
    {
        ProductSummary? product = await catalogue.FindAsync(productId, stopToken);

        return product is null
            ? Results.NotFound()
            : Results.Ok(product);
    }
}
```

`IProductCatalogue` and `ProductSummary` live in `Store.Catalog.Contracts`. Ordering can query Catalog without accessing `CatalogDbContext` or the `Product` aggregate.

```csharp
namespace Store.Catalog.Contracts;

public interface IProductCatalogue
{
    Task<ProductSummary?> FindAsync(
        Guid productId,
        CancellationToken stopToken);
}

public sealed record ProductSummary(
    Guid ProductId,
    string Name,
    decimal UnitPrice,
    string Currency,
    bool IsAvailableForSale);
```

The implementation stays inside the Catalog module and projects directly to the contract type. It does not expose an `IQueryable<Product>` that another module could extend into Catalog's database model.

```csharp
namespace Store.Catalog.Application;

internal sealed class ProductCatalogue(CatalogDbContext db)
    : IProductCatalogue
{
    public Task<ProductSummary?> FindAsync(
        Guid productId,
        CancellationToken stopToken) =>
        db.Products
            .AsNoTracking()
            .Where(product => product.Id == productId)
            .Select(product => new ProductSummary(
                product.Id,
                product.Name,
                product.UnitPrice,
                product.Currency,
                product.IsAvailableForSale))
            .SingleOrDefaultAsync(stopToken);
}
```

Ordering consumes the contract through a normal dependency. A primary constructor keeps the handler compact without weakening the boundary.

```csharp
namespace Store.Ordering.Application;

internal sealed class PlaceOrderHandler(
    OrderingDbContext db,
    IProductCatalogue catalogue,
    TimeProvider clock)
{
    public async Task<PlaceOrderResult> HandleAsync(
        PlaceOrder command,
        CancellationToken stopToken)
    {
        if (command.Lines.Count == 0)
        {
            return PlaceOrderResult.Rejected("An order must contain at least one item.");
        }

        List<PricedOrderLine> lines = [];

        foreach (OrderLineRequest requestedLine in command.Lines)
        {
            ProductSummary? product = await catalogue.FindAsync(
                requestedLine.ProductId,
                stopToken);

            if (product is null || !product.IsAvailableForSale)
            {
                return PlaceOrderResult.Rejected(
                    $"Product {requestedLine.ProductId} is unavailable.");
            }

            lines.Add(new PricedOrderLine(
                product.ProductId,
                product.Name,
                requestedLine.Quantity,
                product.UnitPrice,
                product.Currency));
        }

        Order order = Order.Place(
            command.CustomerId,
            lines,
            clock.GetUtcNow());

        db.Orders.Add(order);
        await db.SaveChangesAsync(stopToken);

        return PlaceOrderResult.Accepted(order.Id);
    }
}
```

This call is simpler than an internal HTTP request, but it still respects Catalog ownership. Ordering receives the data it needs and stores a price snapshot on the order. It does not hold a reference to a Catalog entity whose value could change later. For many products, the contract should expose a batch operation rather than calling `FindAsync` in a loop. The example is intentionally small; production checkout code should avoid turning a former chatty network interaction into a chatty database interaction.

## Keep database ownership after consolidation

Database consolidation usually causes the most anxiety because data boundaries carry more risk than process boundaries. There are three reasonable intermediate or final shapes.

| Data shape | Advantages | Costs | Best use |
| --- | --- | --- | --- |
| Existing database per module | Lowest migration risk and strongest operational separation | Retains much of the database cost and cross database complexity | First consolidation step or a required isolation boundary |
| One SQL logical server with a database per module | Centralises some administration while retaining database ownership | Cross module transactions and queries remain awkward | Modules with separate backup, scaling or access requirements |
| One database with a schema per module | Lowest operational overhead and supports local transactions where deliberately needed | Boundaries rely more heavily on code, permissions and tests | A low traffic system owned by one team |

For the example store, one Azure SQL database with `catalog`, `ordering`, `stock` and `payments` schemas is a sensible target. Each module keeps its own `DbContext` and migration history table.

```csharp
namespace Store.Catalog.Infrastructure;

internal sealed class CatalogDbContext(
    DbContextOptions<CatalogDbContext> options)
    : DbContext(options)
{
    internal const string Schema = "catalog";

    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema(Schema);
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(CatalogDbContext).Assembly);
    }
}
```

```csharp
namespace Store.Ordering.Infrastructure;

internal sealed class OrderingDbContext(
    DbContextOptions<OrderingDbContext> options)
    : DbContext(options)
{
    internal const string Schema = "ordering";

    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OutboxMessage> OutboxMessages => Set<OutboxMessage>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema(Schema);
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrderingDbContext).Assembly);
    }
}
```

Separate migration history tables prevent one context from treating another module's migrations as its own. Each migration command names its context:

```bash
dotnet ef migrations add InitialCatalog \
  --project src/Modules/Catalog/Store.Catalog \
  --startup-project src/Store.Api \
  --context CatalogDbContext

dotnet ef migrations add InitialOrdering \
  --project src/Modules/Ordering/Store.Ordering \
  --startup-project src/Store.Api \
  --context OrderingDbContext
```

Do not create foreign keys from Ordering tables directly to Catalog tables merely because they now share a database. An order line stores the purchased product identifier, description and agreed price as an Ordering owned snapshot. Catalog can change or remove its current listing without corrupting the historical order.

Database permissions can reinforce this ownership. If the deployment model permits separate database principals, give each module's migration or runtime identity access only to its schema. One process hosting makes per module runtime identities less convenient, so code boundaries and automated tests remain important even when schema permissions cannot be fully separated.

### Move data with explicit cutovers

Bringing tables into one database should be treated as a data migration, not a side effect of deploying the new host. For each module, document the source, destination, write owner, cutover time, rollback point and reconciliation query.

Read only modules are easiest. Catalog can be copied, compared and switched while the old API remains available. Write heavy modules require a short maintenance window, change data capture or another controlled synchronisation mechanism. Avoid open ended dual writes from application code. They create a new consistency problem and make it difficult to know which store is authoritative.

Before switching Ordering, compare row counts and business totals, then test representative orders against the destination. Take a recoverable backup. Stop old writes, apply the final delta, change the owner and verify new writes. Keep the old database read only for the agreed rollback period rather than deleting it immediately.

## Decide which communication should become in process

Replacing every message with a method call would throw away useful durability. Keeping every message would preserve complexity that no longer earns its place. Classify interactions by what the business needs.

Catalog lookups, price calculation and simple availability checks are natural synchronous contract calls. The caller needs the answer to continue, and the called module is in the same process. A direct asynchronous C# call gives the clearest behaviour.

Order confirmation emails, exports and external fulfilment notifications should remain durable. A process can stop after the order transaction commits but before an in memory notification handler runs. An outbox record written in the same transaction as the order closes that gap. A background dispatcher can publish it to Service Bus or process it locally with retries.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/7a5afaf0-0a7e-4399-b7c7-d9be8dc03d4b.png align="center")

The outbox remains worthwhile because the event crosses an external durability boundary. For a purely internal notification, the dispatcher may invoke a module contract from a persistent local work queue. The deciding factor is the required delivery guarantee, not the number of deployed services.

If stock must never be reserved without an order, decide where that invariant belongs. You can place the necessary state in one module and transaction, or model a durable workflow with pending, confirmed and rejected states. Calling two module databases sequentially and hoping both writes succeed is no safer in a modular monolith than it was across services. A useful rule is that a module owns its invariants. When a transaction repeatedly needs write access to two modules, reconsider the module boundary before reaching for a cross-context transaction. Technical independence should not divide a business rule that must remain atomic.

### Events that only decouple code

In process domain events can be useful when several handlers react to a completed domain action. Keep their semantics clear. If handlers execute before the transaction commits, a failure can abort the operation. If they execute afterwards, the publisher needs a plan for handler failure. Calling something an event does not create durability. Use an in memory event mechanism for local code organisation and an outbox or broker for delivery that must survive failure. Those are different guarantees even if the message records look similar.

## Migrate through stable seams

The safest route resembles a strangler migration running in the opposite direction. Build the modular host, place stable contracts in front of the existing services, then replace remote adapters with local implementations one capability at a time.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/871a870f-0aa1-4259-9d0e-d742ece0296d.png align="center")

At stage two, the host can expose the existing public API while using HTTP clients behind module contracts. Catalog might still run remotely:

```csharp
internal sealed class CatalogHttpAdapter(HttpClient client)
    : IProductCatalogue
{
    public async Task<ProductSummary?> FindAsync(
        Guid productId,
        CancellationToken stopToken)
    {
        using HttpResponseMessage response = await client.GetAsync(
            $"api/catalog/products/{productId}",
            stopToken);

        if (response.StatusCode == HttpStatusCode.NotFound)
        {
            return null;
        }

        response.EnsureSuccessStatusCode();

        return await response.Content.ReadFromJsonAsync<ProductSummary>(
            cancellationToken: stopToken);
    }
}
```

Later, dependency registration switches `IProductCatalogue` to the in process `ProductCatalogue`. Ordering code does not change. This seam supports an incremental cutover and gives the team a clear place to compare remote and local behaviour. Dont keep the HTTP adapter as permanent internal indirection once Catalog is local. Making the application call itself over HTTP preserves latency and failure modes without preserving independent deployment.

## Choose the migration order carefully

Start with a module that has useful read paths, few dependencies and limited write risk. Catalog is a good first candidate for the store. It proves the host, module registration, endpoint routing, observability and deployment process without immediately moving payment or order state. Notifications are also loosely coupled, but they can hide delivery and retry complexity. Moving them first may test background processing rather than the core modular design. Ordering and Payments should normally come later because their cutovers require stronger reconciliation and rollback plans.

A practical order for this store is:

1.  Create the Store API composition root and common operational setup.
    
2.  Put Catalog contracts in place and route Catalog reads through the new host.
    
3.  Move Basket, which is customer facing but usually has simpler consistency requirements.
    
4.  Move Stock reads, then its reservation workflow.
    
5.  Move Ordering after the data and event strategy has been proven.
    
6.  Move or retain Payments according to its security and compliance boundary.
    
7.  Move Notifications or leave it as a separately scaled worker.
    
8.  Remove the API gateway routes, topics, databases and hosting resources that no longer have consumers.
    

The exact order should follow dependency and risk analysis. A low-risk module that every other service depends on can be a better early move than a completely isolated module because it proves the contract seam used by the rest of the migration.

## Preserve the public API while changing the inside

Clients should not need to know that Catalog moved from a service to a module. Keep the existing routes and response contracts at the edge unless there is a separate reason to version them. The API gateway can remain in front during the transition. Initially, `/catalog` routes to the Catalog service and `/orders` routes to the Ordering service. When Catalog moves, update only its backend route to the Store API. As more modules move, the gateway sends more paths to the same deployment. Once the gateway no longer performs useful authentication, throttling, transformation or external routing, evaluate whether it can be removed.

Contract tests are valuable here. Capture the status codes, headers and response bodies used by real clients. Run the same suite against the old service and new module endpoint. Read only traffic can also be shadowed to the new path and compared, provided personal data and side effects are handled appropriately. For write endpoints, use replayable test fixtures and reconciliation rather than blindly shadowing production requests. A duplicated `PlaceOrder` call can charge a card or reserve stock twice even when the test harness ignores the second response.

## Treat messages as public contracts until consumers are gone

An event topic may have consumers outside the repositories the team remembers. Data platforms, audit processes, Logic Apps and support tooling often depend on messages without appearing in the application dependency graph. Inventory every queue, topic and subscription before removing it. Inspect Azure metrics and logs over a representative business period. Find the owning team and expected schema for each consumer. Keep publishing an existing integration event from the modular host until every external consumer has migrated or confirmed that it no longer needs it. Internal events can be retired after their final remote consumer disappears. During the transition, the local module may handle an action directly while the outbox continues to publish the legacy event. Add an end date to that compatibility path. Otherwise, temporary migration infrastructure becomes permanent architecture.

## Keep module boundaries from decaying

The most common failure after consolidation is convenience driven coupling. A developer sees that `CatalogDbContext` is already registered and injects it into Ordering. Another adds a project reference to reuse an internal entity. Soon every module can reach every table. Use several layers of enforcement. First, make implementation types `internal`. A Contracts project exposes only the supported surface. Second, allow project references in one direction and review the solution graph. Third, test forbidden dependencies. Fourth, keep schemas and migrations module owned. Finally, assign ownership so boundary changes receive deliberate review.

An architecture test can fail the build if Catalog begins depending on Ordering. The example uses NetArchTest syntax, the same rule can be expressed with another architecture testing library or an internal Roslyn analyser.

```csharp
public sealed class ModuleDependencyTests
{
    [Fact]
    public void Catalog_must_not_depend_on_ordering()
    {
        TestResult result = Types
            .InAssembly(typeof(CatalogModule).Assembly)
            .ShouldNot()
            .HaveDependencyOnAny(
                "Store.Ordering",
                "Store.Ordering.Contracts")
            .GetResult();

        Assert.True(
            result.IsSuccessful,
            string.Join(Environment.NewLine, result.FailingTypeNames ?? []));
    }
}
```

Avoid making every dependency bidirectional through Contracts projects. If Catalog and Ordering continually call each other, introduce an orchestrating workflow or revisit their responsibilities. A contract project should clarify a dependency, not legitimise a dependency cycle.

## Test at module and application level

The test strategy becomes simpler, but it should still reflect the boundaries. Domain tests cover aggregates and policies without ASP.NET Core or a database. Module integration tests start the module with its real EF Core mappings and a disposable SQL database. They verify endpoints, migrations, queries and outbox behaviour within that module. Architecture tests enforce references and namespaces. Whole application functional tests start `Store.Api` and exercise journeys such as browsing, basket creation and checkout.

The consolidated host makes full journeys cheaper to run. One `WebApplicationFactory` can execute a request through several modules without starting six independently versioned applications. That should increase coverage of business workflows rather than encourage every test to become a slow end-to-end test.

Keep contract compatibility tests while old clients or services remain. Delete migration-only tests and adapters once the old path is retired. Test suites should become simpler as the architecture becomes simpler.

## Rework observability around the business flow

Dont remove useful telemetry simply because service boundaries disappeared. Keep structured logs, metrics and traces, but add module and business operation dimensions. A checkout log should include the trace identifier, order identifier and module name. Metrics should report placed, rejected and failed orders, stock reservation duration and outbox age. Health checks should distinguish the Store API's liveness from readiness dependencies such as SQL and Service Bus. One Application Insights resource can now show the complete flow. Fewer network dependencies will appear, so custom spans around significant module operations may help preserve useful timing information. Avoid recreating a distributed trace for every internal method call. Instrument boundaries that support diagnosis or service-level objectives.

Compare telemetry volume before and after the migration. Consolidation often reduces duplicate request logs and dependency spans, but a single host can still ingest excessive logs if every module writes verbose payloads. Cost control still requires sampling, sensible log levels and deliberate retention.

## Deploy the modular monolith safely

One artefact changes the deployment risk. A release contains the current versions of every module, so the pipeline needs strong whole application verification. Build once, run unit, architecture, integration and functional tests, then promote the same artefact through environments. Use deployment slots, revisions or a rolling strategy supported by the chosen Azure host. Run backward compatible database migrations before code that requires the new schema. Destructive column removal should happen only after the old code no longer reads it and rollback is no longer expected.

Feature flags can separate feature release from code deployment, especially while a route changes from a remote adapter to a local module. Keep those flags short lived and observable. A permanent flag for every retired service leaves two architectures to support.

Dont run EF Core migrations concurrently from every scaled application replica. Apply them as a controlled deployment step or a single migration job. This avoids several instances racing to modify the same schemas during startup.

The target Azure shape might be one Store API running on Azure Container Apps with at least two replicas for availability, one Azure SQL database, one Application Insights resource and Service Bus retained for external or durable background work. App Service or Azure Kubernetes Service may also be appropriate if already standard in the organisation. The architecture decision does not require changing hosting platform at the same time. Combining an application consolidation with a hosting platform migration makes rollback and diagnosis harder. Unless the existing platform is part of the problem, first run the modular host alongside the services on familiar infrastructure. Optimise the hosting shape after behaviour is stable.

## Retire infrastructure only after proving it is unused

Cost savings arrive when old resources are removed or scaled down. They should be retired methodically. After a service cutover, block new traffic to the old endpoint while keeping it available for a short rollback window. Monitor for unexpected requests. Confirm that no scheduled job, partner or internal tool still calls it. Check queues for active publishers and consumers. Preserve logs and backups for the required retention period. Then remove the service deployment, pipeline, DNS entry, secrets, managed identity assignments, alerts and infrastructure-as-code module. Archive or mark the repository read only if its history is still useful. Remove unused database capacity only after data reconciliation and the rollback window have completed.

Deleting the compute while leaving orphaned private endpoints, databases and Log Analytics retention will produce a disappointing cost result. Compare the post migration Azure bill with the baseline and explain variances by resource category.

## A safe cutover for the Ordering service

Ordering is the most useful example because it combines writes, events and important business state. First, introduce an Ordering contract and route while the existing service remains authoritative. The Store API uses an HTTP adapter to reach it. This proves that clients can use the new edge without moving order logic. Next, bring the Ordering code into its module and run its integration suite against the target schema. Replay sanitised historical requests and compare outcomes. Verify totals, status transitions, idempotency behaviour and published events.

Copy existing order data into the `ordering` schema and run reconciliation. During a planned cutover, pause or drain writes to the old service, apply the last data changes and switch the Store API registration from the HTTP adapter to the local handler. Resume traffic gradually and monitor order acceptance, failures, payment correlation, outbox age and stock reservations.

Continue publishing the established `OrderPlaced` integration event. External consumers should see no architecture change. Keep the old service and database read only during the rollback window. A rollback restores routing and write ownership together; routing back to an old service while the new database continues receiving writes would split the source of truth.

Once the observation period completes, retire the service resources and remove the adapter. This final deletion is part of the migration, not optional tidying.

## What the team should gain

The result should be visible in engineering and business measures, not only in a cleaner diagram.

| Area | Before | After consolidation |
| --- | --- | --- |
| Deployment | Several pipelines and coordinated service versions | One artefact and one main release path |
| Runtime path | HTTP and messaging between core capabilities | Direct contracts for immediate work; durable messaging where needed |
| Debugging | Several processes, stores and telemetry searches | One process, continuous stack traces and one primary telemetry view |
| Data | Several small databases and distributed consistency | Module-owned schemas with deliberate local transactions or workflows |
| Azure cost | Repeated compute, data, messaging and monitoring resources | Consolidated hosting and data capacity, with fewer chargeable operations |
| Local development | Multiple services, brokers and data stores | One host and a smaller dependency set |
| Change delivery | Contract and release coordination across repositories | Atomic changes compiled and tested together |
| Scaling | Every service can scale independently | Whole application scales horizontally; exceptional workloads can remain separate |

Measure lead time for change, deployment frequency, failed deployments, mean time to restore, Azure cost per order, local startup time and the number of repositories touched per feature. Compare several weeks before and after each migration stage. Some benefits, particularly reduced cognitive load, will also appear in developer feedback and onboarding time.

The migration has succeeded when the store is easier to change and operate while meeting the same reliability and performance objectives. A smaller Azure bill helps, but cost reduction alone would not justify a design that made order processing less safe.

## When to extract a module again

A modular monolith does not close the door on microservices. It makes extraction a response to observed pressure. A module becomes a credible service candidate when it needs a substantially different scaling profile, has a stable boundary, is owned by a team capable of operating it, requires stronger process or security isolation, or must deploy independently often enough to justify the operational cost.

By that point, the module already has a contract, data owner and restricted dependency graph. Extraction still requires work, particularly around data and failure handling, but the boundary has been tested inside the application before being made distributed.

Do not extract a module because the project has become large. Project size can be handled with organisation and build tooling. Extract when a process boundary solves a measured problem.

The great thing about Modular Monoliths is most of the hard work when moving from a traditional Monolith to Microservices has already been done for you. If done correctly you should already have well defined boundaries and clear divisions between the composite parts of your system.

## A practical decision for the store

For this online store, the evidence points towards consolidation. Traffic is low, one team owns the system, releases are coordinated, Azure costs are disproportionate and distributed debugging consumes time. Catalog, Basket, Ordering and Stock belong in one Store API with enforced module boundaries. Notifications can use an outbox and durable worker. Payments can remain separate if its compliance or isolation needs justify that boundary. The team should move one capability at a time, preserving public HTTP and event contracts during transition. It should consolidate data only after ownership and cutover procedures are clear. It should retain asynchronous messaging where delivery must survive failure and remove it from synchronous internal paths that never benefited from it.

Starting with microservices was a costly decision, but continuing with them after the evidence changes would be costlier. Architecture earns its place by helping the team deliver and operate the product in front of it. In this case, a modular monolith provides the boundaries the code needs and the simplicity the organisation can use.

*   [What’s new in .NET 10](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-10/overview)
    
*   [What’s new in C# 14](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-14)
    
*   [What’s new in EF Core 10](https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-10.0/whatsnew)
    
*   [Microservices assessment and readiness](https://learn.microsoft.com/en-us/azure/architecture/guide/technology-choices/microservices-assessment)
    
*   [Microservices architecture style](https://learn.microsoft.com/en-us/azure/architecture/guide/architecture-styles/microservices)
    
*   [Common web application architectures](https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/common-web-application-architectures)
    
*   [Azure Container Apps pricing](https://azure.microsoft.com/en-us/pricing/details/container-apps/)
    
*   [Azure Service Bus pricing](https://azure.microsoft.com/en-us/pricing/details/service-bus/)
    
*   [Azure Monitor pricing](https://azure.microsoft.com/en-us/pricing/details/monitor/)
