A Modular Monolith is an architectural style that blends the advantages of both traditional monolithic architectures and microservices. It structures a software application into distinct, loosely coupled modules, with each module handling a specific business capability. However, unlike microservices, these modules are not deployed independently; instead, they are packaged and deployed together as a single unit, like a monolith.
The biggest advantage of a Modular Monolith is its ease of management. Unlike microservices, it does not require Kubernetes or other complex orchestration tools to handle multiple moving parts.
While the application is monolithic in deployment, it is not a tangled "big ball of mud." Instead, it follows a modular structure, where each module functions like a mini-application with a well-defined responsibility. However, all modules are deployed together as a single unit. This approach makes the system both cost-effective and easy to manage.
Data Architecture
In a Modular Monolith architecture, the application is typically deployed as a single unit, which often leads to using a single database shared by all modules as shown in Figure 1. This approach minimizes inter-module communication since all data is centrally available.
When using a single database, organizing data into separate schemas for each module can help isolate their data, improving maintainability and reducing unintended dependencies between modules.
However, if the modules are designed to be independent and serve distinct business functions, they can also have separate databases that store only the data relevant to their specific context as shown in Figure 2. Even in this case, the overall architecture remains monolithic because all modules are deployed together as a single unit.
Module Communication
In this architectural style, direct communication between modules is generally discouraged, though it often becomes necessary in practice. Below are two primary approaches to facilitate module communication, each with its own benefits and trade-offs.
Peer-to-Peer Communication
The simplest approach is peer-to-peer communication, where modules interact directly with one another. Here, a class in one module instantiates a class from another module and calls its methods directly. While this method is straightforward to implement, it has a significant drawback: it tightly couples the modules. Excessive interconnections can erode modularity, leading to a tangled architecture, often leading to the "Big Ball of Mud" antipattern—a system that becomes increasingly difficult to manage and scale.
Aggregator Pattern
A more structured alternative is the aggregator pattern, which introduces an aggregator component as an intermediary layer between modules. Rather than communicating directly, modules send requests to the aggregator, which then routes them to the appropriate destination module.
This approach reduces direct dependencies, enhancing modularity and maintainability. However, while it decouples modules from one another, each module still relies on the aggregator. Although this does not eliminate all coupling, it simplifies the architecture and enforces a controlled, centralized communication structure.
Importantly, the aggregator—not the dependent modules—should provide an API for accessing functionality in other modules. This design keeps modules loosely coupled and preserves a clear separation of concerns.
Example
Here is a typical Modular Monolith example. The solution, named EcommerceApplication
, is organized into distinct directories for clarity and modularity. The Applications directory houses deployable applications, such as the Aggregator project, which includes an API along with its associated API.Contracts
and API.Tests
projects. This directory is designed to accommodate additional deployable applications or user interface, each encapsulated within its own subdirectory.
The Modules directory contains modular components, each residing in a separate subdirectory. This includes InventoryManagement
, OrderPlacement
, and PaymentProcessing
, each with corresponding C# projects. These modules are further structured with Contracts projects that define API contracts and integration events. Additionally, each module includes a Tests project for unit and integration testing.
This structure adheres to a traditional .NET convention, enabling the separation of API contracts into dedicated assembly projects. The Contracts projects not only define the API contracts but also house integration events. For a larger application, these could be further separated to enhance maintainability and scalability.
Advantages
- Simpler Deployment: Since it is a single application, you deploy a single artifact. This avoids the complexity of coordinating multiple services, as you would see in microservices, reducing operational overhead.
- Easier Debugging and Testing: With everything in one codebase, tracing issues across modules is straightforward. You can run the entire system locally, making integration testing simpler compared to distributed systems.
- Performance Efficiency: Modules communicate in-process (e.g., via function calls) rather than over a network, avoiding latency and serialization costs typical in microservices.
- Shared Resources: Modules can share a single database, memory, or other resources without needing complex synchronization mechanisms, simplifying data consistency avoiding the need for distributed transactions or eventual consistency models.
- Gradual Evolution: It is a middle ground between a traditional monolith and microservices. You can start with a modular monolith and later extract modules into separate services if needed, offering flexibility as requirements grow.
- Reduced Overhead: No need for service discovery, API gateways, or distributed logging setups early on, which lowers initial development and infrastructure costs.
Disadvantages
- Scalability Limits: Unlike microservices, you cannot scale individual modules independently. The entire application scales as a unit, which can waste resources if only one module is under heavy load.
- Coupling Risk: While modules are intended to be loosely coupled, poor design can lead to tight dependencies, eroding modularity and bringing back the pitfalls of a traditional monolith (e.g., spaghetti code).
- Single Point of Failure: If the monolith goes down, the entire system is affected. There is no isolation of failures as you would get with separate services.
- Technology Lock-In: All modules typically use the same tech stack. If one module would benefit from a different language or framework, you are constrained unless you refactor significantly.
- Deployment Bottlenecks: Changes to one module require redeploying the whole application, which can slow down release cycles and increase the risk of introducing unrelated bugs.
- Team Coordination: As the codebase grows, multiple teams working on different modules can step on each other’s toes, especially if boundaries are not well-defined or enforced.
When It Works Best
A modular monolith shines for medium-sized applications where you want maintainability without the complexity of a distributed system. It is a pragmatic choice for startups or teams that need to iterate fast but expect growth. If you outgrow it, the modularity makes transitioning to microservices less painful.
Top comments (0)