Skip to main content
SEMastery
Architectureintermediate

Modular Monolith Data Isolation in .NET: A Beginner-Friendly Guide

Learn data isolation in a .NET modular monolith using separate schemas, one DbContext per module, and events instead of cross-module joins. Simple, clear examples.

13 min readUpdated March 19, 2026

A lunch tiffin with separate compartments

Picture the steel tiffin box your family packs for lunch.

A good tiffin has separate compartments. The rice sits in one. The dal sits in another. The tangy pickle gets its own tiny cup. Why? Because pickle is strong. If it leaks into the rice, the whole lunch tastes wrong.

Now imagine someone removes all the walls and dumps everything into one big container. At first it looks fine. But by lunchtime the dal has soaked the puri, the pickle has coloured the rice, and you cannot enjoy any single food. Everything is touching everything.

A modular monolith is like the good tiffin. It is one box you carry as a single unit. But inside, each module sits in its own compartment with clear walls. The Orders food never leaks into the Billing food.

Data isolation is the rule that keeps those walls strong at the database level. It says: each module owns its own data, and no other module is allowed to reach in and grab it. This guide shows you how to build those walls in .NET, step by step, in plain words.

One box on the outside, separate compartments inside.

What "data isolation" really means

Let us make the idea very concrete.

Imagine three modules in an online shop: Orders, Billing, and Shipping. Each module has its own tables.

  • Orders owns the orders and order_items tables.
  • Billing owns the invoices and payments tables.
  • Shipping owns the shipments table.

Data isolation says one simple thing: a module may only touch its own tables. If Billing needs to know about an order, it is not allowed to run SELECT * FROM orders. Instead, Billing must ask the Orders module nicely, through a public door that Orders opens on purpose.

Think of it like neighbours in an apartment block. You do not climb through your neighbour's window to borrow sugar. You ring the doorbell and ask. The doorbell is the public API. The window is the database table, and it stays locked.

When every module follows this rule, something wonderful happens: you can change the inside of one module freely. You can rename a column, split a table, or fix a bug, and no other module even notices. The walls protect everyone.

Why bother? The cost of leaky walls

You might ask: "We are all one app and one database. Why not just join the tables? It is faster to write."

It is faster today. It is slower forever after. Here is what goes wrong.

Without isolationWith isolation
Any module can read any tableA module only reads its own tables
One schema change breaks many modulesChanges stay inside one module
Tangled hidden dependencies growDependencies are clear and on purpose
Splitting into microservices is painfulSplitting later is much easier
Hard to reason about who owns whatOwnership is obvious

The hidden cost is the worst part. A cross-module JOIN is like a secret rope tying two modules together. Nobody sees the rope in a diagram. But the moment the Orders team renames a column, the Billing report crashes in production, and nobody knows why. Isolation removes the secret ropes.

What happens when walls leak

Change column
Hidden JOIN breaks
Another module fails
Surprise outage

Steps

1

Change column

Orders renames a field

2

Hidden JOIN breaks

Billing query depended on it

3

Another module fails

Billing report errors

4

Surprise outage

Nobody expected this

One small table change ripples into a surprise outage.

The three levels of isolation

There is not just one way to isolate data. There are levels, from light to strong. You can pick the level that fits your team. Start light, and tighten later if you need to.

LevelHow it worksStrengthWhen to use
Separate tablesAll modules in one schema, but each owns its tables by agreementWeak (rules only)Tiny apps, early days
Separate schemasEach module gets its own DB schema in the same databaseStrong and easyMost teams, the sweet spot
Separate databasesEach module has its very own databaseStrongestA module needs to scale alone

For most .NET teams, separate schemas is the happy middle. You keep one database, so backups and transactions stay simple. But each module lives in its own named area, like orders, billing, and shipping. The walls are real, and the setup is easy.

Levels of isolation, from light to strong.

Step 1: Give each module its own schema

A schema in a database is just a named folder for tables. Instead of every table sitting in the default dbo area, we group them. Orders tables go in the orders schema. Billing tables go in the billing schema.

