Skip to main content
SEMastery
Architectureintermediate

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.

12 min readUpdated November 12, 2025

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.

Each module owns its own tables. Other modules ask through the public API or events, never by touching the tables directly.

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

Shared schema
Schema per module
Database per module

Steps

1

Shared schema

All tables together. Easy but weak walls.

2

Schema per module

One schema each. The sweet spot for most teams.

3

Database per module

Full physical split. Strongest, but more work.

Climb from simple to strict only when a module truly needs it. Most apps live happily on the middle rung.

Here is the same idea as a quick comparison table.

LevelWhat it isWall strengthBest for
Shared schemaAll modules share one set of tablesWeakTiny apps, prototypes
Schema per moduleEach module gets its own schema in one databaseStrongMost real apps
Database per moduleEach module gets a separate physical databaseStrongestModules 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.

One database, three schemas, three DbContexts. Each context can only see its own schema.

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

Need combined data
Tempting JOIN
Read model + events

Steps

1

Need combined data

One screen needs orders and billing info.

2

Tempting JOIN

Welds the modules. Avoid this.

3

Read model + events

One module keeps its own copy, updated by events.

A JOIN across schemas glues modules together. A read model kept fresh by events keeps them free.

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.

  1. The Billing module marks an invoice as paid.
  2. Billing publishes an InvoicePaid event.
  3. 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.

Billing publishes an event when an invoice is paid. Orders listens and updates its own local copy.

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.

QuestionUse a synchronous callUse an integration event
Do I need the answer immediately?YesNo
Can the other module be busy or slow?Risky, you waitFine, it happens later
Am I just reacting to a change?NoYes
Do I want loose coupling?MediumStrongest

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

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 DbContext so 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