When building a modular monolith in C#, the hardest design decision usually isn't how to slice your modules -- it's how to handle the database. Modular monolith database design in C# is where most teams hit their first real architectural wall: do you use one shared database? Multiple databases? One DbContext or one per module? Get this wrong and your "modular" architecture becomes just a regular monolith with extra folder structure.
This article walks you through the three main strategies for EF Core modular monolith database design, shows real code from a working reference implementation, and gives you a clear framework to pick the right approach for your situation. If you already have a modular monolith running and you're trying to decide whether your current database setup is going to hold up as the system grows -- this is the decision framework you need.
The Three Database Strategies
There are three main approaches to database per module .NET architecture:
- Option A: One shared database, one shared DbContext -- the traditional monolith approach. Familiar and easy to start with, but it quietly destroys your module boundaries.
- Option B: One shared database, separate DbContext per module -- a pragmatic middle ground. Strong code-level isolation without operational complexity.
- Option C: Separate database per module -- the strongest isolation. This is what the reference app uses, and it's where modular monolith multiple databases scenarios become real.
Each option involves genuine tradeoffs. Let's break them down honestly.
Option A: Shared DbContext (Why It Breaks Module Boundaries)
The shared DbContext approach is how most .NET monoliths start. One AppDbContext registered in DI with DbSet<> for every entity across the whole application. It works. But it undermines everything you're trying to achieve with modular design.
The coupling problem is subtle at first. Because all entities live in the same DbContext, any module can reference any other module's entity types at compile time. A Tasks module can import a User entity from the Users module and write LINQ queries that join across both. That seems convenient right up until you try to extract a module into its own service -- now you have hidden cross-module queries buried in repositories throughout the codebase.
Here's the anti-pattern:
// DON'T DO THIS -- one DbContext for the entire application
public class AppDbContext : DbContext
{
// All modules' entities in one context = hidden coupling
public DbSet<User> Users { get; set; }
public DbSet<Project> Projects { get; set; }
public DbSet<TaskItem> Tasks { get; set; }
}
// Now any module can do this -- and that's the problem
public class TaskRepository
{
private readonly AppDbContext _context;
public async Task<IEnumerable<TaskDto>> GetTasksWithUserNamesAsync()
{
// Cross-module join that should never exist
return await _context.Tasks
.Join(
_context.Users,
t => t.AssignedUserId,
u => u.Id,
(t, u) => new TaskDto(t.Id, t.Title, u.Name))
.ToListAsync();
}
}
That cross-module join is exactly the kind of hidden dependency that makes a modular monolith difficult to maintain over time. When it's time to extract the Tasks module to a microservice, you discover these joins scattered throughout the codebase. Refactoring becomes expensive. The shared DbContext looked convenient -- but you were paying for it in future technical debt from day one.
Use Option A only as a temporary state during a refactoring effort. It should never be the end goal for a modular monolith.
Option B: Separate DbContext per Module, Shared Database (The Pragmatic Middle Ground)
Option B gives you meaningful code boundaries without the operational complexity of managing multiple database servers. Each module gets its own DbContext scoped to its own tables. They all point at the same physical database, but no module can see another module's entities through EF Core.
The key technique is using EF Core schemas or table name prefixes to namespace each module's tables. This prevents accidental naming collisions and makes ownership obvious:
// Each module has its own isolated DbContext
public sealed class ProjectsDbContext : DbContext, IProjectsDbContext
{
DbSet<Project> IProjectsDbContext.Projects => Set<Project>();
public ProjectsDbContext(DbContextOptions<ProjectsDbContext> options)
: base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Schema scopes all tables under the "projects" namespace
modelBuilder.HasDefaultSchema("projects");
modelBuilder.Entity<Project>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(1000);
entity.Property(e => e.Status).IsRequired();
entity.Property(e => e.CreatedAt).IsRequired();
});
}
}
Each module registers its own DbContext independently in the DI container. The host project provides the connection string, but the module owns the configuration:
// ProjectsModuleExtensions.cs -- the module owns its own registration
public static IServiceCollection AddProjectsModule(
this IServiceCollection services,
string connectionString)
{
services.AddDbContext<ProjectsDbContext>(options =>
options.UseSqlServer(connectionString)); // Shared server, isolated context
services.AddScoped<IProjectsDbContext>(
sp => sp.GetRequiredService<ProjectsDbContext>());
services.AddScoped<IProjectsModule, ProjectsModule>();
return services;
}
This approach works well when you have one database server to manage, you want isolation at the code level but not the infrastructure level, and your team isn't ready for distributed transactions or eventual consistency. It's also the right default for teams who want clean module boundaries now but aren't committed to a microservices roadmap yet.
The tradeoff with Option B is that the isolation is soft. A developer can still write raw SQL that joins across module tables. You need team discipline to maintain boundaries because the architecture doesn't physically prevent violations. For most teams, that's an acceptable tradeoff.
Option C: Separate Database per Module (The Reference App Approach)
Option C takes module isolation to its logical conclusion -- each module gets its own physical database or file. The reference app uses SQLite, giving each module a separate .db file. No shared connection string, no possibility of cross-module SQL joins. The architecture enforces the boundary rather than relying on discipline alone.
Here's the actual code from the reference implementation.
ProjectsDbContext
using Microsoft.EntityFrameworkCore;
using Projects.Application;
using Projects.Domain;
namespace Projects.Infrastructure;
public sealed class ProjectsDbContext : DbContext, IProjectsDbContext
{
DbSet<Project> IProjectsDbContext.Projects => Set<Project>();
public ProjectsDbContext(DbContextOptions<ProjectsDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Name).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(1000);
entity.Property(e => e.Status).IsRequired();
entity.Property(e => e.CreatedAt).IsRequired();
});
}
}
TasksDbContext
using Microsoft.EntityFrameworkCore;
using Tasks.Application;
using Tasks.Domain;
namespace Tasks.Infrastructure;
public sealed class TasksDbContext : DbContext, ITasksDbContext
{
DbSet<TaskItem> ITasksDbContext.Tasks => Set<TaskItem>();
public TasksDbContext(DbContextOptions<TasksDbContext> options) : base(options)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<TaskItem>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.ProjectId).IsRequired();
entity.Property(e => e.Title).IsRequired().HasMaxLength(200);
entity.Property(e => e.Description).HasMaxLength(1000);
entity.Property(e => e.Status).IsRequired();
entity.Property(e => e.AssignedUserId); // Guid only -- no User navigation property
entity.Property(e => e.CreatedAt).IsRequired();
});
}
}
Notice that TasksDbContext has no reference to User entities. The AssignedUserId is stored as a raw Guid, not as a navigation property to a User object from the Users module. This is intentional -- and it's one of the most important things to get right in database per module .NET design.
Module Registration
Each module receives its own connection string pointing to its own SQLite file. From Program.cs:
builder.Services.AddUsersModule("Data Source=users.db");
builder.Services.AddProjectsModule("Data Source=projects.db");
builder.Services.AddTasksModule("Data Source=tasks.db");
And each module's extension method registers its own DbContext independently:
// Projects.Infrastructure/ProjectsModuleExtensions.cs
public static class ProjectsModuleExtensions
{
public static IServiceCollection AddProjectsModule(
this IServiceCollection services,
string connectionString)
{
services.AddDbContext<ProjectsDbContext>(options =>
options.UseSqlite(connectionString));
services.AddScoped<IProjectsDbContext>(
sp => sp.GetRequiredService<ProjectsDbContext>());
services.AddScoped<IProjectsModule, ProjectsModule>();
return services;
}
}
The module exposes only IProjectsModule to the rest of the application. Other modules never see ProjectsDbContext directly. For more on managing multiple interface implementations in DI -- which comes up naturally when you have multiple DbContext types -- see Keyed Services in Needlr: Managing Multiple Implementations.
Handling Cross-Module Data Needs
This is where modular monolith multiple databases design gets genuinely hard. The Tasks module stores AssignedUserId -- but what if you need to display the user's name alongside the task? The Users data lives in a completely separate database.
The wrong approach is injecting IUsersModule into TasksModule and calling it synchronously every time you need a name. Now Tasks has a compile-time dependency on Users, which partially defeats the isolation you worked to create.
// WRONG -- Tasks module reaching into Users module via direct dependency
public class TasksModule : ITasksModule
{
private readonly ITasksDbContext _db;
private readonly IUsersModule _usersModule; // Avoid this
public async Task<TaskDetailDto> GetTaskAsync(Guid taskId)
{
var task = await _db.Tasks.FindAsync(taskId);
// Synchronous cross-module call creates tight coupling
var user = await _usersModule.GetUserAsync(task.AssignedUserId!.Value);
return new TaskDetailDto(task.Id, task.Title, user?.Name);
}
}
// CORRECT -- Tasks module stores only the ID, no cross-module dependency
public class TaskItem
{
public Guid Id { get; private set; }
public Guid ProjectId { get; private set; }
public string Title { get; private set; } = string.Empty;
public Guid? AssignedUserId { get; private set; } // ID only, no navigation property
// ...
}
The correct approach uses events. When a user is registered, a UserRegisteredEvent fires. The Tasks module can listen for that event and cache the user name locally in its own database. Now Tasks has everything it needs without touching the Users database at runtime.
This is essentially the Observer Design Pattern in C# applied at the module communication level -- modules react to events without knowing who published them. In MediatR-based architectures, you'd use INotificationHandler<UserRegisteredEvent> in the Tasks module to receive and cache the event data.
For a deeper look at when to use the observer approach versus a mediator for this kind of cross-module communication, Observer vs Mediator Pattern in C#: Key Differences Explained walks through the tradeoffs. If you need background processing to handle event-driven data synchronization asynchronously, Hosted Services with Needlr: Background Workers and Lifecycle Management covers building reliable background workers in .NET.
EF Core Migrations with Multiple DbContexts
Managing EF Core migrations with multiple DbContexts is straightforward once you know the pattern. You use the --context flag on every dotnet ef command to specify which DbContext to target.
# Add a migration for the Projects module's DbContext
dotnet ef migrations add InitialCreate
--context ProjectsDbContext
--project src/Modules/Projects/Projects.Infrastructure
--startup-project src/Host
# Apply the migration for the Projects module only
dotnet ef database update
--context ProjectsDbContext
--project src/Modules/Projects/Projects.Infrastructure
--startup-project src/Host
# Add a migration for the Tasks module independently
dotnet ef migrations add InitialCreate
--context TasksDbContext
--project src/Modules/Tasks/Tasks.Infrastructure
--startup-project src/Host
Each module's Infrastructure project maintains its own Migrations folder. When you change an entity in the Projects module, you only add a migration for ProjectsDbContext -- the Tasks and Users modules are not touched. This independence is a significant operational advantage as the application grows.
With Option B (shared database), you still use the --context flag but all migrations apply to the same physical database. With Option C, each migration applies to a separate database file or server, so they're completely independent. For full reference on the EF Core CLI, the Microsoft EF Core tools documentation covers all available flags.
The reference app uses EnsureCreatedAsync for simplicity:
using (var scope = app.Services.CreateScope())
{
var usersDb = scope.ServiceProvider.GetRequiredService<UsersDbContext>();
await usersDb.Database.EnsureCreatedAsync();
var projectsDb = scope.ServiceProvider.GetRequiredService<ProjectsDbContext>();
await projectsDb.Database.EnsureCreatedAsync();
var tasksDb = scope.ServiceProvider.GetRequiredService<TasksDbContext>();
await tasksDb.Database.EnsureCreatedAsync();
}
For production, use MigrateAsync instead. EnsureCreatedAsync creates the schema but bypasses the migrations infrastructure entirely -- once you need to evolve your schema, you'll be stuck.
Which Strategy Should You Choose?
Here's a practical decision framework for modular monolith database design in C# based on the factors that actually matter:
| Factor | Option A (Shared DbContext) | Option B (Shared DB, Separate Contexts) | Option C (Separate Databases) |
|---|---|---|---|
| Isolation level | None | Code-level | Architecture-level |
| Operational complexity | Low | Low | Medium |
| Cross-module joins | Possible (dangerous) | Prevented by EF, not by DB | Impossible |
| Microservice extraction | Hard | Moderate | Easy |
| Team discipline required | High | Medium | Low |
| Schema evolution | One migration set | Per-module migrations | Per-module, per-database |
| Best for | Temporary refactoring step | Most teams | Teams with microservices roadmap |
Choose Option A only if you're refactoring an existing monolith and splitting DbContexts immediately isn't feasible. Treat it as a temporary state, not a destination.
Choose Option B when you want clean module boundaries without distributed system complexity. It's the right default for most teams building their first modular monolith. You get the structural benefits of modular design without the operational overhead of multiple database servers.
Choose Option C when you have a clear roadmap toward microservices, strong DevOps maturity, and a team comfortable with eventual consistency. This is what the reference app demonstrates with SQLite -- in production, this pattern extends to separate PostgreSQL databases, separate schemas on different servers, or whatever your infrastructure supports.
Choosing between these approaches is itself a strategic decision. The Strategy Design Pattern in C# is relevant here -- the pattern of encapsulating behavior behind interfaces (like IProjectsDbContext) is exactly what makes it possible to evolve your database strategy over time without rewriting module logic.
Common Mistakes
Sharing DbContext Across Module Boundaries
The most common mistake is injecting one module's DbContext directly into another module's service or repository. If TasksModule takes ProjectsDbContext as a constructor parameter, you've wired two modules together at the infrastructure level. Use module interface abstractions (IProjectsModule) and never expose a DbContext beyond the assembly that owns it.
Cross-Module LINQ Joins
Even with separate DbContexts, developers sometimes load data from two modules and join them in-memory. Large in-memory joins are a performance problem. If you find yourself doing this frequently, either your module boundaries are wrong -- or you need to cache the relevant data locally via events, as described in the cross-module data section above.
Leaking Entity Types Across Module Boundaries
If Projects.Domain.Project is referenced by the Tasks module, you've leaked a domain entity across module boundaries. Each module's entities should be internal to that module. The TasksDbContext has no DbSet<Project> -- and ProjectsDbContext has no DbSet<TaskItem>. That separation is what makes each module independently deployable in the future.
Using EnsureCreatedAsync in Production
EnsureCreatedAsync is fine for demos and local development. In production, it creates the database schema but bypasses EF Core's migrations infrastructure entirely. Once you need to evolve your schema, you won't have a migrations history to work with. Use MigrateAsync for production deployments.
Ignoring Eventual Consistency
With separate databases, you no longer have ACID transactions across module boundaries. A task can hold a reference to a user ID that no longer exists in the Users database. Your application needs to handle these scenarios gracefully -- through validation at the API boundary, soft deletes, or event-driven cleanup. The Decorator Design Pattern in C# is useful for adding retry logic or idempotency checks to event handlers without changing their core logic.
FAQ
What is the best database strategy for a modular monolith in C#?
For most teams, Option B -- separate DbContext per module in a shared database -- offers the best tradeoffs. You get strong code-level isolation, manageable migrations, and no distributed system complexity. Option C (separate databases per module) is better if you're actively building toward microservices and have the operational maturity to support multiple database servers.
Can I use EF Core with multiple DbContexts in the same application?
Yes, EF Core fully supports multiple DbContexts in the same application without special configuration. Each DbContext is registered independently in the DI container. You can have them pointing at different databases (as in the reference app) or different schemas in the same database. The key is that each DbContext is configured independently and only knows about its own entities.
How do I prevent one module from querying another module's tables?
The most reliable way is Option C -- separate databases make cross-module queries physically impossible. With Option B, keep each module's concrete DbContext class internal to its Infrastructure assembly. Only the interface (e.g., IProjectsDbContext) is visible outside the assembly. Team code review and architecture tests (using ArchUnitNET or similar) can catch violations before they merge.
Should I use a separate DbContext or a separate database per module?
Start with a separate DbContext per module pointing at the same database. Separate databases add real operational overhead -- more connection strings, no cross-database transactions, more infrastructure to provision and monitor. Move to separate databases only when you have a concrete reason to do so, such as preparing a specific module for microservice extraction.
How do I run EF Core migrations when I have multiple DbContexts?
Use the --context flag with every dotnet ef migrations add and dotnet ef database update command to specify which DbContext to target. Each module's Infrastructure project maintains its own Migrations folder. Migrations for each module are completely independent -- adding a migration to ProjectsDbContext has no impact on TasksDbContext or UsersDbContext.
How do modules share data if they have separate databases?
They don't share databases -- they communicate through interfaces and events. If a module needs data that another module owns, it either calls that module's public interface (for synchronous lookups) or subscribes to domain events and caches the relevant data locally (for display or filtering needs). The event-driven approach is preferable because it avoids runtime coupling between modules.
What happens to cross-module transactions with separate databases?
Cross-module ACID transactions become impossible with separate databases. You need to design your modules to embrace eventual consistency -- using sagas, outbox patterns, or compensating transactions for operations that span modules. This is one of the main reasons to delay moving to Option C until your team is ready to reason about distributed consistency.
Conclusion
Modular monolith database design in C# is a spectrum between convenience and isolation. The shared DbContext approach is the most familiar but quietly undermines module independence over time. Separate DbContexts sharing one database hits the sweet spot for most teams building their first modular monolith. Separate databases per module offer the strongest isolation but demand more from your infrastructure and team.
The reference app demonstrates Option C with SQLite -- each module gets its own .db file, its own DbContext, and its own migration target. ProjectsDbContext, TasksDbContext, and UsersDbContext are completely isolated. No module touches another module's data without going through the public module interface.
Whatever strategy you choose, the key principle holds across all three options: module boundaries must be enforced at the code level, not just in documentation. Interfaces like IProjectsDbContext, module-owned registration methods like AddProjectsModule, and strict avoidance of cross-module entity references are what make a modular monolith actually modular -- today, and as the system grows.

