Rules to Better Modular Monoliths - 4 Rules
A Modular Monolith is an architectural style in software development that emphasizes modularity within a monolithic application structure.
A Modular Monolith is a software architecture pattern that combines elements of both monolithic and modular architectures. In this approach, the application is built as a single, unified codebase like a traditional monolith, but it is designed and organized in a modular manner, allowing for logical separation of different components or modules within the codebase.
The Modular Monolith architecture is the “goldilocks” approach that combines the modularity of microservices with the simplicity of traditional Monoliths
- Steve “Ardalis” Smith
Modular Monolith characteristics
- Single Host/Process
- Single Deployment
-
Loosely coupled modules that each have their own
- Domain
- Application
- Infrastructure
- Presentation (API or UI)
- Each module represents a business capability or domain
- Each module should be as highly cohesive and loosely coupled with other modules
- Each module manages it's own data and persistence
✅ Advantages
- Simplicity in Deployment - Since it's a monolith, the deployment is typically simpler than distributed systems like microservices
- Ease of Development - Developers can work on separate modules without significantly affecting other parts of the application
- Performance - Inter-module communication is often faster and more reliable than inter-service communication in distributed architectures
❌ Challenges
- Scalability - While more scalable than a traditional monolith, it may not scale as effectively as microservices
- Modular Discipline - Maintaining strict modularity can be challenging as the application grows and evolves
A Modular Monolith offers a balance between the simplicity and coherence of a monolith and the modularity and maintainability of more distributed architectures. It is particularly useful for certain kinds of applications and organizational contexts.
Modular Monolith compared to other architectures
Trade-Offs Layered / CA Microservices Modular Monolith Modularity ❌ ✅ ✅ Cost $ $$$ $ Scalability ❌ ✅ ❌ Simplicity ✅ ❌ ✅ When building distributed applications messaging is a common pattern to use. Often we might take a hard dependency on a specific messaging technology, such as Azure Service Bus or RabbitMQ. This can make it difficult to change messaging technologies in the future. Good architecture is about making decisions that make things easy to change in future. This is where MassTransit comes in.
MassTransit is a popular open-source .NET library that makes it easy to build distributed applications using messaging without tying you to one specific messaging technology.
.NET Messaging Libraries
There are several .NET messaging libraries that all abstract the underlying transport. These include:
- MassTransit (recommended)
- NServiceBus
- Rebus
There are also the service bus specific libraries:
- Azure Service Bus(not recommended)
- Amazon SQS(not recommended)
- RabbitMQ(not recommended)
- (and more)
Advantages of using MassTransit
✅ Open-source and free to use
✅ Enables swapping of messaging transports by providing a common abstraction layer
✅ Supports multiple messaging concepts:
- Point-to-Point
- Publish/Subscribe
- Request/Response
✅ Supports multiple messaging transports:
- In-Memory
- RabbitMQ
- Azure Service Bus
- Amazon SQS
- ActiveMQ
- Kafka
- gRPC
- SQL/DB
✅ Supports complex messaging patterns such as Sagas
Scenarios
Scenario 1 - Modular Monolith
A Modular Monolith architecture requires all modules to be running in a single process. MassTransit can be used to facilitate in-memory communication between modules in the same process.
This allows us to send events between modules and also request data from other modules.
Scenario 2 - Azure Hosted Microservices
When building microservices in Azure, it's common to use Azure Service Bus as the messaging transport. With minimal changes, MassTransit can be used to send messages to and from Azure Service Bus instead of using the In-Memory transport.
Scenario 3 - Locally Developing Microservices
When developing microservices locally, it's common to use containers for each service. However, some of the cloud based messaging services (e.g. Azure Service Bus) are not able to be run in a container locally. In this scenario, we can easily switch from using the Azure Service Bus transport to Containerized RabbitMQ transport
Demo Code
If you're interested in seeing MassTransit in action, check out github.com/danielmackay/dotnet-mass-transit
Choosing the right software architecture for your system is crucial for its success and maintainability. Making the wrong choice can lead to increased complexity, difficulty in scaling, and higher costs.
Popular Architectures
Here are some of the popular architectures and factors to consider when deciding the best fit for your project:
Clean Architecture
Clean Architecture emphasizes separation of concerns, making your system easier to maintain and scale. This architecture is designed to keep the business logic independent of the frameworks and tools, which helps in achieving a decoupled and testable codebase.
See more on Rules to Better Clean Architecture.
You can find our CA template on GitHub
Vertical Slice Architecture
Vertical Slice Architecture structures your system around features rather than technical layers. Each feature is implemented end-to-end, including UI/API, business logic, and data access. This approach improves maintainability and reduces the risk of breaking changes.
This modular approach to software development can introduce inexperienced teams to the idea of shipping features as functional units with no shared knowledge of the domain entities, infrastructure layer, or application layer within another subsystem, further preparing them for future development environments that may use Modular Monolith or Microservices Architecture.
You can find our VSA template on GitHub
Modular Monolith
A Modular Monolith organizes the system into modules that encapsulate specific functionalities. While it runs as a single application, it retains some benefits of microservices, such as independent module development and testing. It’s a good middle-ground between a monolith and microservices.
See more on Rules to Better Modular Monoliths.
Microservices
Microservices architecture involves splitting the application into small, independently deployable services. Each service focuses on a specific business capability and can be developed, deployed, and scaled independently. This approach is beneficial for complex and large-scale applications with multiple teams working on different parts.
See more on Rules to Better Microservices.
Architecture Decision Tree
It's important to keep in mind that these architectures are not mutually exclusive.
Within a Modular Monolith Architecture, each module could be implemented using Clean Architecture or Vertical Slice Architecture. Similarly, a Microservices Architecture could use Clean Architecture or Vertical Slice Architecture within each service.
Also, from a pragmatic point of view a combination of Modular Monolith and Microservices might provide the best of both worlds. The majority of the system could be implemented as a Modular Monolith, with a few key services implemented as Microservices to provide scalability and flexibility where needed.
Factors to Consider
- Are your requirements certain?
If requirements are likely to change, Clean Architecture or Vertical Slice Architecture can offer more flexibility. - Do you have multiple domains?
For applications with multiple domains, Modular Monoliths or Microservices can provide better separation and modularity. - Do you have many teams? If you have many teams, Microservices or Modular Monolith can help in reducing inter-team dependencies and allow parallel development.
- Do you need independent deployments? If independent deployments are necessary, Microservices is the best choice due to its isolated nature.
- Do you need independent scalability? Microservices allow each service to be scaled independently based on its specific needs, which can be more efficient and cost-effective.
- Do you have DevOps maturity? Microservices require a mature DevOps culture to manage deployments, monitoring, and scaling effectively. Without this, the overhead can be overwhelming.
- Is the team experienced? The complexity of Microservices can be challenging for less experienced teams. Vertical Slice Architecture although simple, has fewer guardrails when compared to Clean Architecture and can lead to a mess if not managed correctly. This leads to recommending Clean Architecture for less experienced teams that need more structure.
Examples
Here are some practical scenarios to illustrate the decision-making process:
Scenario 1: Startup with uncertain requirements
You are building an MVP with a small team and expect the requirements to evolve rapidly.
✅ Choice: Clean Architecture or Vertical Slice Architecture - These architectures offer flexibility and are easier to refactor as requirements change.
Scenario 2: Medium-sized business with limited DevOps maturity
You have a mid-sized team, and your organization is still developing its DevOps practices.
✅ Choice: Modular Monolith - A Modular Monolith provides some modularity benefits without the full complexity of Microservices, making it easier to manage with limited DevOps capabilities.
Scenario 3: Large enterprise with multiple domains and teams
You are developing a large-scale application with multiple business domains and have several teams working in parallel.
✅ Choice: Microservices - Microservices allow independent development, deployment, and scaling, which suits large and complex applications.
By carefully considering these factors and understanding the strengths and limitations of each architectural style, you can choose the best architecture for your system, ensuring a balance between flexibility, scalability, and maintainability.
- Are your requirements certain?
In modular monolith applications, establishing a strong testing strategy is essential to ensure robust functionality and maintainable code across multiple modules. Modular monoliths often centralize domain logic, require clear interactions across module boundaries, and should provide cohesive end-to-end workflows. Implementing a well-structured testing strategy will help catch errors early, validate integrations, and prevent issues from arising in production.
There are 3 main testing strategies that can be used to effectively test modular monoliths:
- Unit Testing
- Integration Testing
- Workflow Testing
Unit Testing
Unit testing is critical in a modular monolith to validate the core business rules and domain logic within individual modules. Effective unit tests ensure that each module functions as expected, enabling:
- Verification of isolated business logic within each module, such as validation rules or calculations.
- Maintenance of a clean, well-defined contract for each module’s internal methods.
Ensure each module’s domain logic has comprehensive unit tests that cover typical and edge cases.
Figure: Good Example - Unit tests confirm the integrity of domain logic in isolation from external dependencies.
Integration Testing
Integration testing in modular monoliths is one of the highest-value forms of testing, as it verifies that modules interact as intended with infrastructure such as databases, and that modules also communicate correctly together. This is essential since a failure in module interactions can lead to complex issues in production.
A reliable integration testing approach involves:
- Testing interactions between modules to ensure data is handled and processed correctly across boundaries.
- Simulating database and service dependencies to validate that modules function cohesively in a realistic environment.
There are several strategies that can make integration tests even more effective:
- Do not mock (where possible) - Mocking can lead to false positives and hide issues that only occur when modules interact. It also makes tests more brittle and harder to maintain.
- Use a real database - Using a real database in integration tests can uncover issues that would not be caught with an in-memory database.
- Isolate test infrastructure - If the modular monolith uses multiple databases, an isolated set of databases should be used for each modules tests. This ensures that inter-module communication is tested correctly without interference from other module tests.
Integration tests allow teams to identify and resolve issues that occur only when modules interact, which can be challenging to catch with unit tests alone.
Implement Workflow Tests for Full End-to-End Scenarios
Workflow (or end-to-end) tests are invaluable in modular monoliths as they cover the application’s key business flows from start to finish. These tests simulate real user actions across multiple modules and validate that all components work together to produce the expected outcomes. This type of testing should be focused on:
- Simulating critical application workflows that span multiple modules, such as user registration, order processing, or data retrieval.
- Ensuring that each step in the workflow completes successfully and transitions correctly to the next, providing a realistic assessment of application stability.
While workflow tests are the most complex and resource-intensive, they provide confidence that major application processes are functioning correctly and as intended.
Design workflow tests that cover the main paths users take, ensuring all modules work together to deliver expected end results.
Figure: Good Example - Workflow tests simulate full user journeys, giving comprehensive validation of the application’s functionality.
By incorporating unit, integration, and workflow tests, teams can ensure that modular monoliths remain reliable, maintainable, and scalable. An effective testing strategy with these layers will provide better insights into system functionality and facilitate easier troubleshooting and faster development cycles.