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.
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.
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
ordersandorder_itemstables. - Billing owns the
invoicesandpaymentstables. - Shipping owns the
shipmentstable.
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 isolation | With isolation |
|---|---|
| Any module can read any table | A module only reads its own tables |
| One schema change breaks many modules | Changes stay inside one module |
| Tangled hidden dependencies grow | Dependencies are clear and on purpose |
| Splitting into microservices is painful | Splitting later is much easier |
| Hard to reason about who owns what | Ownership 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
Steps
Change column
Orders renames a field
Hidden JOIN breaks
Billing query depended on it
Another module fails
Billing report errors
Surprise outage
Nobody expected this
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.
| Level | How it works | Strength | When to use |
|---|---|---|---|
| Separate tables | All modules in one schema, but each owns its tables by agreement | Weak (rules only) | Tiny apps, early days |
| Separate schemas | Each module gets its own DB schema in the same database | Strong and easy | Most teams, the sweet spot |
| Separate databases | Each module has its very own database | Strongest | A 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.
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 BillingDbContextSetting 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
Steps
Edit Orders entity
Change only orders code
Add Orders migration
Use --context OrdersDbContext
Apply Orders migration
Updates orders schema only
Billing untouched
No ripple into other modules
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.
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.
A small example you can picture
Let us walk through one real flow with all the rules in place.
- A customer clicks Place Order.
- The Orders module saves a row in
orders.ordersusingOrdersDbContext. It touches only theordersschema. - Orders publishes an
OrderPlacedevent with the order id, customer name, and total. - Billing hears the event. It writes a new invoice in
billing.invoicesusing its ownBillingDbContext. It never looked at theorderstable. - Shipping hears the same event and writes a row in
shipping.shipments. - 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
Orderentity, 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
--contextflag 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
DbContextper module, each mapped to its own schema withHasDefaultSchema. - Keep migrations per module with the
--contextflag 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
- Modular monoliths — Microsoft .NET architecture guidance
- Multiple DbContext configuration — EF Core docs
- Migrations with multiple providers and contexts — EF Core docs
- Modular Monolith Data Isolation — Milan Jovanović
- How to Keep Your Data Boundaries Intact in a Modular Monolith — Milan Jovanović
- Querying and Transactions Across Multiple Schemas — Anton Dev Tips
Related Posts
What Is a Modular Monolith? A Beginner-Friendly Guide for .NET
Understand the modular monolith in simple words: one app, strong internal walls. Learn how it compares to monoliths and microservices, why it is the 2026 default for most .NET teams, and how to build one.
How to Keep Your Data Boundaries Intact in a Modular Monolith (.NET)
Learn simple, practical ways to keep data boundaries strong in a .NET modular monolith using separate schemas, one DbContext per module, and events instead of cross-module joins.
Internal vs Public APIs in Modular Monoliths (.NET Guide)
Learn the difference between internal and public APIs in a .NET modular monolith, why module boundaries matter, and how to expose only safe contracts to other modules.
Where Vertical Slices Fit Inside the Modular Monolith
A simple guide to how vertical slices live inside the modules of a modular monolith in .NET, with diagrams, code, tables, and everyday examples.
Monolith to Microservices: How a Modular Monolith Helps
Learn how a modular monolith makes the move from monolith to microservices safe and easy in .NET, using clean boundaries, the Strangler Fig pattern, and small steps.
Breaking It Down: How to Migrate Your Modular Monolith to Microservices
A friendly, step-by-step guide to safely move from a .NET modular monolith to microservices using the Strangler Fig pattern, without a risky big-bang rewrite.