The Modular Monolith architecture offers a balanced approach, combining the simplicity of a monolith with the organizational benefits of modular design.
This document is a comprehensive guide to understanding modular monolithic architecture, its challenges, and the points you should consider when designing your modular applications.
Modular monolith architecture is a software design approach that structures an application as a cohesive unit while internally dividing it into distinct, loosely coupled modules. Each module encapsulates specific business functionalities and interacts with others through well-defined interfaces, promoting high cohesion and low coupling within the system.
The entire application is deployed as one unit, simplifying deployment processes and reducing operational complexity.
Although it is a single application, it is organized into separate modules, each responsible for a specific domain or functionality.
Modules communicate through explicit interfaces, ensuring clear boundaries and minimizing interdependencies.
Maintaining a single codebase and deployment pipeline reduces complexity compared to microservices architectures.
Clear module boundaries facilitate easier updates and maintenance, as changes can be made within a module without affecting others.
While starting as a monolith, the modular structure allows individual modules to be extracted into microservices if scaling needs to evolve.
Multiple teams and developers can work on different modules in parallel without affecting each other.
In traditional monolithic architectures, all components are tightly interwoven, leading to challenges in scalability and maintenance. Modular monoliths address these issues by introducing internal modularity.
Microservices architecture involves building applications as a suite of independently deployable services. While offering scalability and flexibility, it introduces significant deployment, communication, and data consistency complexity. Modular monoliths provide a middle ground, offering internal modularity without the overhead of managing multiple services.
In summary, modular monolith architecture combines the simplicity of monolithic deployment with the benefits of modular design, making it a pragmatic choice for applications that require clear structure and maintainability without the complexities associated with microservices.
While modularity offers benefits, as explained in the previous sections, it also introduces several challenges that must be considered while designing, developing, testing and deploying the solution.
Determining module boundaries is one of the most challenging parts of the modular approach. Understanding which functionality should be implemented in which module can take time. Once you better understand the system, you should be ready to move some entities or services fully or partially to another module. Sometimes, that can also happen when business requirements are changed. Making such changes can be difficult, especially if the project has already been deployed and used in production.
Managing interactions between numerous modules can become complex. Ensuring that modules communicate effectively without tight coupling requires careful planning. Unintended dependencies can creep in, leading to a tangled web of modules that are difficult to manage.
Crafting clear and consistent interfaces is crucial. Poorly designed interfaces can lead to misunderstandings and errors in module communication. Updating interfaces without breaking dependent modules requires meticulous version control and communication among development teams.
In a modular application, isolating a module’s data and implementation details from others is a best practice. In this way, internal changes of a module (including database changes) don’t affect other modules as long as you can keep your contracts (a module’s interface used by other modules) unchanged or make them backward compatible. Even if the contracts change, updating your client code is trivial if a good abstraction is implemented.
In a unified data layer, you can perform a single SQL JOIN query that performs on many tables and is well-optimized at the database level. However, when you isolate your data from other modules, they always need to query data from your module when they need it. A simple database query may become several API calls and many database queries in a modular application, which can dramatically decrease application performance and consume system resources.
So, you need to care about performance on every inter-module interaction and optimize these communications. Consider using caching instead of querying every time. You can even consider implementing data duplication (copying other modules’ data into a module’s database to reduce communication and optimize query performance), denormalization and synchronization (on data changes on the target module).
While modules can be tested in isolation, ensuring they work together seamlessly is challenging. Comprehensive integration tests, which can be time-consuming and complex, are necessary to validate module interactions.
New developers may require time to understand the modular architecture and conventions used. Effective coordination is needed to ensure all teams are aligned on module interfaces and integration points.
As explained in the previous sections, modular application development has its challenges. Therefore, it is important to understand what types of projects and teams are suitable for building a modular system.
In the following conditions, you can consider to build a modular monolithic application:
If your domain is too complex to develop and maintain in a single monolith codebase.
If your business domain has clear functional boundaries and can split into sub-domains.
If you have multiple teams that will work on the solution.
If you are considering building a microservice architecture, it is usually advised to start with a monolith modular first, then migrate to microservices later once your business and module boundaries are more stable.
Once you decide to start with a modular architecture, you need to make some fundamental decisions. These decisions shape the organization of your database, codebase and development teams. In the next sections, we will explore these design points to understand our options and their pros and cons.
In modular software, it is a best practice that every module manages its own data and does not access other modules’ data. Otherwise, it would break the main purpose of modularity and make modules coupled to each other's internal implementation details, leading to a complicated codebase. When a module depends on the internal details of a module, any change in the target module may break the dependent module.
There are two common approaches (and their hybrid usages) for the physical database of a modular system: a single (shared) database or separate databases for each module. Let's discuss these approaches in more detail.
The following figure shows the single database approach. While all modules have their isolated codebases, they connect to a single (shared) physical database and store their data in the same place:
Even in the shared database approach, isolating a module’s data from the others is important. To do that, you can use sub-schemas, define separate database users and arrange these users’ permissions so that a module can only read and write for its own tables/views.
Starting with a single (shared) database can help you start your project faster as long as you ensure data isolation between the modules. Once you design and manage it correctly, you can split your databases when needed. A single physical database would be easier to set up, optimize, backup and manage than multiple databases.
In the separate database approach, every module has its own physical database using the same or different database technology.
The following figure shows
a few modules with their databases:
If you plan to migrate to microservices later, it is suggested that you start with the separate databases approach from the first day. Otherwise, the microservice migration process will be difficult, especially if you can not ensure good data isolation between modules.
However, when you have separate databases, do not expect the comfort of a single database in a monolithic application. You can not easily perform table JOIN queries between modules, and you will need to deal with distributed transactions. It is better to face the realities of modularity and design your codebase and database according to the rules of this brave new world.
One of the most important design decisions in modular design is whether to store your codebase in a single Git repository or distribute it across multiple Git repositories.
When you look at that question first, you may think it is natural that each module is developed in a separate Git repository to ensure complete code isolation and parallel development. However, when you start implementing it, you can easily understand how it complicates development, testing, versioning and deployment.
In the next sections, we will discuss the pros and cons of both approaches and suggest when to use them.
If you implement the following structure, then you use the monorepo approach;
Here are the pros of the monorepo approach;
So, it is much easier to develop, version, test and deploy a modular application in a monorepo approach. Especially if you have a small team, each developer is working on multiple modules, and these modules and their communication ways frequently change (which is normal at the beginning of a project), it is best to start with the monorepo approach.
In a multirepo approach,
Notice that, sometimes, you can group a few modules into a repository. In that case, the repository containing these modules should be considered a partial monorepo. All modules in that repository should use each other as local project references and be versioned and deployed together. You can have many groups of modules like that, where each group is located in a separate repository, and you finally have many repositories.
In the following conditions, you may consider using the manyrepo approach:
We can also suggest applying the manyrepo approach just before migrating to microservices. In many cases, it is good to start with a monorepo approach. Once the application matures and you are ready to move to microservices, you can start by splitting the monorepo into many repositories. In that way, you can start to develop, version and deploy your modules. Because when your modules become microservices, that is the way you will do it.
A modular monolith typically consists of multiple module projects and a host application that connects all the modules. This way, modules are developed separately but run as a single monolith application.
The following illustration shows a few modules and a monolithic host application:
It is not shown in the preceding figure, but modules also depend on and use each other. Because of that, it is a common approach to split a module’s codebase into (at least) two essential packages:
This package contains the actual implementation of the module. It includes entities, data access codes, application services, and other internal details. Other modules shouldn’t directly access the objects located in this package.
This package contains service interfaces, data transfer objects, event definition classes, and other objects you want to share with other modules. Other modules use this package to communicate with that module.
In addition to these essential packages, you can create two more packages for a module:
Contains UI pages and components related to the module.
Contains automated (unit/integration) tests related to the module.
So, a module’s codebase can be designed as shown in the following figure with all the packages mentioned above:
The preceding screenshot is taken from Visual Studio. The MyCrm.Ordering
project (package) contains the implementation,
the MyCrm.Ordering.Contracts
project contains the interfaces, the MyCrm.Ordering.Tests
project contains test code and, finally, the
MyCrm.Ordering.UI
project contains razor pages and components (if the UI layer is built with .NET).
Once you create your modules, you can establish dependencies (project/package references) between the modules and communicate them.
The following diagram shows that the two modules depend on each other:
MyCrm.Ordering
project has a reference to the MyCrm.Catalog.Contracts
project. Thus, it can perform service calls to the catalog
module to get information about products. For example, it can check the stock count before placing an order.
MyCrm.Catalog
project is dependent on the MyCrm.Ordering.Contracts
project. Thus, it can use its event classes to subscribe to events.
For example, it can subscribe to an OrderPlaced
event and decrease the stock count of the products included in the placed order.
So, a module can add a reference to the target module’s Contracts package when it needs to communicate with that module. The next two sections will explore two common types of communication between modules.
A module can call a method of another module through a service interface. These kinds of services are called integration services. Consider the following points while implementing such a communication between two modules:
A module can subscribe to another module's events. Once an event is published, it can take the necessary action. We can use an event bus service to orchestrate that communication.
Events are best if a change in a module triggers another change in other modules. By the nature of the “one publisher multiple subscriber” logic, multiple modules can listen to the same event and take different actions.
In a modular monolith application, it is relatively easy to execute event handlers in the same database transaction to ensure data consistency. In this way, if you get an exception on event execution, the main operation (that published the event) is also rolled back. ABP’s event bus works like that by default. If you migrate your system to microservices and convert your modules to services, these events become distributed, and you need to deal with distributed transactions. One approach to ensure transactional event publishing is to use inbox/outbox patterns. ABP’s distributed event bus supports these patterns, so you don’t need to change your application code while migrating to microservices later.
Modularity can be especially challenging in user interface development. Every UI page usually includes information from and takes actions on multiple modules to provide a good user experience. After all, users don’t care about modularity; they want to do everything easily and quickly.
You have two main choices when it comes to the UI of a modular application:
You can just ignore any kind of modularity on the UI layer. You just add references to every modules’ contract packages and consume application services to build a unified user experience. In this way, developing the UI layer would be much straightforward, but working on different parts of the UI by different teams in parallel would be more difficult.
Every module defines its own UI packages. In a module’s UI package, you define pages and components related to that module. You can consume your module's and other modules' services in your UI package. For other modules, only perform read/query operations and not perform data manipulation as a best practice.
When you implement a modular UI, some modules may have UI pages that only use the module's data to which they belong. Building these pages is straightforward; you can just implement the whole page in the module's UI package.
You can implement a component-based UI when pages need to work with multiple modules’ data. Each module defines UI components in its own UI packages. Then, a unification UI layer implements the page by preparing a layout and placing these module components on the page.
The following screenshot is taken from the Amazon’s website:
On that page, you can easily see that many parts can be implemented as components in different modules (e.g., Catalog, Stock, Delivery, Account, Basket, Search, User Reviews…, etc.). Then, we can combine these components to provide a unified user experience. Almost every UI technology provides such a component system.
When you have a non-modular application, your infrastructural code becomes part of the application. In a good implementation, it can be in a separate layer. But even in that way, it is implemented specifically for that application’s requirements.
However, it is not reasonable to repeatedly re-implement the infrastructure requirements in every module of a modular application. You need to have an infrastructure module or, more accurately, an infrastructure framework to implement common requirements. The following sections explain some of these common requirements.
At a very basic level, you want some base classes to inherit your entities, repositories, services, controllers, data transfer objects, and utility classes to perform simple common tasks. Collecting these classes in a shared library makes your modules’ code simpler and less repetitive and forces them to be implemented in a more standard way.
Some common operations should be implemented in every use case: authorization, validation, database connection and transaction management, exception handling, audit logging, concurrency check, response caching, and sometimes more…
The implementation of these common concerns is very similar to that of every other use. In a well-designed application, these concerns are implemented in a single place and then applied to every use case through interceptors, action filters, middlewares or a similar mechanism. So, these implementations should also be located in a shared library.
Every business application needs many third-party libraries and services to perform common tasks, such as dependency injection, object mapping, distributed caching, SMS and email sending, scheduling background jobs, data access, PDF/Excel export, BLOB storage, image manipulation, etc.
A well-designed application has abstractions for these kinds of libraries and services for the following reasons;
So, abstraction and integration code for such libraries and services should also be located in some shared libraries.
Every serious application needs common building blocks, like setting management, permissions management, audit logging, event bus, data filtering, maybe multi-tenancy infrastructure and more. Since all these features can not be part of a particular module, you need to implement them in your infrastructure layer.
Because of all these (and more) common requirements, it is typical for a company to have an infrastructure team working on the infrastructure layer. Otherwise, it is difficult to determine a standard between the modules, separate infrastructural requirements from the modules and implement them in a shared infrastructure layer.
Instead of having such an infrastructure team and building all these common infrastructures yourself, consider using an application framework designed specifically to solve these problems and provide the necessary infrastructure for your application.
In the next section, we will briefly explain how the ABP Platform helps you to overcome these challenges.
The ABP Platform has been designed to provide a complete infrastructure for building modular applications. The next sections highlight some ABP Platform components and explain how they help you build a modular monolithic application successfully.
The core ABP Framework is open-source and provides the fundamental infrastructure for modular development.
ABP provides a comprehensive infrastructure for common business application requirements, not only for modular applications but also for any kind of serious software solutions. Instead of having an internal infrastructure team, let the ABP development team be your infrastructure team.
Audit logging, background jobs, BLOB storing, email sending, advanced setting and permission management, base classes for business objects, many 3rd party library and service integrations, automating all the cross-cutting concerns, and more… Check the ABP documentation to see what you can directly use and then focus on implementing only your business needs.
ABP allows you to define a module, automatically register its services, define and configure its dependencies, perform tasks in application lifecycle events, and optionally load modules as plug-ins. This way, you can create full-featured, isolated and re-usable module packages.
The following code block shows a simple module definition class with ABP Framework. It clearly defines its dependencies, and configures and initializes its own services:
[DependsOn(
typeof(AbpEntityFrameworkCoreModule), //Depending on a framework module
typeof(MyBusinessModule) //Depending on your own module
)]
public class MyAppModule : AbpModule
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
//Configure DI and other modules...
}
public override void OnApplicationInitialization(ApplicationInitializationContext context)
{
//Perform some application initialization logic...
}
}
ABP provides the fundamental models to facilitate communication between modules. The good part is these models are ready for microservices. That means if you migrate to microservices later, no code change is required in your application code.
The event bus is used for loosely coupled communication between the modules. It works as an in-process, transactional event bus in a monolithic application and can be easily replaced by a distributed implementation (e.g. RabbitMQ) with distributed transaction support once you decide to migrate to a microservice architecture.
Integration services provide a convenient way to design and implement API calls between modules. If you need to migrate to microservices later, they can be replaced by HTTP API calls without any code change with the help of ABP’s dynamic or static client proxy and auto API controller systems.
ABP supports multiple UI technologies (e.g. MVC / Razor Pages, Blazor and Angular), and for each technology it provides a complete modular architecture to build UI pages/components in different modules and compose them in a unified web application.
ABP separates UI theming/styling from module code. It determines and standardizes how to build UI pages and components in module packages. This way, all modules use the same design language and can work with any UI theme that applies the same standards.
Menus, toolbars, layout hooks, alerts, localization, client-side package dependencies, the bundling/minification system, and many other UI parts are designed to support multi-module scenarios and allow you to shape your application UI dynamically.
Domain-Driven Design (DDD) is a widely accepted approach to building complex and modular software solutions. DDD is a first-class citizen in the ABP Framework. ABP provides base classes and services to build your aggregates/entities, repositories, domain services, application services, and more.
You can design your modules and communicate them with each other using bounded context and context mapping principles. If you prefer to implement DDD principles and patterns, ABP can help you practically implement your technical requirements. You can also download the free Implementing Domain-Driven Design e-book that clearly explains how to shape DDD building blocks in your application code.
In addition to simplifying and accelerating your modular application development, ABP also provides pre-built application modules ready to install and use in your application.
You don’t have to reinvent the wheel in every application. For example, the account module provides user login, user register, forgot password, email confirmation, two-factor authentication, user lockout, social logins, active directory integration, OpenID connect integration, user impersonation and more… Just install it to your application, and you have an advanced account system. You can check the other modules to understand how they can save you time.
ABP Studio is a cross-platform desktop application for ABP and .NET developers. It aims to provide a comfortable development environment by automating tasks, providing insights about your solution, and making it much easier to develop, run, browse, monitor, trace and deploy your solutions.
One of the main goals of ABP Studio is to architect and build modular .NET solutions. You can easily create modules in your solution, import a module to other modules to consume its services, and install modules from NuGet.
"The ABP Framework has been a transformative tool for our development process. Its unique features, like modularity and built-in multi-tenancy, have significantly streamlined our projects. The ability to choose from different UI frameworks and databases allowed us to customize our applications to meet specific client needs without compromising efficiency. The comprehensive documentation and active community support have also been invaluable resources. Overall, the ABP Framework has enabled us to deliver high-quality software solutions faster and more effectively than ever before."
Use the following resources to start building your modular monolithic application today using the ABP Platform:
Use the following resources to start building your modular monolithic application today using the ABP Platform: