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.


#1. Start modular and extract only with a reason

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.

To decide with criteria, see the styles comparison.


#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 table
services.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.


#3. The public API is the only door

Each module exposes a PublicApi project with one interface and a few DTOs (records). Everything else is internal:

CSHARP
// Sales.PublicApi
public interface ISalesModule
{
    Task<OrderSummary?> GetOrderAsync(Guid orderId, CancellationToken ct);
}

public sealed record OrderSummary(Guid Id, decimal Total, string Status);

// Sales (internal project)
internal sealed class SalesModule(SalesDbContext db) : ISalesModule
{
    public async Task<OrderSummary?> GetOrderAsync(Guid orderId, CancellationToken ct) =>
        await db.Orders
            .Where(o => o.Id == orderId)
            .Select(o => new OrderSummary(o.Id, o.Total, o.Status.ToString()))
            .SingleOrDefaultAsync(ct);
}

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.

CSHARP
quot;Forbidden dependencies: {string.Join(", ", result.FailingTypeNames ?? [])}"); }" aria-label="Copiar código"> Copiar
[Fact]
public void SalesMustNotDependOnBillingInternals()
{
    TestResult result = Types.InAssembly(typeof(SalesModule).Assembly)
        .ShouldNot()
        .HaveDependencyOnAny("Billing.Domain", "Billing.Infrastructure", "Billing.Features")
        .GetResult();

    Assert.True(result.IsSuccessful,
        $"Forbidden dependencies: {string.Join(", ", result.FailingTypeNames ?? [])}");
}

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.


#6. Read other modules through their public API

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:

  1. 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);
  1. 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.


#8. Same database does not mean same transaction

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.


#9. Pick consistency by the cost of being wrong

Not everything needs a transaction:

Situation If it goes wrong What to use
Carrier notification failed Retry later; the order still stands Event (eventual)
Two customers buy the last item Real money lost One transaction (strong consistency)
Send an email or update analytics A delay doesn't hurt Event (eventual)

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.


#10. Keep the path to services ready

If a module ever has to leave, don't start by splitting repositories: you will spend weeks building pipelines before anything works. Do it in steps:

  1. Give it its own host project inside the same solution, so it can run as a separate process.
  2. Replace method calls with HTTP. The public API interface becomes an HTTP client.
  3. Only then move events to a message bus.

To make this viable, give each module its own tracing so every trace shows which module did what:

CSHARP
internal static class SalesTelemetry
{
    public static readonly ActivitySource Source = new("MyApp.Sales");
}

using Activity? activity = SalesTelemetry.Source.StartActivity("PlaceOrder");

Register each source with AddSource("MyApp.Sales") in OpenTelemetry.


#When you do NOT need all this

#modulith #dotnet #ef-core #architecture #ddd