Skip to main content

Command Palette

Search for a command to run...

Communicating Between Modules in a Modular Monolith

Published
10 min read
Communicating Between Modules in a Modular Monolith
P
Senior Software Engineer specialising in cloud architecture, distributed systems, and modern .NET development, with over two decades of experience designing and delivering enterprise platforms in financial, insurance, and high-scale commercial environments. My focus is on building systems that are reliable, scalable, and maintainable over the long term. I’ve led modernisation initiatives moving legacy platforms to cloud-native Azure architectures, designed high-throughput streaming solutions to eliminate performance bottlenecks, and implemented secure microservices environments using container-based deployment models and event-driven integration patterns. From an architecture perspective, I have strong practical experience applying approaches such as Vertical Slice Architecture, Domain-Driven Design, Clean Architecture, and Hexagonal Architecture. I’m particularly interested in modular system design that balances delivery speed with long-term sustainability, and I enjoy solving complex problems involving distributed workflows, performance optimisation, and system reliability. I enjoy mentoring engineers, contributing to architectural decisions, and helping teams simplify complex systems into clear, maintainable designs. I’m always open to connecting with other engineers, architects, and technology leaders working on modern cloud and distributed system challenges.

Why Developers Get This Wrong

Most engineers learning modular monoliths fall into two traps. The first group collapses boundaries by sharing DbContexts, repositories, and entities across modules. The second group overcompensates by enforcing microservice-style communication within the monolith, introducing HTTP calls or message buses between modules. Both patterns undermine the purpose of a modular monolith. The correct approach maintains module isolation while enabling fast in-process communication through contracts and events. Vertical Slice architecture alters how we structure these modules.

The Architecture We Are Targeting

We are building a modular monolith with:

  • Vertical Slice architecture

  • CQRS

  • Minimal APIs

  • Separate module databases

  • No HTTP between modules

  • No message bus inside the process

Example modules:

  • Users

  • Claims

Each module owns its data and exposes capabilities, not services.

Instead of layers like Application, Domain, Infrastructure, the module is organised by features.

src
 ├ Users
 │   ├ Contracts
 │   │   └ UserQueries.cs
 │   │
 │   ├ CreateUser
 │   │   ├ Endpoint.cs
 │   │   ├ Command.cs
 │   │   ├ Handler.cs
 │   │   └ Validator.cs
 │   │
 │   ├ GetUser
 │   │   ├ Query.cs
 │   │   └ Handler.cs
 │   │
 │   └ DeleteUser
 │       ├ Command.cs
 │       └ Handler.cs
 │
 └ Claims
     ├ Contracts
     │   └ ClaimQueries.cs
     │
     ├ CreateClaim
     │   ├ Endpoint.cs
     │   ├ Command.cs
     │   └ Handler.cs
     │
     └ ApproveClaim
         ├ Command.cs
         └ Handler.cs

Each folder is a slice.

The slice contains everything needed for that use case.

Dependency Direction Between Modules

Modules reference contracts only, never implementation slices.

This rule is the cornerstone that keeps a modular monolith genuinely modular instead of slowly degrading into a tangled codebase. The Claims module is allowed to reference Users.Contracts because contracts represent the public capability surface of the Users module. They define what the Users module is willing to expose to the rest of the system in a controlled, stable way. Contracts typically contain simple request objects, response DTOs, and interfaces that describe operations such as queries or commands. Importantly, they contain no business logic, persistence concerns, or internal implementation details. By depending only on this contract layer, the Claims module interacts with the Users module in the same way an external client would, through clearly defined capabilities rather than through direct knowledge of how the module works internally. What the Claims module must never do is reference internal slices like Users.CreateUser, Users.GetUser, or Users.DeleteUser, because those folders represent implementation details of individual vertical slices inside the Users module. Allowing other modules to depend on those slices would immediately couple them to internal design decisions, meaning a simple refactor of a handler or slice could ripple across the entire system. The same rule applies even more strongly to infrastructure concerns such as UsersDbContext. Once another module starts using a different module’s DbContext, it is effectively reaching directly into that module’s database and bypassing its business rules, validations, and invariants. That instantly destroys the boundary between modules and turns the architecture into a shared persistence layer disguised as modules. By restricting dependencies strictly to Users.Contracts, the Users module remains free to reorganise its slices, change its database schema, refactor its handlers, or even split into a separate service later without breaking other modules. The Claims module only knows what operations are available, not how they are implemented, which is exactly the level of coupling a modular monolith is designed to enforce.The Three Communication Mechanisms

