Modular Monolith: Domain-Centric Design
In previous posts in this series, I covered what a Modular Monolith is, what its architecture looks like and how this architecture can be enforced. Then I described architectural drivers for this architecture and styles of integration between modules.
In this post I would like to go deeper - a level lower and describe how such architecture can be designed. We are not going to implement this architecture yet - we will focus on its technology-agnostic design.
As the name of Modular Monolith architecture suggests, the design of our architecture must be oriented towards high modularity. What follows from this, the system must have self-contained modules that provide the entire business functionality. This is why domain-centric architecture and design is natural choice in that case.
Moreover, as we know, modules must have well-defined interfaces. All communication between modules should only take place through these interfaces, which means that each module must be highly encapsulated.
Let’s see how such architecture can look like from high-level view:
Modular Monolith: Domain-Centric Design
Looking at a high-level view, there are similarities to domain-centric architectures. This is exactly the case - both in terms of the architecture of the entire system (system architecture) and individual modules (application architecture) described later.
Looking at the similarities to the Hexagonal Architecture we have:
- API: primary adapters
- Module API: primary ports
- secondary ports and its adapters (to communicate with a database, events bus, other modules)
Modular Monlith: Hexagonal Architecture View
Source code dependencies must point only inward, toward higher-level policies.
Modular Monlith: Clean/Onion Architecture View
Let’s try to describe all the elements one after the other.
API is the entry point to our system. Mainly implemented as a web service (SOAP/REST/GraphQL) that accepts HTTP requests and returns HTTP responses.
The main and only responsibility of the API is to forward the request to the appropriate module. It is an equivalent of API Gateway in microservice architecture, only instead of network calls to services we have module calls in memory.
The API should be very thin. There should be no logic there - neither application nor business one. Everything we put there should be related only to processing HTTP requests and routing.
Each module should be treated as a separate application. In other words, it’s a subsystem of our system. Thanks to this, it will have autonomy. It will be loosely or even not coupled to other modules (subsystems). It means that each module can be developed by a separate team. This is the same architectural driver as in the case of microservice architecture.
Moreover, we will be able to easily extract a particular module into a separate runtime component (Monolith split). Of course only if necessary - this is not the goal of our architecture, only a great side-effect of modularity.
Since the module should be domain-oriented (see Bounded Context concept from DDD strategic patterns set), we can use the domain-centric architecture again - this time on the level of the module itself.
The module architecture is as follows:
Module Startup API
Module Startup API is a port/interface thanks to which a given module can be initialized. As a given module must be self-contained, it should be able to initialize itself, getting only the appropriate configuration parameters needed for its operation. This means that we do NOT configure a given module in the API (or another module host). We only initiate its initialization at startup.
Support for module autonomy also means that a given module must be able to create an object dependency graph itself, i.e. it should have its own Composition Root.
This usually means that it will have its own IoC container. It is very important thing. Unfortunatelly, the most common approach is an IoC container defined per whole runtime component. It is good approach for tiny systems, not for the more complex and modular.
Module API is an interface (primary port) for communicating with the given module (except initialization - see Module Startup API). Such a module API can be created in two ways:
- traditional approach: a list of methods (
- CQRS-style approach: a set of Queries and Commands to be sent (
I am definitely a fan of the second, CQRS-style approach, but in my opinion, the first approach is also acceptable.
Infrastructure - Secondary Adapters
This is where the implementation of secondary adapters should be (from the nomenclature of Ports and Adapters architecture). Secondary adapters are responsible for communication with the external dependencies (in-process and out-of-process): databases, events bus, another modules.
Here you should find the implementation of use cases related to the module. It is a Hexagon boundary (from Ports and Adapters architecture view), Application Core boundary (from Onion Architecture architecture view) or Use Cases layer (from Clean Architecture view).
No matter which domain-centric architecture you follow the principle is the same. In that place, we no longer deal with technical and infrastructure issues. Here, we only focus on implementing application and business requirements.
However, sometimes domain is not trivial so we want explicitly separate it calling this layer the Domain.
Even the least complicated module has a domain. And the domain is the most important here, it is the heart of all domain-centric architecture, same for Modular Monolith architecture. Thanks to the domain-centric architecture, it is decoupled from frameworks and infrastructure.
Model of this domain (Domain Model) is applicable only in Bounded Context (boundary). There will be only concepts related to our domain and the so-called enterprise business rules.
How many layers?
There is a lot of discussion about application layers on the Internet. Some prefer to have a very clear layers division (e.g. using separate libraries/packages or other language techniques). Others prefer to keep everything together without logical decomposition.
First of all, be aware that each module will be different. One may have a more complicated domain, the other may only implement CRUD operations. In this case, the application architecture for these modules will be different.
Moreover, within the same module there may be both more and less complicated functionalities. In this case, we should also respect each functionality separately.
In conclusion, the application of layers should not be a global decision for a given module - each use case should be considered separately. This approach is close to the Vertical Slices architecture, applied at the module level, not the entire application.
Some say domain-centric architectures and vertical slices are opposites. It is far from the truth - in my opinion, they complement each other perfectly.
Module application architecture styles
Each module must have its own state, which means that its data must be private. We don’t want to use Shared Database Pattern. This is a key attribute needed to achieve the autonomy and modularity of a module. If we want to know the state of the module or change it - we have to do it through the interface. There are no shortcuts.
However, sometimes we want to share some data for reporting purposes. In this case, we can use separate Reporting Database and provide data in the form of separate views on the module database in a special integration scheme - only for this kind of integration. In this way, we create a concept of API on the database level - it is just a good thing to do.
I wrote about the modules integration in detail in the previous post. As you can see, the Modular Monolith architecture design assumes 2 forms of communication:
- Asynchronous via events (Event-Driven architecture). Each module sends or subscribes to certain events via Events Bus. This Events Bus can be in memory mechanism or out-of-process component - depending on the needs.
- Synchronous with in memory calls. Here, as in the case of API communication with modules, it can be implemented in a traditional approach or CQRS-style (Commands / Queries). What is important here is that such integration should be explicit - by creating a Gate (adapter) on the consumer side and a Facade (port and its implementation) on the supplier side.
If you want to modularize your system, it means that it is not trivial (or won’t be in the future). This means that automated tests are a must-have. However, what percentage of a given type of test should be written depends on the given system, its level of complexity, number of integrations, and other factors.
Tests are an extensive topic that is beyond the scope of this article and certainly deserves a separate one. Here, I wanted only to highlight which tests should be considered.
End to End tests
E2E tests test your entire system - from API to infrastructure and back again. Often, They check the whole fragment of our system so they have the biggest test code coverage.
However, they often also test the system together with the GUI. For this reason, they are the slowest, fragile and hard to maintain.
Integration Testing is a broad term that is understood in various ways. In the proposed architecture, these are comprehensive tests of a given module (or interaction between modules), without the API layer. These types of integration tests are second consumers of our modules (just another adapters). As the API layer is very thin, they cover practically our entire application and do not operate on low-level abstraction objects (JSON, HTTP).
Unit tests will be used mainly to test the Domain Model - business logic. Thanks to the use of domain-centric architecture as part of the module, the Domain Model is separated from the infrastructure and can be easily tested in memory.
As you can see, making our system modular requires discipline in following the rules and principles of proper design. The entire system is constantly decomposed into smaller fragments. Every piece of the puzzle is important. Let’s summarize the most important attributes of this architecture:
- domain-centric on multiple levels
- well-defined integration points (interfaces)
- self-contained, encapsulated modules
- testability - making the application and domain layer independent from frameworks and infrastructure
- evolutionary - easy to develop and maintain (adding new modules or adapters)
If you don’t need to distribute your system (and most people don’t) and your system is non-trivial - maybe a Modular Monolith with Domain Centric Design in mind will be for you. Remember, however, that it all depends on the context in which your project exists so make your decisions consciously.
Image credits: Magnasoma
More in series
This post is part of the Modular Monolith series: