Schema-Per-Module vs Database-Per-Module: Which Data Isolation Strategy Should You Pick?

Schema-Per-Module vs Database-Per-Module: Which Data Isolation Strategy Should You Pick?

6 min read··

dotnetef-coremodular-monolithsoftware-architecture

Start with schema-per-module: each module owns a schema inside one shared database, which gives you real data isolation with far less operational overhead. Graduate to database-per-module when a module needs independent scaling, independent backup and restore, or is about to become a microservice.

When you build a modular monolith, one of the earliest decisions you'll face is how to isolate data between modules. Two strategies dominate the conversation: schema-per-module and database-per-module.

They're the two strongest levels on the data isolation spectrum, which also includes table prefixes at the weak end.

Both enforce module boundaries at the data layer. But they make very different trade-offs around complexity, consistency, and your future migration path. The right choice depends on where your system is today - not where you hope it'll be in two years.

Let me break down each strategy, show you how to wire them up in EF Core, and compare them head to head.

Diagram contrasting schema-per-module, where one database holds a catalog schema and an ordering schema, with database-per-module, where the catalog and ordering modules each get their own separate database

Schema-Per-Module

The schema-per-module strategy keeps all modules inside a single database but assigns each module its own schema. The catalog module owns catalog.products, while the ordering module owns ordering.orders. They share a database server and connection string but have logically separated table namespaces.

In EF Core, you configure this with HasDefaultSchema on each DbContext:

public class CatalogDbContext : DbContext
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("catalog");

        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(CatalogDbContext).Assembly);
    }
}

public class OrderingDbContext : DbContext
{
    public DbSet<Order> Orders => Set<Order>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("ordering");

        modelBuilder.ApplyConfigurationsFromAssembly(
            typeof(OrderingDbContext).Assembly);
    }
}

Both contexts share the same connection string. Each one also gets its own migrations history table, inside its own schema, so the modules can add and apply migrations independently:

var connectionString = builder.Configuration
    .GetConnectionString("DefaultConnection");

builder.Services.AddDbContext<CatalogDbContext>(options =>
    options.UseNpgsql(connectionString, npgsql =>
        npgsql.MigrationsHistoryTable(
            "__EFMigrationsHistory", "catalog")));

builder.Services.AddDbContext<OrderingDbContext>(options =>
    options.UseNpgsql(connectionString, npgsql =>
        npgsql.MigrationsHistoryTable(
            "__EFMigrationsHistory", "ordering")));

Without the MigrationsHistoryTable call, both contexts record their migrations in the same default __EFMigrationsHistory table, which couples the modules' migration workflows.

This strategy works well when you're starting out or when your modules share traffic patterns and don't need independent scaling. One database means one backup strategy, one monitoring target, and one connection string in your configuration.

Database-Per-Module

The database-per-module strategy gives each module its own database entirely. The catalog module connects to myshop_catalog, and the ordering module connects to myshop_ordering. There's no way to accidentally join across module boundaries because the tables live on separate databases.

The EF Core setup uses distinct connection strings:

builder.Services.AddDbContext<CatalogDbContext>(options =>
    options.UseNpgsql(builder.Configuration
        .GetConnectionString("CatalogDb")));

builder.Services.AddDbContext<OrderingDbContext>(options =>
    options.UseNpgsql(builder.Configuration
        .GetConnectionString("OrderingDb")));

Your configuration carries multiple connection strings:

{
  "ConnectionStrings": {
    "CatalogDb": "Host=localhost;Database=myshop_catalog;Username=app;Password=secret",
    "OrderingDb": "Host=localhost;Database=myshop_ordering;Username=app;Password=secret"
  }
}

Migrations are completely independent. Each module runs its own migration at startup without any coordination:

using var scope = app.Services.CreateScope();

var catalogDb = scope.ServiceProvider.GetRequiredService<CatalogDbContext>();
await catalogDb.Database.MigrateAsync();

var orderingDb = scope.ServiceProvider.GetRequiredService<OrderingDbContext>();
await orderingDb.Database.MigrateAsync();

This strategy works well when you need physical isolation between modules. If one module has heavy write loads and another is read-heavy, separate databases let you tune each independently. It also gives you independent backup and restore capabilities.

