Modular Monolith: Domain-Centric Design

This post is part of articles series about Modular Monolith architecture:

1. Modular Monolith: A Primer
2. Modular Monolith: Architectural Drivers
3. Modular Monolith: Architecture Enforcement
4. Modular Monolith: Integration Styles

5. Modular Monolith: Domain-Centric Design (this)

Introduction

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.

Domain-Centric 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 Design
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
Modular Monlith: Hexagonal Architecture View

If we look closely, this architecture is no different from Onion and Clean architectures. The most important thing is that our domain is inside and the Dependency Rule is respected:

Source code dependencies must point only inward, toward higher-level policies.

Modular Monlith: Clean/Onion Architecture View
Modular Monlith: Clean/Onion Architecture View

Let’s try to describe all the elements one after the other.

API

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.

Module

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 Architecture
Module Architecture

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.

Composition Root

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

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 (CustomerService.GetCustomer, OrderService.AddOrder)
CQRS-style approach: a set of Queries and Commands to be sent (GetCustomerQuery, AddOrderCommand)

I am definitely a fan of the second, CQRS-style approach, but in my opinion, the first approach is also acceptable.

According to the modularization key attributes, this module API should be as small as possible – expose only what is needed (no less, no more). This will make it more stable.

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.

Application

Here you should find the implementation of use cases related to the module. It is a Application Core boundary (from Onion Architecture architecture view) or 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.

Domain Model should have Persistence Ignorance. Written in Ubiquitous Language and completely testable. Here we focus the most, the rest is only to make it easier for us.

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
Module application architecture styles

Module Data

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.

Modules integration

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:

1. 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.

2. 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.

Tests

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 Tests

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

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.

Summary

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.

Related Posts

1. Modular Monolith: A Primer
2. Modular Monolith: Architectural Drivers
3. Modular Monolith: Architecture Enforcement
4. Modular Monolith: Integration Styles
5. Domain Model Encapsulation and PI with EF Core
6. Simple CQRS implementation with raw SQL and DDD

Image credits: Magnasoma

Modular Monolith: Integration Styles

This post is part of articles series about Modular Monolith architecture:

1. Modular Monolith: A Primer
2. Modular Monolith: Architectural Drivers
3. Modular Monolith: Architecture Enforcement
4. Modular Monolith: Integration Styles (this)

5. Modular Monolith: Domain-Centric Design

Introduction

No module or application in a larger system works in 100% isolation. In order to deliver business value, individual elements must somehow integrate with each other. Let me write here a quote from the book “Thinking in Systems: A Primer”, where Donella H. Meadows defines the system concept in general:

A system is an interconnected set of elements that is coherently organized in a way that achieves something. If you look at that definition closely for a minute, you can see that a system must consist of three kinds of things: elements, interconnections, and a function or purpose.

The concept of systems integration is defined as follows (wiki):

process of linking together different computing systems and software applications physically or functionally, to act as a coordinated whole.

As you can see from the definitions above, in order to provide a system that fulfills its purpose, we must integrate elements to form a whole. In previous articles in this series, we discussed the attributes of these elements which are, in our terminology, called modules.

In this post, I would just like to discuss the missing part – Integration Styles for modules in Modular Monolith architecture.

Enterprise Integration Patterns book

The title of this post is not accidental. It sounds exactly like Chapter 2 of Gregor Hohpe and Bobby Wolf great book Enterprise Integration Patterns. This book is considered a bible of information about systems integration and messaging. This article takes some knowledge from this chapter and relates it to the monolithic and modular architecture.

In any case, everyone interested in the topic of integration, I invite you to read the book or materials that are available online at https://www.enterpriseintegrationpatterns.com/ site.

Integration Styles

Integration Criteria

Like everything in nature, each Integration Style has its pros and cons. Therefore, we must define criteria on the basis of which we will compare all styles. Then, based on that criteria, we will decide on the method of integration in the future.

We can distinguish the following criteria: Coupling, Complexity, Data Timeliness.

1. Coupling

Coupling is a measure of the degree to which 2 modules are dependent on each other (wiki):

coupling is the degree of interdependence between software modules; a measure of how closely connected two routines or modules are; the strength of the relationships between modules.

If you’ve read the previous posts in the series, you already know that one of the most important attributes of modular design is independence. Therefore, it will be easy to guess that coupling is one of the more important criteria in terms of integration style.

2. Complexity