Vertical slice modular monoliths usually communicate through:

  1. Query contracts

  2. Command contracts

  3. Domain events

These are not HTTP calls and not service bus messages. They are simple in-process calls.

Pattern 1 - Query Contracts

Suppose the CreateClaim slice needs to verify that the user associated with the claim actually exists and is allowed to submit a claim. At first glance it may seem natural for the Claims module to simply query the Users table directly, especially since everything runs inside the same application and the Users database is technically accessible. However, doing so would immediately violate the boundary between modules because the Claims module would now be coupled to the Users module’s persistence model and database schema. Any change to the Users table structure, indexes, or entity model could silently break the Claims module, and worse, the Claims module would be bypassing any business rules or invariants that the Users module is responsible for enforcing. In a modular monolith, each module owns its data and must be the only component allowed to access that data directly. Instead of reading the Users database, the Claims module should request the information it needs through a query contract exposed by the Users module. This contract defines a simple, explicit capability such as "retrieve a summary of a user by ID." The Claims module then calls that query through an interface defined in Users.Contracts, allowing the Users module to remain the sole authority over how user data is stored, retrieved, and validated. The Claims module gets exactly the information it needs to perform its operation, while the internal implementation of the Users module remains completely hidden behind the contract boundary.

Users.Contracts

public record GetUserSummaryQuery(Guid UserId);

public record UserSummaryDto(
    Guid Id,
    string Email,
    bool IsActive);

public interface IUserQueries
{
    Task<UserSummaryDto?> GetUserSummary(
        GetUserSummaryQuery query,
        CancellationToken stopToken);
}

The contract lives inside Users.Contracts.

No EF. No implementation.

Implementing the Query Slice

Inside the Users module.

Users/GetUserSummary
internal sealed class Handler : IUserQueries
{
    private readonly UsersDbContext db;

    public Handler(UsersDbContext db)
    {
        this.db = db;
    }

    public async Task<UserSummaryDto?> GetUserSummary(
        GetUserSummaryQuery query,
        CancellationToken stopToken)
    {
        return await db.Users
            .Where(x => x.Id == query.UserId)
            .Select(x => new UserSummaryDto(
                x.Id,
                x.Email,
                x.IsActive))
            .FirstOrDefaultAsync(stopToken);
    }
}

Register the slice handler.

services.AddScoped<IUserQueries, Handler>();

Using the Query in a Claims Slice

Inside the CreateClaim slice.

public sealed class Handler
{
    private readonly IUserQueries users;
    private readonly ClaimsDbContext db;

    public Handler(
        IUserQueries users,
        ClaimsDbContext db)
    {
        this.users = users;
        this.db = db;
    }

    public async Task<Guid> Handle(
        Command cmd,
        CancellationToken stopToken)
    {
        var user = await users.GetUserSummary(
            new GetUserSummaryQuery(cmd.UserId),
            stopToken);

        if (user is null)
            throw new Exception("User not found");

        var claim = Claim.Create(cmd.UserId);

        db.Claims.Add(claim);
        await db.SaveChangesAsync(stopToken);

        return claim.Id;
    }
}

The call remains fully in-process.


Pattern 2 - Command Contracts

Sometimes a module does not just need to read information from another module, it needs that module to actually perform some work on its behalf. A good example is when a claim is approved in the Claims module and the user should be notified that their claim has been accepted. It might be tempting for the Claims module to send an email directly or call some notification service itself, but that would be a mistake because notification behaviour belongs to the Users module’s responsibility. The Claims module should not need to know whether notifications are sent via email, SMS, push notification, or some future system that has not even been introduced yet. If Claims implements that logic, it becomes tightly coupled to infrastructure details that are outside its domain. Instead, the Claims module should simply express the intent of the action by issuing a command to the Users module through a contract. That command represents a capability such as "notify this user with this message." The Users module then decides how that notification is handled internally. By structuring the interaction this way, the Claims module remains focused purely on claims-related business logic while the Users module retains full ownership of notification behaviour and the infrastructure required to deliver it. This keeps responsibilities clearly separated and prevents implementation details from leaking across module boundaries.