What Are the Real Trade-offs?

Choosing between schema-per-module and database-per-module comes down to five key areas.

Cross-module transactions. With schemas, both modules share a database, so you can wrap operations across modules in a single transaction. Treat that as an escape hatch, not a feature - every shared transaction couples the modules a little more. With separate databases, cross-module transactions are impossible by construction. You'll need patterns like the outbox pattern or sagas to maintain consistency across module boundaries. This is a significant complexity jump.

Deployment complexity. Schema-per-module means one database to provision, monitor, and back up. Database-per-module multiplies that by the number of modules. If you have five modules, you now have five databases to manage. In production, this means more connection pools, more monitoring dashboards, and more backup schedules.

Performance isolation. Schemas share the same database resources - CPU, memory, I/O. A poorly optimized query in one module can degrade performance for every other module. Separate databases give you true resource isolation. You can allocate more resources to your busiest module without touching the others.

Developer experience. Schema-per-module is simpler for local development. One PostgreSQL instance, one connection string, and you're running. Database-per-module means each developer needs multiple database instances, which usually means a Docker Compose file with several containers. Not a dealbreaker, but it adds friction.

Migration path to microservices. If you plan to eventually extract modules into microservices, database-per-module puts you closer to that goal. Each module already owns its data store. With schema-per-module, extracting a module means migrating its schema into a new database and updating all module communication to handle network boundaries.

Head-to-Head Comparison

Here's the whole comparison condensed:

Schema-per-moduleDatabase-per-module
Isolation levelLogical, in one databasePhysical, separate databases
Cross-module transactionsPossible, but discouragedImpossible, forces saga and outbox patterns
Deployment overheadOne database to manageOne database per module
Performance isolationShared CPU, memory, and I/OIndependent resources
Local dev setupOne connection stringDocker Compose file with several database containers
Migration to microservicesData extraction step required firstAlready separated
Backup and restoreAll-or-nothingPer module
Connection poolingA single poolOne pool per module, watch total connection counts

Enforcing the Boundary Either Way

Whichever strategy you pick, isolation only holds if nothing bypasses it. Use a separate database user per module (with permissions limited to its own schema or database) so a cross-module query fails loudly instead of working silently. I cover more enforcement techniques in how to keep your data boundaries intact.

Summary

Start with schema-per-module. It gives you meaningful data isolation with minimal infrastructure overhead. You get separate namespaces, independent migration histories, and a clear boundary that prevents accidental cross-module queries through EF Core.

Then graduate to database-per-module when you hit one of these triggers:

  • A module needs independent scaling due to different load patterns
  • You need to restore one module's data without affecting others
  • A noisy-neighbor problem is degrading performance across modules
  • You're actively planning to extract a module into a service

The transition from schemas to databases is straightforward. You create the new database, migrate the schema's tables into it, and update the connection string. No changes to your DbContext classes or application code beyond configuration.

Picking the "best" strategy upfront is less important than picking one that enforces boundaries at all. A modular monolith with schema isolation beats a monolith where every module reaches into every other module's tables.

Thanks for reading, and stay awesome!

Frequently Asked Questions

Should each module in a modular monolith have its own database?

Usually not at the start. Schema-per-module in a single database gives you real isolation with far less operational overhead. Move a module to its own database when it needs independent scaling, backups, or is about to become a microservice.

What is schema-per-module?

Each module owns a database schema inside one shared database, for example catalog.products and ordering.orders. Modules share a server and connection string, but each EF Core DbContext only maps its own schema.

Can you do cross-module transactions with schema-per-module?

Physically yes, since everything is one database. But you should avoid it, because a shared transaction couples modules and blocks future extraction. Prefer integration events and the outbox pattern for cross-module consistency.

How do EF Core migrations work with multiple DbContexts in one database?

Give each DbContext its own migrations history table using MigrationsHistoryTable, ideally inside the module schema. Each module can then add and apply migrations independently without collisions.

Is it hard to move from schema-per-module to database-per-module?

No, and that is the main reason to start with schemas. If modules never query each other directly, moving one to its own database is mostly a data copy plus a connection string change.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.