The second criterion for evaluating the Integration Style is its level of complexity. Some integration methods are simple – require little work, are easy to understand and use. However, others are more complicated, require more commitment, knowledge, and discipline.

3. Data Timeliness

The last criterion is the length of time between when one module decides to share some data and other modules have that data. This means how soon after a state change in a given module, the rest of the modules concerned will take this change into account. Of course, the shorter this time, the better.

Now that we know all the most important criteria, let’s move on to the ways of integrating our modules. Let’s discuss 4 Integration Styles: File Transfer, Shared Database Data, Direct Call and Messaging.

File Transfer

The first option is to integrate our modules using a regular file. Such a file must be exported from the source module and imported into the target module. This can happen in 3 ways:
– manual, where the user manually imports/exports
– automatic, where files are imported and exported automatically by systems
– hybrid, where the file is imported/exported automatically on one side and vice versa on the other

Modular Monolith Integration Styles - File Transfer
File Transfer

One of the main tasks of this type of integration is to determine the format of a given file. What is important, it is the only dependency that two modules integrating in this way have. You can visualize it as a really huge message that is carried over by the filesystem. For this reason, it can be assumed that the coupling is very low in this case.

As for the level of complexity of this approach, it can be evaluated as an average. On the one hand, generating a file in a specific format is not difficult in these times. On the other hand, uploading to a shared resource, managing files, handling duplicates, and so on is more complicated and time-consuming.

From a timeliness point of view, modules integration via files is slow (not to mention manual export/import). Most often it is performed in larger batches at some time intervals (so-called batches), often at night. For this reason, the delay can be a day, a week or more.

To be honest, I’ve seen file-sharing integration many, many times between systems, and probably never in a monolith – which is rather understandable. This Integration Style I have described for the sake of completeness of this topic. The most popular integration method for monoliths is the Shared Database Data.

Shared Database Data

In the EIP book, this method of integration is called Shared Database, but I believe that it is not quite the right name. Sharing the database does not always have to mean sharing data, because modules can store its data in separate tables (most often it is done through database schemas). Therefore, in my opinion, Shared Database Data is a better term.

Modular Monolith Integration Styles - Shared Data
Integration Style: Shared Data

In Shared Database Data modules share a certain set of data in the database. In this way, the data are always integrated and consistent with each other because, generally speaking, they are the same data. If module A writes data to table X, module B can read the data immediately after the database transaction is completed.

The level of complexity of such a solution is very small. Nowadays every application/module needs a database, so there is no need to add anything extra with this approach.

The solution looks perfect at first glance. However, its biggest disadvantage is a very high coupling. By sharing data, modules share their state which couples them together. The high coupling means no autonomy for the module. In addition, one little change to database structure or even data itself can break another module without notice. It implies that each change to the database must be consulted and coordinated. This way database becomes bottle-neck of changes. The whole solution is not evolutionary anymore.

The shared state has another significant disadvantage – it is very hard or even impossible to create one, unified data model which will ensure that the requirements of all modules are met. The attempt to unify most often ends with a very weak, ambiguous model that is difficult to understand, develop and maintain.

To reduce coupling while still maintaining the same level of data timeliness we can use Direct Call.

Direct Call

The third option is to directly call the method of the module we’re integrating with. In this case, we use the encapsulation mechanism. The module exposes only what is needed. The whole behavior is closed in a method. In this way, the state of our module is not exposed to the outside as it is in the case of the Shared Database Data approach. Thanks to this, the caller is not able to break anything from the outside.

Modular Monolith Integration Styles - Direct Call
Integration Styles – Direct Call

Not sharing the data implies that each module has its own data set. It can be the same database broken down by schemas or each module can have even a separate database created in different technology. In a scenario with one database, it is important to keep the data really in isolation. It means no constrains between tables from separate modules and no transactions between them.

Both the caller and the callee should treat each other as external. Both modules will use a different language and have different concepts modeled. Therefore, the Anti-Corruption Layer (ACL) should be applied. On the caller side, it could just be a gateway, on the callee side a facade. Thanks to this, modules encapsulation are kept.

In the case of a distributed system, the Direct Call is known as Remote Procedure Invocation/Call (RPI/RPC). Unfortunately, this technique is very often used in Microservice architecture and can lead to the so-called Distributed Monolith anti-pattern architecture. As the call is always synchronous, we are dealing with temporal coupling. Both caller and calle must be available in the same time. In the case of a monolith, it is not a problem because this is its nature, in the case of microservices it is much worse – it reduces architecture quality attributes like autonomous development and deployment. Read other article in this series about architectural drivers for more details.

