A modular monolith is a single deployable application built from modules that own their own code, data and public contract, so the codebase behaves like a set of small services without the operational cost of running several of them. It is written for teams who feel the pain of an unstructured monolith but are not ready to pay for distributed systems: independent teams, unclear ownership, and a domain that is still settling. This guide covers what makes a module a module, how to separate data per module, in-process messaging between modules, enforcing boundaries with architecture tests, what belongs in a shared kernel, testing strategy, and how to extract a module into its own service once you have a proven reason to.

What Is a Modular Monolith?#

A modular monolith is one process, one deployment unit and usually one solution, organized so that each business capability, a module, is a self-contained vertical slice: its own domain model, its own persistence, and a small, explicit public surface that other modules call through. Nothing outside the module reaches into its internals, whether that is a repository, an entity, or a table. The name is sometimes shortened to "modulith," but the idea predates the word: it is simply a monolith with disciplined internal boundaries instead of a tangle of shared services and cross-cutting repositories.

The appeal is that it gives you most of what people actually want from microservices, clear ownership, independent development, the option to scale a capability out later, without the parts that are expensive from day one: network calls where a method call used to be, eventual consistency everywhere, and a fleet of services to deploy, monitor and secure. Kamil Grzybek's widely referenced Modular Monolith with DDD sample demonstrates the pattern end to end in .NET: independent modules for user access, registrations, meetings and payments, each with its own Application, Domain, Infrastructure and IntegrationEvents layers, communicating only through an in-memory events bus.

How a Modular Monolith Works: Modules, Boundaries and Contracts#

Three rules do almost all of the work:

  • A module owns its data exclusively. No other module, and no shared reporting query, reads or writes its tables directly.
  • A module exposes a small, deliberate public contract, made of interfaces, DTOs and integration events, and keeps everything else internal.
  • Modules communicate only through that contract, either a synchronous call for a query or an asynchronous event for a fact that already happened; they never share a DbContext, a repository or a domain entity.

Module boundaries usually mirror bounded contexts from Domain-Driven Design: Orders, Catalog, Payments and Shipping are plausible modules because each one is a cohesive area of the business with its own language and its own reasons to change. A module built around a database table instead, such as a generic "Products" CRUD module used by everyone, tends to become the same kind of tangled dependency that layered monoliths have, just renamed.

Getting Started: Structuring a Modular Monolith Solution#

The two common layouts trade ceremony for enforcement strength. One project per module gives you a real assembly boundary: the C# internal keyword genuinely stops other modules from reaching in, and the compiler catches violations for free. One project, folder-per-module is faster to set up and easier to refactor across early on, but every module lives in the same assembly, so internal no longer separates them and you rely entirely on convention plus architecture tests, covered below.

Text
Shop.slnx
src/
  Modules/
    Orders/
      Orders.Contracts/       IOrdersApi.cs, OrderSummary.cs, OrderPlaced.cs (public)
      Orders.Domain/          Order.cs, OrderLine.cs (internal)
      Orders.Application/     PlaceOrderHandler.cs (internal)
      Orders.Infrastructure/  OrdersDbContext.cs, OrdersApi.cs (internal)
    Catalog/
      Catalog.Contracts/
      Catalog.Domain/
      Catalog.Application/
      Catalog.Infrastructure/
  Shared/
    Shop.SharedKernel/        Money.cs, Result.cs (referenced by every module)
  Shop.Api/                   Program.cs: composition root, no business logic
tests/
  Shop.ArchitectureTests/     NetArchTest or ArchUnitNET rules

Shop.Api references every module's Contracts and Infrastructure project so it can wire up dependency injection, but application code never references another module's Domain, Application or Infrastructure project, only its Contracts.

Module Boundaries and Public Contracts#

A public contract should read like an API you would be comfortable versioning for an external consumer: interfaces for queries a caller needs synchronously, and events for facts other modules might want to react to. Everything the module needs internally, entities, EF Core configurations, validators, stays internal so it simply cannot be referenced from outside the project.

