Modular monolith in .NET: 10 rules to keep boundaries from breaking
Practical rules for building a modular monolith in .NET with EF Core. One schema per module, public API as the only door, architecture tests, transactions, consistency and how to keep the path to services open.
A modular monolith has a single deployment but modules with real boundaries. The hard part is not drawing the modules: it is stopping someone from crossing them "just for today" three months later. Here are ten rules to prevent that, with .NET and EF Core examples.
Inspired by 20 Laws of Modular Monoliths, by Anton Martyniuk. I picked ten, rewrote them with my own examples and added the nuances I find important. For the general concept, see Modulith.
Begin with one deployment and separate modules. Extract a module into a service only when it needs something the monolith cannot give it: its own team, its own release schedule or its own scaling.
The underlying reason: you will get your boundaries wrong the first time, everyone does. In a modular monolith, fixing a boundary is a refactor inside one solution. In microservices it is a migration across services, contracts and teams.
#2. One schema per module, not one database per module
You don't need four databases for four modules. One database and one schema per module is enough, with a DbContext per module that only sees its own tables:
CSHARP
public sealed class SalesDbContext(DbContextOptions<SalesDbContext> options) : DbContext(options){ public DbSet<Order> Orders => Set<Order>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema("sales"); modelBuilder.ApplyConfigurationsFromAssembly(typeof(SalesDbContext).Assembly); }}// Registration: module-owned migrations history tableservices.AddDbContext<SalesDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.MigrationsHistoryTable("__EFMigrationsHistory", "sales")));
With its own history table, each module migrates independently. The rule: no module touches another module's tables, not even to read.
Keeping the implementation internal makes the compiler hide the details. And if you later extract the module into a service, this same interface becomes an HTTP client without changing its callers.
It is the same principle as the ports in Hexagonal, applied between modules.
#4. Boundaries are guarded by a test, not by the compiler
The compiler won't protect you from a "just for today" reference to another module's Domain: it compiles fine. Your AI agent will take the same shortcut. The defense is an architecture test that fails the PR and names the type that broke the rule.
Use NetArchTest (or ArchUnitNET). Run it in CI, not only locally.
#5. Inside the module: slices, rich domain and explicit results
One project per layer or per feature, depending on size. For a module with rules, three projects work well: Domain, Features and Infrastructure. Inside Features, one folder per use case with endpoint, handler, validator and mapping together (see Vertical Slice). A simple CRUD module can live in a single project: no ceremony where there are no rules.
CSHARP
Shipping/├── Shipping.Domain/ ← entities, value objects├── Shipping.Features/│ ├── CreateShipment/ ← endpoint + handler + validator + mapping│ └── CancelShipment/├── Shipping.Infrastructure/ ← DbContext, migrations, external clients└── Shipping.PublicApi/ ← the only door
Rich domain. Private setters and methods with meaning (Dispatch(), Cancel()), so invalid states cannot exist.
Results instead of exceptions for expected failures. Return Result<T> when a failure is part of the normal flow (not found, conflict, invalid data) and map it at the edge to 404, 409 or 400. Reserve exceptions for the unexpected.
About the repository: inside a module, using the DbContext directly in handlers is usually enough, since it is already a repository and unit of work. It is a topic with nuances; I covered it in DbContext vs. Repository + Unit of Work.
Your first dashboard will test all your boundaries: shipments, carriers and stock on one screen is a JOIN you cannot write because the data belongs to different modules. You have two tools:
Call each module's public API. Add batch methods to avoid the N+1 problem:
CSHARP
// Instead of calling GetCarrierAsync(id) in a loop: Task<IReadOnlyDictionary<Guid, CarrierInfo>> GetCarriersAsync( IReadOnlyCollection<Guid> ids, CancellationToken ct);
Copy data that rarely changes (for example, a carrier's name) into your own schema through events. That way your module does not depend on another for every read.
Careful: copying data introduces eventual consistency and the copy must be maintained. Use it only for stable data.
#7. Reports: a separate module with read-only access
If you truly need queries that cross schemas (reports, analytics), create a dedicated reporting module with a read-only database user. It is the only place allowed to join across schemas, and since it never writes, it cannot break anyone's invariants.
This keeps the rest of the system clean without giving up cross-cutting queries.
Two DbContext over the same database do not share a transaction automatically. A shared transaction needs one physical connection:
CSHARP
await using var connection = new NpgsqlConnection(connectionString);await connection.OpenAsync(ct);await using var transaction = await connection.BeginTransactionAsync(ct);await using var sales = new SalesDbContext( new DbContextOptionsBuilder<SalesDbContext>().UseNpgsql(connection).Options);await using var stock = new StockDbContext( new DbContextOptionsBuilder<StockDbContext>().UseNpgsql(connection).Options);await sales.Database.UseTransactionAsync(transaction, ct);await stock.Database.UseTransactionAsync(transaction, ct);// ... changes in both contexts ...await sales.SaveChangesAsync(ct);await stock.SaveChangesAsync(ct);await transaction.CommitAsync(ct);
The catch: if you give each module its own database user (a good isolation practice), each one uses its own connection and UseTransactionAsync fails. You then have two paths: share one connection for that specific write, or use events with Outbox.
The practical rule: events by default, and a single transaction where mistakes cost money (payments, stock, bookings). To publish safely, save first and publish after using Outbox, so you don't lose events if the process dies between the two steps.
A small team with a simple domain: a well-organized monolith by folders is enough. Modules with their own schema, public API and architecture tests are an investment that pays off when there are several teams or a growing domain.
If you know you'll always be a single team: rules 2, 3 and 4 are still worth it; rule 10 probably isn't.