# How to Keep Your Data Boundaries Intact in a Modular Monolith

> Want real boundaries in your modular monolith? This article shows how to enforce them at the database level using Postgres schemas, roles, and EF Core — so your modules can't step on each other's data, even by accident.

Published: 2025-08-02. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/how-to-keep-your-data-boundaries-intact-in-a-modular-monolith

Enforce data boundaries in the database, not only in code.
Give each module its own PostgreSQL schema and login role, grant that role privileges on its schema alone, and point one EF Core `DbContext` per module at it with the module's connection string.
Cross-module reads go through a view or an API.

[**Modular monoliths**](https://milanjovanovic.tech/blog/what-is-a-modular-monolith) promise the productivity of a monolith and the clear boundaries of microservices.
They work because each module is self-contained: its domain model, behavior and data live behind a boundary.
But one of the hardest places to maintain those boundaries is in the database.

Nothing stops a developer from running a rogue `JOIN` across tables or bypassing a public API.

In previous articles, I described [**four levels of data isolation**](https://milanjovanovic.tech/blog/modular-monolith-data-isolation) (table, schema, database and alternative persistence)
and argued that [**modules should expose explicit APIs**](https://milanjovanovic.tech/blog/internal-vs-public-apis-in-modular-monoliths) to access their data.

This article goes deeper on the database side.
You'll learn how to carve out logical and physical boundaries using PostgreSQL schemas and roles and EF Core,
why those choices matter, and how to handle cross-cutting queries without breaking encapsulation.

## Why enforce database boundaries?

In a modular monolith each module **owns its data**.
If Module A reaches into Module B's tables, you lose this constraint and your modules become tightly coupled.
Instead, B exposes a public API and hides its persistence logic from consumers.

Beyond clean code, enforcing boundaries at the database level protects you against mistakes
and makes it easier to extract a module into its own service later.

Database schemas act like folders: they let you organise objects and share a database among many users,
but a user can access any schema only if they have privileges.
This means we can deliberately lock each module to its own schema.

**The strategy**

- Create a [**schema per module**](https://milanjovanovic.tech/blog/schema-per-module-vs-database-per-module) and a dedicated database role.
- Grant that role privileges only on its schema and set its default search path.
- Use EF with one `DbContext` per module, setting a default schema and connection string per module.
- For cross-cutting queries, publish a read-only database view that acts like a public API.
- _Optional_: Use row-level security policies to restrict access within a table.

These practices give us **enforceable boundaries** while keeping the **operational overhead low**.

## Schemas, roles and search paths

PostgreSQL lets you create multiple schemas within a single database.
A module can define its own schema and a role that owns it.
The role only has usage and table-level privileges on that schema.
For example, for an orders module:

```sql
-- create a user for the module and its schema
CREATE ROLE orders_role LOGIN PASSWORD 'orders_secret';
CREATE SCHEMA orders AUTHORIZATION orders_role;
GRANT USAGE ON SCHEMA orders TO orders_role;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA orders TO orders_role;
ALTER ROLE orders_role SET search_path = orders;
```

The `ALTER ROLE` command sets the role's default search path so unqualified names resolve to the module's schema.
If you don't want to rely on the search path, always qualify table names (`orders.table_name`).

Row-level security (RLS) allows you to filter rows based on a policy expression.
You enable RLS on a table and define a policy referencing `current_user` or columns:

RLS is powerful for multi-tenant scenarios or sensitive data, but it adds complexity.
Start with schemas and roles and add RLS only when necessary.

## Configuring EF schemas and multiple DbContexts

I assume readers are comfortable with EF Core, so we'll skip the basics and focus on what matters for modular monoliths:

- Use [**one DbContext per module**](https://milanjovanovic.tech/blog/using-multiple-ef-core-dbcontext-in-single-application).
  Each context contains only the entities of its module, and you call `modelBuilder.HasDefaultSchema("orders")` in `OnModelCreating`
  to map entities to the correct schema.
  Setting a default schema also affects sequences and migrations.
- Provide a connection string per module using the module's role.
  Even if modules share the same database, separate credentials ensure that a misconfigured context cannot access another schema.
- Configure the migrations history table in each context using `MigrationsHistoryTable("__EFMigrationsHistory", "orders")`
  so that EF's migration metadata stays within the module's schema.

These settings ensure EF Core queries and migrations respect the boundaries established by the database.

## Step-by-step: Enforcing module boundaries

Suppose we have two modules (**Orders** and **Shipping**) and we want to enforce boundaries between them.
Here's what we need to do:

1. **Create schemas and roles**.
   Use SQL to create orders and shipping schemas and their corresponding roles (`orders_role`, `shipping_role`).
   Grant each role privileges only on its schema.

   ```sql
   -- Orders schema and role
   CREATE ROLE orders_role LOGIN PASSWORD 'orders_secret';
   CREATE SCHEMA orders AUTHORIZATION orders_role;
   GRANT USAGE ON SCHEMA orders TO orders_role;
   GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA orders TO orders_role;
   ALTER ROLE orders_role SET search_path = orders;

    -- Shipping schema and role
    CREATE ROLE shipping_role LOGIN PASSWORD 'shipping_secret';
    CREATE SCHEMA shipping AUTHORIZATION shipping_role;
    GRANT USAGE ON SCHEMA shipping TO shipping_role;
    GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA shipping TO shipping_role;
    ALTER ROLE shipping_role SET search_path = shipping;
   ```

2. **Add connection strings**. In configuration, define separate connection strings per module:

   ```json
   {
     "ConnectionStrings": {
       "Orders": "Host=localhost;Database=appdb;Username=orders_role;Password=orders_secret",
       "Shipping": "Host=localhost;Database=appdb;Username=shipping_role;Password=shipping_secret"
     }
   }
   ```

3. **Define DbContexts**. Each module defines its own context and sets the default schema. For the Orders module:

   ```csharp
   public class OrdersDbContext : DbContext
   {
       public DbSet<Order> Orders { get; set; } = default!;
       public DbSet<OrderLine> OrderLines { get; set; } = default!;

       public OrdersDbContext(DbContextOptions<OrdersDbContext> options) : base(options) { }

       protected override void OnModelCreating(ModelBuilder modelBuilder)
       {
           // set default schema for all entities in this context
           modelBuilder.HasDefaultSchema("orders");
           // optional: configure tables explicitly
           modelBuilder.Entity<Order>().ToTable("orders");
           modelBuilder.Entity<OrderLine>().ToTable("order_lines");
           base.OnModelCreating(modelBuilder);
       }
   }
   ```

   Register each context with its connection string and specify the migrations history table:

   ```csharp
   builder.Services.AddDbContext<OrdersDbContext>(options =>
       options.UseNpgsql(builder.Configuration.GetConnectionString("Orders"),
           o => o.MigrationsHistoryTable("__EFMigrationsHistory", "orders")));

   builder.Services.AddDbContext<ShippingDbContext>(options =>
       options.UseNpgsql(builder.Configuration.GetConnectionString("Shipping"),
           o => o.MigrationsHistoryTable("__EFMigrationsHistory", "shipping")));
   ```

4. **Maintain migrations separately**.
   Because each module has its own context and schema, you maintain migrations separately.
   When generating a migration, specify the context:

   ```bash
   dotnet ef migrations add InitialOrders --context OrdersDbContext --output-dir Data/Migrations/Orders
   ```

   Repeat this for the Shipping context.
   EF Core will generate migration classes that create tables within the specified schema (because of `HasDefaultSchema`).
   Remember to apply the migrations in the correct order when deploying. You can automate this by having a migration runner iterate through the contexts.

With schemas, roles and multiple contexts in place, the data boundary becomes enforceable at the database level:

- The **Orders** module's `DbContext` knows only about the orders schema and uses credentials that have no privileges on the **Shipping** schema.
- The **Shipping** module cannot query `orders.orders` directly because its role lacks the necessary privileges.
- Cross-module communication must go through the module's public API (or an asynchronous event).
  This explicit coupling makes dependencies obvious and maintainable.

## Cross-cutting queries

Even in a modular system you occasionally need a screen that spans multiple modules,
such as an **Order History** page that shows order and shipping data.
Resist the temptation to `JOIN` across schemas.
Two approaches can help:

- **Dedicated read model**.
  One module owns a view model and [**subscribes to events**](https://milanjovanovic.tech/blog/event-driven-communication-modules) from others.
  This pattern works well when modules might be extracted later.

- **Database views with privileges**.
  Since our modules share a database, we can create a read-only view in the public schema that joins the relevant tables.
  We grant `SELECT` on the view to a special role or module.
  This view acts like a controlled public API.
  Consumers query the view but cannot access the underlying tables directly.
  The trade-off is similar to calling a synchronous API: if you later split the database, the view will have to be replaced with a service call.

For example:

```sql
CREATE VIEW public.order_summary AS
SELECT o.id, o.total, s.status
FROM orders.orders o
JOIN shipping.shipments s ON s.order_id = o.id;

-- grant read access to a reporting role
GRANT SELECT ON public.order_summary TO reporting_role;
```

This approach lets you build dashboards or admin screens without breaking boundaries.

## Conclusion

By giving each module its own schema, role and DbContext, and by controlling cross-module access via views or APIs,
you **ensure that boundaries in your modular monolith are enforceable** rather than aspirational.
This discipline makes it easier to evolve your system and paves the way for a future [**microservice extraction**](https://milanjovanovic.tech/blog/when-to-extract-module-to-microservice).

If you found this article valuable, check out my previous posts on modular monoliths:

- [What Is a Modular Monolith?](https://milanjovanovic.tech/blog/what-is-a-modular-monolith)
- [Modular Monolith Data Isolation](https://milanjovanovic.tech/blog/modular-monolith-data-isolation)
- [Internal vs Public APIs in Modular Monoliths](https://milanjovanovic.tech/blog/internal-vs-public-apis-in-modular-monoliths)

If you want a structured, hands-on approach to building modular systems, from defining boundaries to extracting services,
check out my [**Modular Monolith Architecture**](https://milanjovanovic.tech/modular-monolith-architecture) course.
Join more than 2,100+ students who have mastered modular monoliths with it.
The course walks through these patterns in depth and shows how to apply them in a real codebase.

Thanks for reading.

And stay awesome!

---

## Frequently asked questions

### How do you enforce module boundaries at the database level in a modular monolith?

Create a schema per module and a dedicated database role, GRANT that role privileges only on its own schema, and set its default search_path. Then give each module its own EF Core DbContext and a connection string using the module's role, so a misconfigured module cannot query another module's tables.

### Why enforce data boundaries in the database instead of only in code?

Because nothing in code stops a developer from running a rogue JOIN across tables or bypassing a public API. Database-level privileges protect against mistakes, keep modules loosely coupled, and make it much easier to extract a module into its own service later.

### Should each module in a modular monolith have its own DbContext?

Yes. Each context contains only its module's entities and calls HasDefaultSchema in OnModelCreating to map them to the module's schema. Pair it with a per-module connection string and configure MigrationsHistoryTable in the same schema so EF migration metadata stays inside the boundary.

### How do you handle queries that span multiple modules?

Resist cross-schema JOINs. Either build a dedicated read model that one module owns and populates by subscribing to events from others, or publish a read-only database view and GRANT SELECT on it to a reporting role. The view acts like a controlled public API over the underlying tables.

### What do PostgreSQL schemas and roles do in a modular monolith?

Schemas act like folders inside one database, and a role only accesses schemas it has privileges on. You create each schema with AUTHORIZATION for its module role, GRANT USAGE plus table privileges on that schema alone, and use ALTER ROLE to set the search_path, locking each module to its schema.

### Do you need row-level security in a modular monolith?

Usually not at first. Row-level security filters rows with policy expressions and is powerful for multi-tenant scenarios or sensitive data, but it adds complexity. Start with schemas and roles for module isolation, and add RLS only when you actually need per-row restrictions.
