Modular Monolith
How often do you hear people debating about monolith vs microservices? What if I tell you, you can do both 😅. Modular monolith is a middle ground that offers the simplicity of a monolith with the clarity and scalability of modular design.
Today, we’ll explore the pros and cons of traditional monolithic and microservices architectures. We’ll look at how the modular monolith emerged, its internal structure, various strategies for data isolation, and the available communication methods between modules.
So, let’s make our own microservices with blackjack and modules 😅.
A Tale of Two Architectures
A monolith is a big codebase in which all the API, application logic, business logic, and data access are tightly connected and run together as a single unit within a process.
Sometimes monolithic applications are viewed negatively, but this is a traditional way of building applications, especially at the beginning.
If you start comparing it in different categories, you will see that there are always pros and cons:
This is why people prefer microservices (or small monoliths 🤭):
Microservices have a better PR campaign. They are trendy, modern, and stylish😎.
However, just for the sake of the experiment, let’s do the same comparison again and see what microservices are about:
Soooo…. yeaah… Not all that glitters is gold 😒.
Why is my point here? Both approaches have advantages and disadvantages. To make the right decision, you need to consider lots of factors. It’s essential to understand the business objectives clearly. Do not go for microservices just because they are fancy and modern. Think about why you need it 🙃.
Now, let’s say you decided to opt for microservices. What is your main reasoning? Most of the time, it's because you want to reduce the complexity of a big project.
So you start refactoring your project. You define the functionality you want to extract. Determine its boundary. Move those to a separate project before finally deploying it as a separate API.
The transition does not happen over one night. Overall, it is a tedious and complicated process. At some point, your architecture will have the following look:
You extracted some functionality in a separate module, but you still rely on a single entry point.
At this point, you may wonder:
Wait, a second…. I already reduced the complexity by structuring functionality in a separate module. Why go beyond that 🤔?
That’s the point. You don’t have to 😅.
You’re not Microsoft or Google — most projects don’t actually need microservices. Many teams that use them don’t even scale horizontally to avoid distributed cache, distributed locks, load balancing, etc.
Other developers came to the same conclusion when they realized they had met their needs without the added complexity of microservices.
This is how a new architecture was born. Behold, modular monolith 😁.
Anatomy of a Modular Monolith
Previously, the modular monolith was seen as an intermediate step toward microservices. However, today it’s common to start new projects using this approach from the very beginning.
It is a good way to reduce coupling in the system, prepare for a future transition to microservices, or work through domain uncertainties.
In this architecture, you have a single host with different modules.
The host is a composition root — a place where all dependencies are registered, and modules are composed together.
It is responsible for cross-cutting concerns like authentication, logs, transactions, exception handling, etc.
Additionaly, it can contain some common functionality for each layer, like a shared kernel for Domain, thread locks for Application, common Infrastructure like cache, etc.
The module is a self-contained, independent unit of code that performs a specific function within a larger system.
Each module has its own architecture, entities, business logic, use cases, infrastructure, etc.
Modules should not reference each other directly except through their public APIs (contracts).
The Application.Contract project contains the public API interface (IOrderApiClient), view models, integration events, enums, and other DTOs that the module exposes for external use.
The client API interface is an abstraction. It should be generic and similar to out-of-process communications:
public interface IOrderApiClient
{
Task<OrderDto> GetById(int orderId);
Task<CreateOrderResponseDto> Create(CreateOrderRequestDto request);
. . .
}Its implementation simply makes an in-memory call to the corresponding application service rather than using network communication:
internal class OrderApiClient : IOrderApiClient
{
public Task<OrderDto> GetById(int orderId)
{
return _mediator.Send(new GetOrderQuery(orderId));
}
. . .
}Storage
Now, let’s say we have the UI and several modules behind it. However, this is not enough. Modules always need to communicate, store, and exchange data with each other.
Let’s see what options are available for data persistence.
- single database
All modules can simply rely on a shared database.
This is considered to be a bad approach not only in microservices but also for modules.
Get iamprovidence’s stories in your inbox
Join Medium for free to get updates from this writer.
We are trying to deal with coupling, and having all tables for all modules in the same database does not really help us here 😒:
- by accessing and modifying another’s module data, you can bypass business logic invariants
- data modified in the wrong way can lead to unexpected behavior
- module has a dependency on a component it does not control
- separate schema
Often, modules use the same database, but different schemas.
This way, you can better distinguish tables that belong to a specific module:
It means:
- each module has its own entities, db models, and tables
- each module has its own
DbContextand can access only its own data - tables are not shared between modules
It’s important to remove any navigation property that references an entity from another module.
class Email
{
public string Address { get; set; }
public int OrderId { get; set; }
// ❌
// public Order Order { get; set; }
}It ensures that other modules cannot fetch or modify other data, helping to maintain business invariants. joins are only allowed between tables within the same module.
By the way, you can still keep foreign keys and cascade deleting at the database level — they help enforce data integrity and can be easily removed later if you choose to split the databases.
- separate databases
You can also place each module’s data in a separate database. Just remember, do not access another’s module database directly.
At this point, you can even use different database types that suit each module's needs: SQL, Redis, MongoDB, etc.
Modules Communications
Now, imagine a typical task where somebody placed an order in orders.*, and you need to get the username from users.*. How would you solve it?
Unexperienced developers, for the sake of performance, would query the other module’s database. This is a big NO-NO 👎. Once again, modular monolith is about dealing with complexity, not improving performance. If you start crossing boundaries, you are already mixing responsibilities.
You need to address this problem the same way you would with microservices:
- synchronously (query the Users module through its contract)
- asynchronously (duplicate the data and maintain it through events)
- synchronous communication
The synchronous communication means simply calling another module through its public contract (IOrderApiClient) to query missing data.
However, it results in tight coupling. Modules become dependent on each other’s functionality. If you move some behavior to another module, you need to rewrite all dependent clients.
- asynchronous communication
Asynchronous communication is usually implemented with messages. As before, this can be simple in-memory communication, but you can also use real message brokers like RabbitMQ or Kafka.
If we were to choose asynchronous communication, the problem from above could be solved this way:
- OrdersModule creates its own projection of
[User]table (but only with the columns it needs) - then OrdersModule subscribes for changes in UsersModule
- when the user’s name changes, OrdersModule updates the corresponding records in its own
[User]table
Yes, it means data duplication; however, this time, OrdersModule is self-sufficient. It has all the data needed to execute an action without making additional requests.
Data Consistency
In a monolith, there’s just one database, so transactions are easy — either all changes happen, or none. This gives you strong consistency, where updates are instantly visible.
In a modular monolith, you can still share the same DbContext / UnitOfWork, even if multiple modules are involved. The Application Service should coordinate transactions across modules. It is common to put this behaviour in the decorator:
public interface ITransactionalRequest { }
public class TransactionScopePipelineBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>, ITransactionalRequest
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next)
{
using (var scope = new TransactionScope())
{
var response = await next();
scope.Complete();
return response;
}
}
}In practice, most of the time you will access another module in a read-only manner, so this is not even a problem.
However, suppose you anticipate a future transition to microservices and plan to separate the modules into distinct services. In that case, you’ll need to design for eventual consistency rather than relying on a single, atomic transaction.
For distributed transactions, you can apply the same patterns as you have in microservices: Saga, compensation events, etc.
For example, when your controller receives a request, it can simply trigger a saga:
[ApiController]
[Route("orders")]
public class OrderController : ControllerBase
{
[HttpPost]
public Task<OrderItem> CreateOrder(
[FromBody] CreateOrderCommand request.
[FromServices] CreateOrderSagaOrchestrator saga)
{
return saga.Process(request);
}
}Where the Saga orchestrator is just an application service that calls different modules one by one:
public class CreateOrderSagaOrchestrator : ISagaOrchestrator<CreateOrderCommand, OrderItem>
{
private readonly IStateMachine _stateMachine;
private readonly IOrderApiClient _orderApiClient;
private readonly IEmailApiClient _emailApiClient;
private readonly TaskCompletionSource<OrderItem> _taskResult;
private CreateOrderCommand _request;
public CreateOrderSagaOrchestrator(
IOrderApiClient orderApiClient,
IEmailApiClient emailApiClient)
{
_orderApiClient = orderApiClient;
_emailApiClient = emailApiClient;
_stateMachine = ConfigureStateMachine();
_taskResult = new TaskCompletionSource<OrderItem>();
_request = null;
}
private IAsyncStateMachine<State, Event> ConfigureStateMachine()
{
var builder = new StateMachineBuilder();
builder
.Execute(OnCreateOrder)
.OnRollback(OnCreateOrderRollback)
.OnFailure(OnCreateOrderFailed)
.Execute(OnSendEmail)
.OnFailure(OnSendEmailFailed)
.Execute(OnCompleted);
return builder.Build();
}
// Start Saga
public async Task<OrderItem> Process(CreateOrderCommand request)
{
_request = request;
await _stateMachine.Run();
return await _taskResult.Task;
}
// first step
private async Task OnCreateOrder()
{
await _orderApiClient.Execute(new CreateOrderCommand(_request));
}
private async Task OnCreateOrderRollback()
{
// compensate action
await _orderApiClient.Execute(new DeleteOrderCommand(_request.OrderId));
await _stateMachine.Rollback();
}
private async Task OnCreateOrderFailed()
{
// finish saga
_taskResult.SetResult(null);
}
// second step
private async Task OnSendEmail()
{
await _emailApiClient.Execute(new SendEmailCommand());
}
private async Task OnSendEmailFailed()
{
await _stateMachine.Rollback();
}
// complete
private async Task OnCompleted()
{
var order = await _orderApiClient.Query(new GetOrderByIdQuery()
{
OrderId = _request.OrderId,
});
// finish saga
_taskResult.SetResult(order);
}
}Notice how the state machine and TaskCompletionSource are used.
Summary
The modular monolith structures an application as a single deployable unit while enforcing strict boundaries between internal modules.
The result? Easier development, better isolation, testability, and a smoother path to future scaling — without the premature complexity of distributed systems.
If you’ve read my previous articles, you might notice this one feels quite similar to the one about microservices — and that’s no coincidence 😅.
It’s actually a good practice to treat each module as if it were a separate microservice; after all, they are also small, isolated, independent, and focused on a single business domain.
When facing challenges like inter-module communication or transaction handling, ask yourself: how would I approach this if it were a microservice?
💬 Let's chat and share our experience. Have you tried this architecture? How do you feel about it?
👏 Give this article a clap if you liked it
☕️ You can also support me using the link below
✅ Follow to see how to implement them in practice
🙃 And keep coding