The Direct Call Integration Style seems to be a very good choice when it comes to integrating our modules, but has some drawbacks too. First, the call is synchronous, so the caller has to wait for the result. Second, the calling module needs to know about the module it is calling, it has to have a direct dependency. Moreover, it has to know the intent of what it wants to do. Coupling is lower than in Shared Database Data, but still exists. If we want to avoid these drawbacks, we can use the last integration style: Messaging

Messaging

The File Transfer integration style has a great advantage – it does not create dependencies between modules. However, it has a big drawback – the data timeliness in most cases is unacceptable. Messaging does not have this disadvantage. The data timeliness is not so good as in the case of Direct Call because it is asynchronous communication, but it can be safely said that it is very good and acceptable in most cases.

Modular Monolith Integration Styles - Messaging - in memory
Integration Styles – Messaging (in memory)
Modular Monolith Integration Styles - Messaging - separate process
Integration Styles – Messaging (separate process)

Appropriate use of Messaging for the implementation of Event-Driven Architecture causes no dependency between modules. Modules integrate through events. However, these are not Domain Events because a domain event is local and should be encapsulated in a given Bounded Context. Integration Events contain only as much as needed to avoid the so-called Fat Events. Integration Events should be as small as possible, as they are part of the contract made available by the given module. As you know, the smaller the contract, the more stable it is -> the less frequently other modules have to change.

In addition, asynchronous processing, that causes Eventual Consistency, on the other hand supports performance advantages, scales better and it is more reliable.

What are the disadvantages of Messaging? First, due to the nature of asynchronicity, the state of our entire system can be eventually consistent as described above. That is why it is so important to have the boundaries of the modules well defined. This is almost as important as in Microservices architecture, but the advantage of the Modular Monolith architecture is that it is much easier to change these boundaries.

The second disadvantage of Messaging is that it is more complex. To provide asynchronous processing and Event-Driven Architecture, we’re going to need some sort of Event Bus. It can be an in-memory broker or a separate component (eg RabbitMQ). In addition, we will also need job processing mechanisms for internal processing – outbox, inbox, internal commands messages. There is need to write some of this infrastructure code. Fortunately, this is a generic problem – we do it only once and there are a lot of libraries and frameworks which support this.

Comparison

Below I present a comparison of all 3 Integration Styles taking into account 3 criteria – Coupling, Data Timeliness and Complexity.

Comparison - Coupling vs Complexity
Comparison – Coupling vs Complexity
Comparison - Coupling vs Data Timeliness
Comparison – Coupling vs Data Timeliness

What can we deduce from these diagrams? First of all, there is no one perfect style. Fortunately, we can see some heuristics:

1. If the system is very, very simple (essential complexity is low) and you do not care about modularity: choose simplicity and use Shared Database Data style.
2. If the system is complex (essential complexity is high) you must care about modularity. Options are:
– choose Messaging if you prefer highest level of autonomy and eventual consistency between modules is acceptable
– choose Messaging if you care a lot about performance, reliability, scalability
– choose Direct Call if you must have strong consistency, don’t need maximum level of modules autonomy or Data Timeliness is the primary factor.

Additionally, you can mix different styles. Most often, when we are talking about modular architecture, it is best to use Direct Call and Messaging together. Some modules can communicate synchronously and some asynchronously, depending on the need.

Summary

As I mentioned at the beginning, no module, component, or system lives in complete isolation. We must follow the “Divide and Conquer” principle so we divide our solution into smaller parts, but finally – we have to integrate these parts together to create a system.

Let’s summarize all 4 styles again:

File transfer – provides low coupling but has almost always unacceptable data timeliness so it is impractical in the monolith
Shared Database Data – the simplest, quick, but couples modules together
Direct Call – provides lower coupling than Shared Database Data, encapsulates modules, relatively simple
Messaging – ensures the lowest coupling, modularity, autonomy but at the cost of complexity

Which Integration Style to choose? Everything, as usual, “it depends”. However, I hope that I managed to at least to some extent explain what it depends on. No silver bullet, again.

Related Posts

1. Modular Monolith: A Primer
2. Modular Monolith: Architectural Drivers
3. Modular Monolith: Architecture Enforcement
4. How to publish and handle Domain Events
5. Handling Domain Events: Missing Part
6. Processing multiple aggregates – transactional vs eventual consistency