C#
// Orders.Contracts β€” the only project other modules are allowed to reference.
public interface IOrdersApi
{
    Task<OrderSummary?> GetOrderAsync(Guid orderId, CancellationToken ct);
}

public sealed record OrderSummary(Guid OrderId, string Status, decimal Total);
public sealed record OrderPlaced(Guid OrderId, decimal Total) : IIntegrationEvent;

// Orders.Infrastructure β€” internal, so Catalog or Payments cannot new this up directly.
internal sealed class OrdersApi(OrdersDbContext db) : IOrdersApi
{
    public async Task<OrderSummary?> GetOrderAsync(Guid orderId, CancellationToken ct) =>
        await db.Orders.Where(o => o.Id == orderId)
            .Select(o => new OrderSummary(o.Id, o.Status, o.Total))
            .FirstOrDefaultAsync(ct);
}

Resist returning EF Core entities or IQueryable from a contract method: both leak the module's internal shape and let a caller compose a query that silently depends on implementation details, such as column names, that the module never agreed to keep stable.

Data Separation: A Schema per Module#

Physical database-per-module is usually unnecessary inside a monolith; a schema per module in one database gets you almost all of the benefit, keeps backups and transactions simple, and is easy to promote to a separate database later if a module is extracted. Enforce it in code so no DbContext can even compile a cross-schema join by accident:

C#
public sealed class OrdersDbContext(DbContextOptions<OrdersDbContext> options) : DbContext(options)
{
    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.HasDefaultSchema("orders");
        modelBuilder.ApplyConfigurationsFromAssembly(typeof(OrdersDbContext).Assembly);
    }
}

Each module gets its own DbContext type, its own migrations history table, and its own connection string if you want that level of isolation, even while all of them point at the same physical server. A read model that genuinely needs data from several modules, a dashboard or a search index, should be built by subscribing to integration events and maintaining its own denormalized store, the same technique a microservice would use, rather than by querying across schemas.

In-Process Messaging Between Modules#

Synchronous contract calls suit queries; asynchronous, in-process events suit facts that several modules might care about, exactly like the asynchronous messaging covered in the messaging guide, just without a broker. A module publishes an integration event after it commits its own transaction, and any number of other modules subscribe without the publisher needing to know who is listening.

C#
public interface IModuleEventBus
{
    Task PublishAsync<TEvent>(TEvent integrationEvent, CancellationToken ct)
        where TEvent : IIntegrationEvent;
}

public sealed class InProcessEventBus(IServiceProvider services) : IModuleEventBus
{
    public async Task PublishAsync<TEvent>(TEvent integrationEvent, CancellationToken ct)
        where TEvent : IIntegrationEvent
    {
        foreach (var handler in services.GetServices<IIntegrationEventHandler<TEvent>>())
        {
            await handler.HandleAsync(integrationEvent, ct);
        }
    }
}

You do not need a mediator library to implement this, though several fit naturally. MediatR moved to a commercial license starting with its 13.x line (a free community tier still covers small organizations, and 12.5.0 remains the last Apache-2.0 release), so many teams now reach for the MIT-licensed Mediator source-generator package instead, or for a message bus they are already bringing in, such as Wolverine's local, in-process queues, which work the same way whether a handler lives in this process or across the network. Whichever you choose, keep handlers idempotent, exactly as you would for messaging across a real broker, because a process crash between committing a module's transaction and finishing its in-process publish is the same dual-write problem, just without the network in between.

Enforcing Boundaries with Architecture Tests#

Code review catches a stray reference for a while; it does not scale, and a boundary that is not enforced automatically erodes. Two libraries make this a unit test that runs on every build. NetArchTest.Rules checks dependencies between namespaces or assemblies:

C#
[Fact]
public void Orders_Should_Not_Depend_On_Catalog_Internals()
{
    var result = Types.InAssembly(typeof(OrdersApi).Assembly)
        .That().ResideInNamespace("Shop.Modules.Orders")
        .ShouldNot().HaveDependencyOn("Shop.Modules.Catalog.Domain")
        .GetResult();

    Assert.True(result.IsSuccessful, string.Join(", ", result.FailingTypes ?? []));
}

