Skip to main content

Command Palette

Search for a command to run...

How Features Should Talk to Each Other Inside the Same Module in a Modular Monolith

Published
11 min read
How Features Should Talk to Each Other Inside the Same Module in a Modular Monolith

A lot of confusion around modular monoliths comes from mixing up two different questions. One question is how one module should talk to another module. The other is how one feature should talk to another feature inside the same module. Those are not the same problem, and the design advice is not the same either.

Inside the same module, you are still operating within one business boundary. That means the goal is not to protect a hard architectural seam between bounded contexts. The goal is to stop your feature slices from turning into a tangled dependency graph where handlers call handlers, validation logic is duplicated, and workflows become impossible to reason about. Good .NET architecture guidance still points in the same direction here, keep parts of the application loosely coupled and let them communicate through explicit interfaces or messaging when that fits the problem.

This post is about that exact scenario. You have a modular monolith. Inside one module, such as Users, Orders, or Billing, one feature needs something from another feature. What should it do?

The short answer is this. A feature should usually not call another feature handler directly, even inside the same module. A handler is a use-case entry point, not a reusable building block. If two features need the same business logic, move that logic into the domain model or a domain service. If they need the same read logic, move it into a query or read service. If one feature needs to react to something that happened in another feature inside the same module, raise a domain event and handle it internally. That keeps the module cohesive without making the features depend on each other in brittle ways. The examples below use modern ASP.NET Core and Minimal APIs, which Microsoft currently recommends for new HTTP API projects, and the .NET 10 line continues to add Minimal API improvements such as built-in validation support.

The wrong shape

Suppose you have a Users module with these features:

CreateUser
AssignUserRole
GetUserPermissions
DeactivateUser

A common first attempt is to let one handler call another. AssignUserRoleHandler needs the user’s current effective permissions, so it injects GetUserPermissionsHandler. Later, DeactivateUserHandler injects AssignUserRoleHandler because it wants to remove privileged roles before deactivation. The folder structure still looks clean, but the runtime coupling is already slipping.

This is the kind of flow that causes trouble:

The problem is not that the code cannot work. It often does work, at first. The problem is that handlers represent application use cases. They carry use-case specific orchestration, validation, authorisation assumptions, and response shapes. Once handlers start calling other handlers, those assumptions leak everywhere. You are no longer reusing domain capability. You are reusing one use case as an implementation detail of another use case.

That is exactly why this pattern gets messy. A feature handler should answer the question, "How do I execute this use case?" It should not answer the question, "What shared business capability should other features depend on?"

What should happen instead

Inside the same module, feature-to-feature interaction usually belongs in one of three places. If the shared logic is core business behaviour, put it in the domain model or a domain service. If the shared logic is a reusable read, put it in a read service or query service. If one feature should react after another feature completes, use an internal domain event.

That gives you this instead:

This is the same module. There is no hard boundary crossing. But the design is still disciplined.

Example module structure

Here is a clean way to structure the Users module in a modern .NET application.

src/
  App.Api/
    Program.cs

  Modules.Users/
    Data/
      UsersDbContext.cs
    Domain/
      User.cs
      UserRole.cs
      IUserAccessPolicyService.cs
      UserAccessPolicyService.cs
      UserDeactivatedDomainEvent.cs
    Features/
      CreateUser/
        CreateUserCommand.cs
        CreateUserEndpoint.cs
        CreateUserHandler.cs
      AssignUserRole/
        AssignUserRoleCommand.cs
        AssignUserRoleEndpoint.cs
        AssignUserRoleHandler.cs
      GetUserPermissions/
        GetUserPermissionsQuery.cs
        GetUserPermissionsEndpoint.cs
        GetUserPermissionsHandler.cs
        UserAccessReader.cs
      DeactivateUser/
        DeactivateUserCommand.cs
        DeactivateUserEndpoint.cs
        DeactivateUserHandler.cs
    Contracts/
      UserPermissionsDto.cs

Notice what is missing. There is no feature-to-feature dependency. The shared logic has been promoted to the right place.

