Skip to content

Modular Monolith Overview

A modular monolith is a single deployable application that is internally decomposed into highly isolated feature modules. Each module owns its domain, data, and business logic, communicating with other modules only through well-defined integration events. Modulus scaffolds this architecture from day one so that you get the simplicity of a monolith with the structural discipline of microservices.

Why a Modular Monolith?

Microservices solve real problems -- independent deployability, polyglot persistence, per-service scaling -- but they introduce equally real complexity: distributed transactions, network latency, eventual consistency, and a heavy operational burden. For most teams, that complexity arrives long before the benefits become relevant.

A modular monolith gives you the structural benefits without the distributed systems tax:

ConcernModular MonolithMicroservices
DeploymentSingle artifactMany artifacts, orchestration required
Module isolationEnforced by architecture tests and schema separationEnforced by network boundaries
Cross-module communicationIn-memory integration events (same process)Network calls, serialization, retries
TransactionsStandard database transactions within a moduleDistributed transactions or sagas
RefactoringRename and move within one solutionCoordinated changes across repositories
Operational overheadOne process to monitorService mesh, observability per service

Start monolith, extract later

The Modulus architecture is designed so that any module can be extracted into its own microservice when the need arises. You get isolation guarantees today and a clean extraction path tomorrow. See Extracting to Microservices for the step-by-step process.

When to Choose a Modular Monolith

Choose a modular monolith when:

  • Your team is small to medium and owns the entire product.
  • You value fast iteration and simple deployments.
  • You want clear module boundaries without distributed systems complexity.
  • You are not yet sure where the service boundaries should be drawn.

Consider microservices when:

  • Independent teams need to deploy on different cadences.
  • Individual modules have dramatically different scaling requirements.
  • You need polyglot persistence or technology diversity.
  • You have the operational maturity to manage distributed infrastructure.

Solution Structure

A Modulus-generated solution follows a consistent layout. The host web API composes all modules, each module is self-contained with five layers, and shared building blocks provide base types used across modules.

Key Structural Decisions

  • Single host -- EShop.WebApi is the only runnable project. It discovers and registers all modules at startup through the IModuleRegistration interface.
  • Five layers per module -- Domain, Application, Infrastructure, Api, and Integration. Each layer has strict dependency rules enforced by architecture tests.
  • Building blocks -- Shared base classes (Entity<TId>, AggregateRoot<TId>, ValueObject, BaseDbContext, etc.) live in a BuildingBlocks folder and are referenced by all modules.
  • Modulus libraries -- Modulus.Mediator provides in-process CQRS dispatch. Modulus.Messaging provides integration event publishing and consumption via MassTransit.

How Modules Stay Isolated

Module isolation is not a convention -- it is enforced at multiple levels:

1. Schema Separation

Each module owns its own database schema. The module's DbContext is configured to use a dedicated schema name (e.g., catalog, orders), preventing any module from directly querying another module's tables.

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

2. Integration-Only Cross-References

Modules never reference each other's Domain, Application, Infrastructure, or Api projects. The only project a module may reference from another module is its Integration project, which contains nothing but integration event record types.

// Allowed
Orders.Infrastructure --> Catalog.Integration (to consume CatalogItemCreated event)

// Forbidden
Orders.Application --> Catalog.Domain
Orders.Infrastructure --> Catalog.Infrastructure

3. Architecture Tests

Every module includes a dedicated architecture test project that uses NetArchTest to verify layer dependency rules at build time. If a developer accidentally adds a reference from Domain to Infrastructure, the test fails.

csharp
[Fact]
public void Domain_Should_Not_Reference_Infrastructure()
{
    var result = Types.InAssembly(DomainAssembly)
        .ShouldNot()
        .HaveDependencyOn("EShop.Modules.Catalog.Infrastructure")
        .GetResult();

    result.IsSuccessful.Should().BeTrue();
}

See Architecture Tests for the full set of enforced rules.

4. Communication via Integration Events Only

Modules communicate asynchronously through integration events. A domain event within one module triggers an integration event that is published through the outbox and consumed by other modules. There are no synchronous cross-module calls.

In-memory transport in monolith mode

When running as a monolith, the message bus uses an in-memory transport. Events are still published through the outbox for consistency, but delivery happens within the same process. When you extract a module, you switch the transport to RabbitMQ or Azure Service Bus -- no business logic changes.

What's Next

Explore the architecture in detail:

Released under the MIT License.