Users.Contracts

public record NotifyUserCommand(
    Guid UserId,
    string Message);

public interface IUserCommands
{
    Task NotifyUser(
        NotifyUserCommand command,
        CancellationToken stopToken);
}

Users Slice Implementation

Users/NotifyUser
internal sealed class Handler : IUserCommands
{
    public Task NotifyUser(
        NotifyUserCommand command,
        CancellationToken stopToken)
    {
        // send email, SMS etc
        return Task.CompletedTask;
    }
}

Claims Slice Triggering the Command

Claims/ApproveClaim
public sealed class Handler
{
    private readonly IUserCommands users;

    public Handler(IUserCommands users)
    {
        this.users = users;
    }

    public async Task Handle(
        Command cmd,
        CancellationToken stopToken)
    {
        await users.NotifyUser(
            new NotifyUserCommand(
                cmd.UserId,
                "Claim approved"),
            stopToken);
    }
}

Again, no HTTP, no message broker.


Pattern 3 - Domain Events

Commands are appropriate when one module explicitly knows that another module must perform a specific action. In those cases the calling module intentionally invokes a capability exposed by the other module through a contract. Events serve a different purpose. Events are used when a module should not know which other modules might care about something that has happened. Instead of directing another module to do something, the module simply announces that a significant domain event occurred. A good example is user deletion. When the Users module deletes a user, it should not contain logic that checks whether the Claims module exists or whether it needs to clean up claims data. That would tightly couple the Users module to the rest of the system and force it to understand responsibilities that belong to other domains. Instead, the Users module publishes a UserDeleted event indicating that the user has been removed. Other modules that care about that event can react independently. The Claims module might close open claims for that user, an auditing module might archive historical data, and a reporting module might update statistics. None of those reactions are the Users module’s responsibility. By publishing an event rather than issuing direct commands, the Users module remains completely unaware of which modules subscribe to that event, preserving loose coupling and allowing new behavior to be added later without modifying the Users module itself.

public record UserDeletedEvent(Guid UserId);

Event Flow

Users publishes:

await dispatcher.Publish(
    new UserDeletedEvent(userId),
    stopToken);

Claims reacts:

public sealed class Handler
{
    private readonly ClaimsDbContext db;

    public async Task Handle(
        UserDeletedEvent evt,
        CancellationToken stopToken)
    {
        var claims = await db.Claims
            .Where(x => x.UserId == evt.UserId)
            .ToListAsync(stopToken);

        foreach (var claim in claims)
        {
            claim.MarkUserDeleted();
        }

        await db.SaveChangesAsync(stopToken);
    }
}

Users never references Claims.

In-Process Event Dispatcher

Because everything runs in one process, the dispatcher is trivial.

public class EventDispatcher
{
    private readonly IServiceProvider services;

    public EventDispatcher(IServiceProvider services)
    {
        this.services = services;
    }

    public async Task Publish<T>(
        T domainEvent,
        CancellationToken stopToken)
    {
        var handlers =
            services.GetServices<IEventHandler<T>>();

        foreach (var handler in handlers)
        {
            await handler.Handle(domainEvent, stopToken);
        }
    }
}

Minimal API Integration

Endpoints live inside the slice.

Example:

Claims/CreateClaim/Endpoint.cs
app.MapPost("/claims",
    async (
        Command cmd,
        Handler handler,
        CancellationToken stopToken) =>
{
    var id = await handler.Handle(cmd, stopToken);
    return Results.Ok(id);
});

The endpoint talks only to its slice handler.

Why This Works

This architecture preserves:

  • strict module boundaries

  • independent databases

  • vertical slice isolation

  • extremely fast in-process calls

The system remains loosely coupled because modules depend only on contracts.

Yet communication remains extremely simple.

The Performance Advantage

In-process contract calls are dramatically faster than external communication.

Communication Typical latency
HTTP 3–15 ms
Message bus 10–100 ms
In-process contract <0.1 ms

For high-throughput systems, that difference matters.

A modular monolith using Vertical Slice + CQRS + Minimal APIs should not resemble either a layered monolith or a microservice system.

Slices contain the behaviour. Modules own the data. Contracts define the boundaries.

Queries read across modules. Commands trigger behaviour. Domain events propagate changes.

The result is an architecture that is simple, fast, and strongly modular without introducing the complexity of distributed systems.