Case 1: Shared business behaviour belongs in the domain

Assume the rule for assigning roles is not trivial. Maybe only active users can receive roles. Maybe a suspended user cannot be given elevated permissions. Maybe some roles conflict with others. That is business behaviour. It should not live inside AssignUserRoleHandler, and it definitely should not be "borrowed" by calling some other feature.

Put it in a domain service.

// File: src/Modules.Users/Domain/IUserAccessPolicyService.cs
namespace Modules.Users.Domain;

public interface IUserAccessPolicyService
{
    void EnsureRoleCanBeAssigned(User user, string roleName);
}
// File: src/Modules.Users/Domain/UserAccessPolicyService.cs
namespace Modules.Users.Domain;

internal sealed class UserAccessPolicyService : IUserAccessPolicyService
{
    public void EnsureRoleCanBeAssigned(User user, string roleName)
    {
        if (!user.IsActive)
            throw new InvalidOperationException("Cannot assign a role to an inactive user.");

        if (user.IsSuspended && roleName is "Admin" or "Approver")
            throw new InvalidOperationException(
                $"Cannot assign privileged role '{roleName}' to a suspended user.");

        if (user.Roles.Any(x => x.Name == roleName))
            throw new InvalidOperationException(
                $"User already has role '{roleName}'.");
    }
}

Now the feature handler stays thin.

// File: src/Modules.Users/Features/AssignUserRole/AssignUserRoleHandler.cs
using Microsoft.EntityFrameworkCore;
using Modules.Users.Data;
using Modules.Users.Domain;

namespace Modules.Users.Features.AssignUserRole;

internal sealed class AssignUserRoleHandler(
    UsersDbContext db,
    IUserAccessPolicyService accessPolicyService)
{
    public async Task<IResult> HandleAsync(
        AssignUserRoleCommand command,
        CancellationToken stopToken)
    {
        var user = await db.Users
            .Include(x => x.Roles)
            .SingleOrDefaultAsync(x => x.Id == command.UserId, stopToken);

        if (user is null)
            return Results.NotFound($"User '{command.UserId}' was not found.");

        try
        {
            accessPolicyService.EnsureRoleCanBeAssigned(user, command.RoleName);
        }
        catch (InvalidOperationException ex)
        {
            return Results.BadRequest(new { error = ex.Message });
        }

        user.AssignRole(command.RoleName);

        await db.SaveChangesAsync(stopToken);

        return Results.Ok(new { user.Id, command.RoleName });
    }
}

That is the correct dependency direction. The handler depends on reusable business behaviour. It does not depend on another feature handler.

Case 2: Shared reads belong in a query service or read service

Now take a different problem. AssignUserRole needs to check the user’s effective permissions for a validation rule. GetUserPermissions also needs that same calculation for the API response. This is not really domain mutation logic. It is a reusable read model.

That belongs in a read service.

// File: src/Modules.Users/Contracts/UserPermissionsDto.cs
namespace Modules.Users.Contracts;

public sealed record UserPermissionsDto(
    Guid UserId,
    IReadOnlyCollection<string> Permissions);
// File: src/Modules.Users/Features/GetUserPermissions/UserAccessReader.cs
using Microsoft.EntityFrameworkCore;
using Modules.Users.Contracts;
using Modules.Users.Data;

namespace Modules.Users.Features.GetUserPermissions;

internal sealed class UserAccessReader(UsersDbContext db)
{
    public async Task<UserPermissionsDto?> GetPermissionsAsync(
        Guid userId,
        CancellationToken stopToken)
    {
        var user = await db.Users
            .AsNoTracking()
            .Include(x => x.Roles)
            .ThenInclude(x => x.Permissions)
            .SingleOrDefaultAsync(x => x.Id == userId, stopToken);

        if (user is null)
            return null;

        var permissions = user.Roles
            .SelectMany(x => x.Permissions)
            .Select(x => x.Name)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .OrderBy(x => x)
            .ToArray();

        return new UserPermissionsDto(user.Id, permissions);
    }
}