In EF Core, the cleanest pattern is one DbContext per module. Each module's context knows only about its own entities and maps them to its own schema.

Here is the Orders module's context. Notice the call to HasDefaultSchema. That one line puts every Orders table into the orders schema.

public sealed class OrdersDbContext : DbContext
{
    public OrdersDbContext(DbContextOptions<OrdersDbContext> options)
        : base(options) { }
 
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<OrderItem> OrderItems => Set<OrderItem>();
 
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Every table in this context lives in the "orders" schema.
        modelBuilder.HasDefaultSchema("orders");
 
        // Keep EF's migration history inside the module's schema too,
        // so each module owns its own migration records.
        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(OrdersDbContext).Assembly);
    }
}

The Billing module has its own context that looks almost the same, but it points at the billing schema and only knows about invoices and payments. The two contexts never share entities. That is the wall.

Step 2: Keep migrations separate too

When you use multiple DbContext classes, EF Core needs to know which one you mean when you create a migration. Always pass the --context flag, or you will see a "More than one DbContext was found" error.

// In Program.cs, register one context per module.
builder.Services.AddDbContext<OrdersDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Shop"),
        sql => sql.MigrationsHistoryTable(
            "__EFMigrationsHistory", "orders")));
 
builder.Services.AddDbContext<BillingDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Shop"),
        sql => sql.MigrationsHistoryTable(
            "__EFMigrationsHistory", "billing")));

Then you run migrations per module from the command line:

dotnet ef migrations add InitialOrders --context OrdersDbContext
dotnet ef migrations add InitialBilling --context BillingDbContext

Setting the MigrationsHistoryTable schema means each module tracks its own migration history inside its own schema. Orders never sees Billing's migration records, and the other way around. The tiffin compartments stay sealed even for the bookkeeping.

Creating a new feature with isolated modules

Edit Orders entity
Add Orders migration
Apply Orders migration
Billing untouched

Steps

1

Edit Orders entity

Change only orders code

2

Add Orders migration

Use --context OrdersDbContext

3

Apply Orders migration

Updates orders schema only

4

Billing untouched

No ripple into other modules

Each module ships its own change, on its own track.

Step 3: Never JOIN across schemas

This is the golden rule, so let us say it loudly: a query should touch only one schema.

It is tempting to write a single fast query that joins orders.orders to billing.invoices. The database will happily let you. But that query glues Orders and Billing together forever. Now Billing cannot change its invoices table without breaking the Orders code, and nobody planned for that.

So how does Billing learn about an order? Two clean ways.

Way one: ask through a public method. Orders exposes a small, public contract. Billing calls it and gets back exactly the data Orders chooses to share, in a shape Orders controls.

// Public contract that the Orders module exposes to others.
public interface IOrdersApi
{
    Task<OrderSummary?> GetSummaryAsync(Guid orderId, CancellationToken ct);
}
 
// A simple, flat shape — not the internal entity.
public sealed record OrderSummary(
    Guid OrderId,
    string CustomerName,
    decimal Total);

Billing depends on IOrdersApi, not on the Order entity and not on the orders table. Orders can rebuild its tables tomorrow as long as it still answers this question. The window stays locked; only the doorbell is used.

Way two: listen to events. When something important happens in Orders, it shouts out a message. Other modules that care can listen and keep their own small copy of the data.

Step 4: Share data with events, not tables

Events are how modules stay in sync without peeking at each other's tables.

When a customer places an order, the Orders module publishes an integration event, for example OrderPlaced. It carries just the facts other modules need. Billing listens and creates an invoice. Shipping listens and prepares a shipment. Each module keeps its own copy of just the bits it cares about.

One event, many listeners, zero shared tables.

The event is a small, public message. It is part of the contract, just like the public API. The internal tables stay private.

A quick licensing note while we are here. Some popular .NET messaging libraries have changed how they are licensed. MediatR and MassTransit are now commercial for many uses, so check their current license before you adopt them in a paid product. Free options like the in-process event tools in Wolverine, or a small hand-written event dispatcher, work perfectly well for a modular monolith and keep your costs predictable. The pattern matters more than the library.

Step 5: Protect the walls with separate credentials

