A modular monolith thrives when its boundaries reflect the real structure of the business. When teams start with clean intent and clearly defined responsibilities, the entire system becomes easier to modify, test, and evolve without falling into the trap of early microservices. Most systems become difficult not because they are monoliths, but because the boundaries inside the monolith are porous, inconsistent, or forced into technical layers that do not represent the domain. Once those internal seams become ambiguous, every change leaks across the codebase, and the monolith begins to decay. The solution is not to split everything into services but to impose discipline around internal architecture.
The first step is understanding that a module is a domain boundary, not a folder structure. It holds its own behaviour, its rules, its data access, and its public contract. A module is not defined by a project file; it is defined by decision-making authority. When a feature is coherent, when its invariants belong together, and when changes to it typically originate from the same part of the business, it is a natural module. A good heuristic is imagining the module being removed from the codebase entirely. If too many unrelated parts break, the boundary is wrong.
Inside a modular monolith, each module exposes a narrow interface to the rest of the system. That interface usually appears as a set of commands it can execute, queries it can answer, and events it publishes when something meaningful has occurred. The module should never expose internal entities, EF Core DbSets, or mutable internal objects. Those details remain inside the boundary. Other modules interact with it through an internal API, mediated by well-defined request and response structures. When done consistently, the application feels like a set of small, well-behaved services running inside a single process.
A useful way to visualise this structure is to map the modules as islands connected only by specific interaction paths. The following diagram illustrates a system composed of Programs, Submissions, Tasks, and Workflow modules, each with its own internal logic.
The relationships are directional because each module acts as a client of another through defined contracts. None of them reaches inside another module’s domain or data. The seams are enforced through language boundaries, public types, and, ideally, internal scoping.
Representing a module through a vertical slice helps maintain that clarity. A slice is an end-to-end flow of a single behaviour: an API endpoint, a handler, validation rules, domain logic, and persistence executed as a single unit. It removes the temptation to scatter responsibilities across layers that have no domain meaning.
An example such as creating a program shows this structure in code. The endpoint calls a handler, which coordinates domain logic and persistence. The domain rules live in the module and stay separate from external dependencies.
public static class CreateProgramEndpoint
{
public static IEndpointRouteBuilder MapCreateProgram(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/programs", async (CreateProgramRequest request, ISender sender) =>
{
var result = await sender.Send(new CreateProgramCommand(request.Name, request.Type));
return Results.Created($"/programs/{result.Id}", result);
});
return endpoints;
}
}
public sealed record CreateProgramCommand(string Name, string Type) : IRequest<ProgramResponse>;
public sealed class CreateProgramHandler(IProgramRepository repository)
: IRequestHandler<CreateProgramCommand, ProgramResponse>
{
public async Task<ProgramResponse> Handle(CreateProgramCommand request, CancellationToken ct)
{
var program = Program.Create(request.Name, request.Type);
await repository.AddAsync(program, ct);
return new ProgramResponse(program.Id, program.Name, program.Type);
}
}
public sealed class Program
{
public Guid Id { get; private set; }
public string Name { get; private set; }
public string Type { get; private set; }
private Program(string name, string type)
{
Id = Guid.NewGuid();
Name = name;
Type = type;
}
public static Program Create(string name, string type)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name is required.");
return new Program(name, type);
}
}
The handler does not know how the repository stores the data. The repository does not expose entities outside the module. The program entity enforces its invariants internally. The module owns everything it needs to perform this behaviour, and nothing leaks out.
Once the modules are shaped like this, integration becomes predictable. Commands represent intent. Queries provide read models without revealing internals. Events reflect domain milestones that other modules can react to. This structure also makes module extraction trivial if the monolith eventually evolves into a distributed system. Because the module already behaves like a small service with its own data, API surface, and event outputs, the migration becomes mostly mechanical.
Database structure is another area where boundaries must be respected. A single database is not a problem; the problem appears when tables owned by different modules become entangled through implicit relationships. Each module should own its tables and never assume that foreign tables enforce cross-module rules. Foreign keys across boundaries often force coupling and prevent independent decision-making. A more stable model uses explicit identifiers and contracts, letting each module validate its own invariants in its own data. The boundary stays intact even within a shared schema.
Testing strategy follows the same pattern. A module should be testable without booting the entire system. Slice tests verify that a request produces the correct outcome within that module’s domain. Integration tests verify that modules respect each other’s contracts. No global fixtures that force a single shared state across everything; each module maintains its own test surface.
Finally, the point at which a module qualifies for extraction into a microservice becomes obvious rather than forced. A cohesive module with a stable boundary, explicit contracts, isolated data, and clear responsibilities is ready to move. The modular monolith makes service extraction a technical step, not an architectural gamble.