Then the query feature uses that reader.

// File: src/Modules.Users/Features/GetUserPermissions/GetUserPermissionsHandler.cs
using Modules.Users.Contracts;

namespace Modules.Users.Features.GetUserPermissions;

internal sealed class GetUserPermissionsHandler(UserAccessReader reader)
{
    public async Task<IResult> HandleAsync(
        Guid userId,
        CancellationToken stopToken)
    {
        var result = await reader.GetPermissionsAsync(userId, stopToken);

        return result is null
            ? Results.NotFound($"User '{userId}' was not found.")
            : Results.Ok(result);
    }
}

And if another feature in the same module needs the same read, it uses the same UserAccessReader, not the GetUserPermissionsHandler.

That distinction is important. The read service expresses reusable capability. The handler expresses one HTTP-facing use case.

Case 3: Follow-on reactions belong in domain events

Now take DeactivateUser. Suppose deactivating a user should write an audit entry, revoke sessions, and notify an internal workflow. That does not mean DeactivateUserHandler should call three more handlers directly. It means a business event happened inside the module, and several parts of the module may react to it.

Microsoft’s architecture guidance describes domain events as a way to model side effects explicitly, including across multiple aggregates in the same domain. That fits this use case very well.

Start with the event.

// File: src/Modules.Users/Domain/UserDeactivatedDomainEvent.cs
namespace Modules.Users.Domain;

public sealed record UserDeactivatedDomainEvent(
    Guid UserId,
    DateTimeOffset OccurredAtUtc);

Raise it from the aggregate.

// File: src/Modules.Users/Domain/User.cs
namespace Modules.Users.Domain;

public sealed class User
{
    private readonly List<object> _domainEvents = [];

    public Guid Id { get; private set; }
    public bool IsActive { get; private set; } = true;
    public bool IsSuspended { get; private set; }
    public List<UserRole> Roles { get; } = [];

    public IReadOnlyCollection<object> DomainEvents => _domainEvents;

    public void AssignRole(string roleName)
    {
        Roles.Add(new UserRole(roleName));
    }

    public void Deactivate()
    {
        if (!IsActive)
            return;

        IsActive = false;
        _domainEvents.Add(new UserDeactivatedDomainEvent(
            Id,
            DateTimeOffset.UtcNow));
    }

    public void ClearDomainEvents() => _domainEvents.Clear();
}

Handle deactivation in the feature.

// File: src/Modules.Users/Features/DeactivateUser/DeactivateUserHandler.cs
using Microsoft.EntityFrameworkCore;
using Modules.Users.Data;

namespace Modules.Users.Features.DeactivateUser;

internal sealed class DeactivateUserHandler(
    UsersDbContext db,
    UserDomainEventDispatcher dispatcher)
{
    public async Task<IResult> HandleAsync(
        DeactivateUserCommand command,
        CancellationToken stopToken)
    {
        var user = await db.Users
            .Include(x => x.Roles)
            .SingleOrDefaultAsync(x => x.Id == command.UserId, stopToken);

        if (user is null)
            return Results.NotFound($"User '{command.UserId}' was not found.");

        user.Deactivate();

        await db.SaveChangesAsync(stopToken);

        await dispatcher.DispatchAsync(user.DomainEvents, stopToken);
        user.ClearDomainEvents();

        return Results.Ok(new { user.Id, user.IsActive });
    }
}

Then react elsewhere inside the module.

// File: src/Modules.Users/Features/DeactivateUser/AuditUserDeactivationHandler.cs
using Modules.Users.Domain;

namespace Modules.Users.Features.DeactivateUser;

internal sealed class AuditUserDeactivationHandler
{
    public Task HandleAsync(
        UserDeactivatedDomainEvent domainEvent,
        CancellationToken stopToken)
    {
        // Write audit record
        return Task.CompletedTask;
    }
}
// File: src/Modules.Users/Features/DeactivateUser/RevokeSessionsOnUserDeactivatedHandler.cs
using Modules.Users.Domain;