TngTech.ArchUnitNET, a .NET port of Java's ArchUnit, reads more like a rule you would say out loud and integrates directly with xUnit, NUnit or MSTest:

C#
private static readonly Architecture Architecture =
    new ArchLoader().LoadAssemblies(typeof(OrdersApi).Assembly, typeof(CatalogApi).Assembly).Build();

[Fact]
public void Modules_Should_Only_Depend_On_Other_Modules_Contracts()
{
    var orders = Types().That().ResideInAssembly("Orders.*").As("Orders module");
    var catalogInternals = Types().That().ResideInAssembly("Catalog.Domain", "Catalog.Infrastructure");

    IArchRule rule = Classes().That().Are(orders)
        .Should().NotDependOnAny(catalogInternals)
        .Because("modules may only call another module's Contracts project");

    rule.Check(Architecture);
}

Run these alongside your unit tests, not as a separate, skippable job: a boundary violation should fail the build the same way a broken unit test does.

The Shared Kernel: What's Allowed to Be Shared#

A shared kernel is the small amount of code every module is allowed to reference without it counting as a boundary violation: genuinely generic building blocks such as a Money value object, a Result<T> wrapper, base classes for entities and value objects, or common validation primitives. Keep it deliberately small and stable, because every module now depends on it, so a breaking change there ripples everywhere at once. The moment a "shared" type encodes a business rule specific to one module, such as an OrderStatus enum used only by Orders, it belongs in that module's contract instead, not in the kernel.

Testing a Modular Monolith#

Layer your tests the same way Clean Architecture does, just once per module: unit tests against each module's domain model with no database or web host involved, integration tests that exercise a module's Contracts interfaces against a real (or Testcontainers-hosted) database, and a thin set of end-to-end tests through the composition root for the handful of flows that cross modules. Architecture tests are the piece unique to this style, and they deserve the same attention as any other regression suite, since they are what keeps the modules modular six months and three new hires later.

Extracting a Module into a Service Later#

A module with a clean public contract, its own schema, and event-based integration is already most of the way to being a separate service; extraction becomes an infrastructure change rather than a redesign. The sequence that keeps the system working throughout:

  1. Confirm nothing outside the module's Contracts project is referenced anywhere else in the solution; the architecture tests should already guarantee this.
  2. Move the module's schema to its own physical database and update its connection string; nothing else changes because no other module was ever allowed to query it directly.
  3. Swap the in-process event bus subscription for a real broker subscription using the same event types, moving to the patterns in the messaging guide.
  4. Replace the in-process implementation of the module's contract with a remote one behind the same interface, so callers never notice the difference:
C#
// Same public contract as before extraction; only the implementation moved.
internal sealed class OrdersApiClient(HttpClient http) : IOrdersApi
{
    public async Task<OrderSummary?> GetOrderAsync(Guid orderId, CancellationToken ct) =>
        await http.GetFromJsonAsync<OrderSummary>($"/api/orders/{orderId}", ct);
}
  1. Deploy the extracted module as its own service, keep both implementations behind a feature flag briefly, and only delete the in-process one once traffic has run cleanly through the remote path.

Extract modules when a proven, specific need shows up, independent scaling, a separate release cadence for one team, or a different technology stack, not on a schedule. Many modular monoliths run happily in production for years without extracting a single module.

Best Practices#

  • Draw module boundaries around business capabilities, not around technical layers or database tables.
  • Make the public contract the only thing that compiles across a boundary, whether that is enforced by internal and separate assemblies or by architecture tests.
  • Give every module its own schema from day one; retrofitting data separation later is the hardest part of this style to fix.
  • Prefer events over synchronous calls between modules for anything that is a fact rather than a query, to keep modules loosely coupled even while sharing a process.
  • Run architecture tests in CI on every pull request, not as documentation someone reads once.
  • Keep the shared kernel small and treat any change to it as a breaking change across the whole solution.
  • Extract a module only when you have a concrete reason, and keep the contract stable through the extraction so callers do not need to change.

Common Pitfalls#