Schemas give you logical walls. You can make them even stronger with database permissions.

Give each module its own database login that can only see its own schema. The Orders login can read and write orders tables, and nothing else. Even if a developer accidentally writes a cross-schema query in the Billing module, the database refuses it because the Billing login has no rights to the orders schema.

This is like giving each family member a key that only opens their own compartment. A mistake cannot turn into a leak, because the lock simply will not open.

// Each module uses its own connection string with its own login.
builder.Services.AddDbContext<OrdersDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Orders")));
// "Orders" connection uses a login limited to the orders schema.
 
builder.Services.AddDbContext<BillingDbContext>(options =>
    options.UseSqlServer(
        builder.Configuration.GetConnectionString("Billing")));
// "Billing" connection uses a login limited to the billing schema.

Handling transactions across modules

A fair question comes up fast: "If Orders and Billing have separate schemas and maybe separate logins, how do I save changes to both in one go?"

For a modular monolith with one shared database, you can still wrap several contexts in one transaction by sharing the same database connection. But the better long-term habit is to avoid needing it. Let each module commit its own work, then use events to bring the others along.

This is called eventual consistency. Orders saves the order and publishes OrderPlaced. A moment later, Billing creates the invoice. For a heartbeat, the invoice does not exist yet. That tiny delay is almost always fine, and it keeps the modules free. If you truly need both saved together, keep them in the same module instead of forcing a cross-module transaction.

Each module commits its own work, then events catch the others up.

A small example you can picture

Let us walk through one real flow with all the rules in place.

  1. A customer clicks Place Order.
  2. The Orders module saves a row in orders.orders using OrdersDbContext. It touches only the orders schema.
  3. Orders publishes an OrderPlaced event with the order id, customer name, and total.
  4. Billing hears the event. It writes a new invoice in billing.invoices using its own BillingDbContext. It never looked at the orders table.
  5. Shipping hears the same event and writes a row in shipping.shipments.
  6. Later, a report needs the order total next to the invoice status. Instead of joining schemas, the report module keeps its own small read copy, updated from events, and reads only its own tables.

Every step touched exactly one schema. No secret ropes. If tomorrow the Orders team splits orders into two tables, nobody else breaks, because nobody else was looking.

When to split into a separate database

Separate schemas are enough for a long time. But one day a module might need its own database, maybe because it gets huge traffic, or needs different backups, or is moving to a microservice.

Because you isolated the data from day one, that move is gentle. The other modules already talk to this module through its public API and events, not its tables. So you point that one module at a new database, copy its schema over, and the contracts stay the same. The neighbours never knew the furniture moved, because they always rang the doorbell.

That is the real prize of data isolation. It is not only about today's tidiness. It is about keeping every future door open.

Common mistakes to avoid

  • Sharing one giant DbContext. It lets every module see every entity, which quietly erases your walls. Use one context per module.
  • Writing "just one" cross-schema JOIN. There is no such thing as just one. Each one is a hidden rope. Use the public API or events instead.
  • Sharing entity classes between modules. If Billing imports the Order entity, it is now tied to the Orders table shape. Share a small record or DTO, never the entity.
  • Putting cross-module logic in the database. Triggers and views that span schemas hide the coupling. Keep coordination in code, through events.
  • Skipping per-module migrations. Without the --context flag and a per-schema history table, your migrations get muddled. Keep them separate.

Quick recap

  • Data isolation means each module owns its own tables, and no one else may touch them directly.
  • Start with separate schemas in one database. It gives strong walls with easy setup.
  • Use one DbContext per module, each mapped to its own schema with HasDefaultSchema.
  • Keep migrations per module with the --context flag and a per-schema history table.
  • Never JOIN across schemas. A query should touch only one schema.
  • Share data through a public API or integration events, never through tables.
  • Strengthen walls with separate database logins so mistakes cannot leak.
  • Prefer eventual consistency over forced cross-module transactions.
  • Clean isolation makes a later split into a separate database or microservice far less painful.
  • Watch out for shared contexts, sneaky JOINs, and shared entity classes.

References and further reading

Related Posts