namespace Modules.Users.Features.DeactivateUser;

internal sealed class RevokeSessionsOnUserDeactivatedHandler
{
    public Task HandleAsync(
        UserDeactivatedDomainEvent domainEvent,
        CancellationToken stopToken)
    {
        // Revoke sessions or tokens
        return Task.CompletedTask;
    }
}

The point is not the plumbing library. The point is the modelling choice. A domain event says, "this happened". It does not say, "please call these three use cases in sequence".

What a modern endpoint can look like

Since this is a modern .NET version of the pattern, here is how one of these features can be exposed with Minimal APIs.

// File: src/App.Api/Program.cs
using Microsoft.EntityFrameworkCore;
using Modules.Users.Data;
using Modules.Users.Domain;
using Modules.Users.Features.AssignUserRole;
using Modules.Users.Features.GetUserPermissions;
using Modules.Users.Features.DeactivateUser;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<UsersDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("UsersDb")));

builder.Services.AddScoped<IUserAccessPolicyService, UserAccessPolicyService>();
builder.Services.AddScoped<UserAccessReader>();
builder.Services.AddScoped<AssignUserRoleHandler>();
builder.Services.AddScoped<GetUserPermissionsHandler>();
builder.Services.AddScoped<DeactivateUserHandler>();
builder.Services.AddScoped<UserDomainEventDispatcher>();

builder.Services.AddProblemDetails();
builder.Services.AddValidation();

var app = builder.Build();

app.MapPost("/users/{userId:guid}/roles", async (
    Guid userId,
    AssignUserRoleRequest request,
    AssignUserRoleHandler handler,
    CancellationToken stopToken) =>
{
    var command = new AssignUserRoleCommand(userId, request.RoleName);
    return await handler.HandleAsync(command, stopToken);
});

app.MapGet("/users/{userId:guid}/permissions", async (
    Guid userId,
    GetUserPermissionsHandler handler,
    CancellationToken stopToken) =>
{
    return await handler.HandleAsync(userId, stopToken);
});

app.MapPost("/users/{userId:guid}/deactivate", async (
    Guid userId,
    DeactivateUserHandler handler,
    CancellationToken stopToken) =>
{
    return await handler.HandleAsync(new DeactivateUserCommand(userId), stopToken);
});

app.Run();

public sealed record AssignUserRoleRequest(string RoleName);

Minimal APIs are a solid fit here because the endpoint stays thin and the module keeps the real behaviour inside the feature and domain layers. Microsoft’s current ASP.NET Core guidance positions Minimal APIs as the recommended approach for building fast HTTP APIs, and .NET 10 continues improving that stack.

The decision rule

When a feature inside the same module needs something from another feature, stop and ask what it really needs. If it needs shared business rules, extract a domain service or move the behaviour into the aggregate. If it needs a shared read, extract a query or read service. If it needs to react after something happens, raise a domain event.

If your answer is "I’ll just inject the other handler", you are probably choosing the easiest short-term path and the worse long-term design.

Why this is important as the module grows

This discipline pays off early, but it becomes critical once a module has six or eight serious use cases. Without it, you end up with a hidden graph of feature dependencies that nobody can see from the folder structure. You change a validation rule in one handler and break two other features because they reused that handler internally. You add authorisation to one use case and accidentally affect another. You reuse an endpoint-level response shape where a domain decision should have existed. That is how a modular monolith starts looking clean on disk while behaving like a ball of mud in practice.

With explicit shared services and internal domain events, the dependencies stay understandable. The features stay thin. The domain stays central. The module remains cohesive.

So yes, the advice changes when the question is about features inside the same module.

Across modules, the main concern is protecting a boundary. Inside the same module, the main concern is keeping one business boundary clean and maintainable.

That leads to a simple rule. Inside the same module, features should not normally call each other’s handlers directly. They should share domain behaviour through the domain model or domain services, share reads through read services, and coordinate follow-on reactions through domain events. That is the pattern that keeps a modular monolith modular, even when everything still runs in one process.

FREE ARCHITECTURE EBOOK