A "Common" or "Core" project everyone depends on. It starts as a shared kernel and quietly becomes a dumping ground for anything that does not fit elsewhere, at which point every module depends on every other module transitively.

Cross-module joins for reporting. The first dashboard that needs data from three modules is a strong temptation to add a direct query across schemas; build a read model from events instead, or you have quietly given up your data boundary.

No architecture tests, or architecture tests nobody looks at. A boundary that is only a convention decays within a few pull requests once the team that understood the original design has moved on to other work.

Treating this as a permanent architecture rather than a stage. A modular monolith is a good place to live for a long time, but for a handful of modules, extraction is a natural next step once a real operational reason appears; do not resist it purely out of inertia.

Confusing "modular" with "microservices in one process." If modules call each other synchronously for every operation and share a database, you have the coordination cost of a distributed system's coupling without any of its independent deployability.

Modular Monolith vs Microservices vs Layered Monolith#

CriterionLayered monolithModular monolithMicroservices
Boundary enforcementConvention only, easily violatedinternal and/or architecture testsProcess and network
DataOne shared schemaSchema per moduleDatabase per service
DeploymentOne unitOne unitOne per service
Cross-module callsDirect, often through shared repositoriesIn-process contract calls and eventsNetwork calls and messages
Team autonomyLow; changes ripple across layersMedium to high; modules evolve independentlyHigh; teams own services end to end
Operational costLowLowHigh
Path to changeRefactor boundaries in placeExtract a module when justifiedAlready distributed

A modular monolith is rarely a permanent stopping point between the other two; it is closer to a discipline you apply to a monolith so that the day extraction is justified, it is a mechanical exercise instead of an untangling project. See the microservices guide for the operational reality you are deferring by staying a monolith, and the CQRS and mediator guide for structuring the command and query flow inside each module.

Frequently Asked Questions#

Is a modular monolith just microservices without the network?#

Not quite. It keeps the design discipline of microservices, clear ownership, isolated data, explicit contracts, but deliberately keeps a single deployment unit and a single process, so most calls between modules are still in-process method calls or in-memory events rather than network requests. You get many of the coupling benefits without paying for distributed transactions, service discovery or per-service deployment pipelines until you actually need them.

How many modules should a .NET solution have?#

There is no fixed number; let modules follow the business capabilities you can name, such as Orders, Catalog, Payments and Shipping, and split further only when a module's internal cohesion breaks down, not to hit a target count. A handful of well-bounded modules beats a dozen thin ones that constantly need to call each other.

Can two modules share a database table?#

No, not if you want the architecture to hold. Shared tables are the most common way modular monoliths quietly turn back into a tangled layered monolith, because they recreate the same hidden coupling a shared repository would. If two modules need the same data, one of them owns it and exposes it through its contract, and the other consumes it through that contract or through an event-built read model.

Do I need NetArchTest or ArchUnitNET, or is code review enough?#

Code review alone tends to work for a few months and then quietly stops catching violations as the team grows or turns over. Architecture tests cost little to write, run in seconds as part of your normal test suite, and turn a boundary violation into a failing build instead of a design conversation nobody has time for.

When should I extract a module into its own service?#

Extract when you have a concrete, current reason: the module needs to scale independently of the rest of the system, a separate team needs its own release cadence, or it needs a different runtime or language. A clean public contract, its own schema and event-based integration make the extraction mechanical; without those three in place first, extracting early usually just moves the same coupling problems onto the network.

Summary#

  • A modular monolith organizes one deployable application into modules that own their data and expose a small public contract, enforced by internal types, separate assemblies, or architecture tests.
  • Give every module its own schema, and let cross-module reads happen through the contract or through events, never through a direct query.
  • Use in-process events for facts other modules react to, and keep handlers idempotent the same way you would for real broker-based messaging.
  • Enforce boundaries automatically with NetArchTest.Rules or TngTech.ArchUnitNET, run in CI, not by relying on review alone.
  • Keep the shared kernel small, and treat module extraction as a mechanical step you take when a concrete need appears, not a milestone you chase for its own sake.

Further Reading#