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.
A tiffin box with separate compartments
Think of a steel tiffin box that your family packs for lunch.
A good tiffin has separate compartments. Rice goes in one, dal in another, and the pickle sits in its own tiny cup. The pickle is strong and tangy. If it leaks into the rice, the whole lunch tastes wrong. So we keep a proper wall between each food.
Now imagine someone removes all the walls and throws 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 item. Everything is touching everything.
A modular monolith is like the good tiffin. It is one box that you carry as a single unit. But inside, each module sits in its own compartment with clear walls. The Orders food does not leak into the Billing food. The Shipping food stays in its own cup.
A data boundary is one of those walls. It says: these tables belong to this module, and only this module is allowed to touch them. When the walls hold, your app stays tasty and easy to change. When the walls leak, you get one big mush that is painful to fix.
This guide shows you how to keep those walls strong in a .NET app, step by step, in plain words.
What a data boundary really means
A module is a slice of your app that owns three things: its logic, its public API, and most importantly its data.
Owning the data is the part teams forget. It is easy to draw nice module folders in your code and still let every module read and write every table. That is a tiffin with no walls. The folders look neat, but the data is one big mush.
Here is the simple rule that keeps boundaries intact:
A module may read and write only its own tables. To get data from another module, it must ask that module through a public contract or listen to that module's events. It may never query another module's tables directly.
Let us see how the pieces fit together.
Notice the important detail. Billing never reaches into the orders tables. It talks to the Orders API, or it reacts to an Orders event. The wall stays intact.
The three levels of data isolation
There is no single "correct" amount of isolation. There is a ladder, and you climb it as your app grows. Start low and simple. Move up only when you have a real reason.
The Data Isolation Ladder
Steps
Shared schema
All tables together. Easy but weak walls.
Schema per module
One schema each. The sweet spot for most teams.
Database per module
Full physical split. Strongest, but more work.
Here is the same idea as a quick comparison table.
| Level | What it is | Wall strength | Best for |
|---|---|---|---|
| Shared schema | All modules share one set of tables | Weak | Tiny apps, prototypes |
| Schema per module | Each module gets its own schema in one database | Strong | Most real apps |
| Database per module | Each module gets a separate physical database | Strongest | Modules that must scale alone |
For the rest of this guide we focus on the middle rung — a schema per module in a single database. It gives strong, enforceable walls with very little extra setup, and it is the path most .NET teams take.
Step 1: Give each module its own schema
A schema is just a named folder for tables inside one database. In SQL Server you can have an orders schema, a billing schema, and a shipping schema, all living in the same database file.
When the Orders module asks Entity Framework Core to map a table, you tell EF Core to put it in the orders schema. This single line is the start of your wall.
// Inside the Orders module's DbContext
public sealed class OrdersDbContext : DbContext
{
public DbSet<Order> Orders => Set<Order>();
public OrdersDbContext(DbContextOptions<OrdersDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Every table in this context lives under the "orders" schema.
modelBuilder.HasDefaultSchema("orders");
modelBuilder.Entity<Order>(b =>
{
b.ToTable("Orders");
b.HasKey(o => o.Id);
b.Property(o => o.CustomerName).HasMaxLength(200);
});
}
}Now the Billing module does the same with its own DbContext and its own schema. Two contexts, two schemas, one database.
// Inside the Billing module's DbContext
public sealed class BillingDbContext : DbContext
{
public DbSet<Invoice> Invoices => Set<Invoice>();
public BillingDbContext(DbContextOptions<BillingDbContext> options)
: base(options) { }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("billing");
modelBuilder.Entity<Invoice>(b =>
{
b.ToTable("Invoices");
b.HasKey(i => i.Id);
});
}
}The key idea: one DbContext per module. The Orders context knows nothing about invoices. The Billing context knows nothing about orders. Each context can only see its own tables, so a developer cannot accidentally write a cross-module query — the other tables simply are not there.
Step 2: Lock the walls with database permissions
Separate schemas draw the wall. Database permissions turn it into a real, guarded fence.
You can give each module its own database login, and grant that login rights to only its own schema. The Orders login can read and write the orders schema, but it has zero rights on billing or shipping.
-- The Orders module connects with this login.
-- It can touch ONLY the orders schema.
CREATE LOGIN orders_app WITH PASSWORD = 'a-strong-secret';
CREATE USER orders_app FOR LOGIN orders_app;
GRANT SELECT, INSERT, UPDATE, DELETE
ON SCHEMA::orders TO orders_app;
-- No grants on billing or shipping = no access at all.Now even if a developer writes a sneaky query against the billing tables from inside the Orders module, the database itself says no. The boundary is no longer just a polite agreement in code. It is enforced by the engine. This is the strongest, cheapest guard you can add, and it catches mistakes that code reviews miss.
Step 3: Never JOIN across module schemas
This is the rule people break most often, usually with good intentions.
Imagine you need a screen that shows each order along with whether its invoice is paid. The order data lives in the orders schema. The paid status lives in the billing schema. The "easy" fix is one quick JOIN across both schemas.
Please resist it. That JOIN secretly welds the two modules together. From that moment, the Billing team cannot rename or restructure its Invoices table without breaking the Orders screen. The wall is gone, and nobody even noticed it fall.
Cross-Module Query: The Wrong Way vs The Right Way
Steps
Need combined data
One screen needs orders and billing info.
Tempting JOIN
Welds the modules. Avoid this.
Read model + events
One module keeps its own copy, updated by events.
The clean answer is a read model. One module owns a small, local copy of just the fields it needs from another module. It keeps that copy fresh by listening to events. We build that next.
Step 4: Share data with events, not tables
When something important happens in a module, that module publishes an integration event — a small message that announces the change. Other modules subscribe and react. No module ever pokes into another module's tables.
Let us walk through the "order paid" example properly.
- The Billing module marks an invoice as paid.
- Billing publishes an
InvoicePaidevent. - The Orders module is listening. It updates its own small copy that says "this order is paid".
Now the Orders screen reads only from the orders schema. It never touches billing tables, yet it still shows the paid status.
Here is what the event and the listener look like in C#. The event itself is a tiny, plain record — just the facts other modules need.
// A public contract that any module is allowed to depend on.
public sealed record InvoicePaid(Guid OrderId, DateTime PaidAtUtc);
// Inside the Orders module: a handler that listens for the event.
public sealed class InvoicePaidHandler
{
private readonly OrdersDbContext _db;
public InvoicePaidHandler(OrdersDbContext db) => _db = db;
public async Task HandleAsync(InvoicePaid e, CancellationToken ct)
{
var order = await _db.Orders.FindAsync([e.OrderId], ct);
if (order is null) return;
// Orders updates ITS OWN table. It never touches billing tables.
order.MarkPaid(e.PaidAtUtc);
await _db.SaveChangesAsync(ct);
}
}A note on tools: popular event libraries like MediatR and MassTransit moved to commercial licensing in their newer versions. They are still excellent, but check the license and pricing before you adopt them. For in-process events inside a single modular monolith, you can also start with a small custom dispatcher or a free, lightweight library, and only reach for the bigger tools when you genuinely need their features.
When you do need synchronous data
Sometimes a module needs an answer right now, not later through an event. For example, before Orders accepts a new order, it may need to confirm a customer exists in the Customers module.
That is fine — just ask through a public contract, not a table. The Customers module exposes a clean method or interface. Orders calls it. The Customers module decides how to answer and what to reveal.
// Public contract owned by the Customers module.
public interface ICustomerDirectory
{
Task<bool> ExistsAsync(Guid customerId, CancellationToken ct);
}
// Inside the Orders module: depend on the contract, not on Customers' tables.
public sealed class CreateOrderHandler
{
private readonly ICustomerDirectory _customers;
public CreateOrderHandler(ICustomerDirectory customers)
=> _customers = customers;
public async Task HandleAsync(CreateOrder cmd, CancellationToken ct)
{
if (!await _customers.ExistsAsync(cmd.CustomerId, ct))
throw new InvalidOperationException("Unknown customer.");
// ... create the order in the orders schema ...
}
}The Orders module depends on the interface, which is part of the Customers module's public face. It does not know or care which tables Customers uses behind the scenes. The Customers team can change its storage freely. The wall holds.
Synchronous vs event-based sharing
Both ways of sharing keep boundaries intact, but they suit different jobs. Here is a simple way to choose.
| Question | Use a synchronous call | Use an integration event |
|---|---|---|
| Do I need the answer immediately? | Yes | No |
| Can the other module be busy or slow? | Risky, you wait | Fine, it happens later |
| Am I just reacting to a change? | No | Yes |
| Do I want loose coupling? | Medium | Strongest |
A good rule of thumb: use a synchronous call when you must validate or fetch something before you continue, and use an event when you are simply reacting to something that already happened in another module.
A quick mental checklist for keeping walls intact
Before you ship a feature that touches more than one module, ask yourself these questions. If any answer is "no", you have probably cracked a wall.
- Does each module have its own schema and its own
DbContext? - Does each module's database login have rights to only its own schema?
- Are there zero JOINs across module schemas anywhere in your code?
- When one module needs another's data, does it use a public contract or an event?
- Could you move one module into its own database later without rewriting other modules?
If you can answer "yes" to all five, your tiffin compartments are sealed, and your app will stay easy to change for years.
References and further reading
- Modular monoliths — Microsoft .NET architecture guidance — official .NET architecture docs.
- Modular Monolith Data Isolation — Milan Jovanović — schemas, roles, and enforceable boundaries.
- How to Keep Your Data Boundaries Intact in a Modular Monolith — Milan Jovanović — deeper dive on the same topic.
- Using Multi-Context EF Core for Schema Isolation — Mehmet Özkaya — one DbContext per module in practice.
- Querying Across Multiple Schemas in a Modular Monolith — Anton Dev Tips — handling cross-schema reads safely.
Quick recap
- A data boundary is a wall around one module's tables. Only that module may touch them, like compartments in a tiffin box.
- Climb the isolation ladder: shared schema, then schema per module, then database per module. Most teams are happiest with schema per module.
- Give each module its own schema and its own EF Core
DbContextso it can only see its own tables. - Lock the wall with database permissions: each module's login may touch only its own schema.
- Never JOIN across module schemas. It secretly welds modules together.
- Share data through public contracts for instant answers and integration events for reactions — never through another module's tables.
- Remember that MediatR and MassTransit are now commercially licensed; check the license before adopting, and a small dispatcher is fine to start.
- Keep the walls intact and any module can later move into its own database with very little pain.
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.
Building a Modular Monolith With Vertical Slice Architecture in .NET
Learn to build a modular monolith using vertical slice architecture in .NET. Simple words, real-life analogy, diagrams, tables, and clean C# code examples.
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.
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.
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.