Modular Architecture

Modular Monolith Architecture
with .NET

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.

What is Modular Monolith Architecture?

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.

Key Characteristics

Single Deployment Unit

The entire application is deployed as one unit, simplifying deployment processes and reducing operational complexity.

Internal Modularity

Although it is a single application, it is organized into separate modules, each responsible for a specific domain or functionality.

Defined Interfaces

Modules communicate through explicit interfaces, ensuring clear boundaries and minimizing interdependencies.

Advantages

Simplified Development and Deployment

Maintaining a single codebase and deployment pipeline reduces complexity compared to microservices architectures.

Enhanced Maintainability

Clear module boundaries facilitate easier updates and maintenance, as changes can be made within a module without affecting others.

Scalability Potential

While starting as a monolith, the modular structure allows individual modules to be extracted into microservices if scaling needs to evolve.

Parallel Development

Multiple teams and developers can work on different modules in parallel without affecting each other.

Comparison with Other Architectures

Traditional Monoliths

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

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.

Challenges of a Modular Application Development

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 Boundaries of Modules

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.

Complexity of Module Interactions

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.

Designing Robust Interfaces

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.

Optimizing Performance

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

Testing and Quality Assurance

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.

Learning Curve and Team Coordination

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.

Who is Modular Application Development Suitable For?

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.

Starting with a Monolith-first for Microservice Architecture

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.

Designing a Modular Monolith Solution

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.

The Database Architecture

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.

Single (Shared) Database Approach

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.

Advantages of the single (shared) database approach:
  • It is possible to perform multi-module table JOIN queries to prepare reporting/dashboard pages. However, in that case, the reporting module will be coupled with the related modules’ internal data structures. Be ready to deal with changes that affect your reporting module. Instead, you can consider creating a reporting database that is seeded by other modules’ databases asynchronously.
  • It is easy to ensure database-level transactions for multi-module data changes. However, keep in mind that this kind of multi-module transactional data manipulation operation couples these modules’ databases and makes them difficult to separate later. Instead, use events to implement a change triggered by a change in another module. We will come to that topic later.

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.

Separate Databases Approach

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:

Advantages of separate databases:
  • Better ensures data isolation. It is not easy to accidentally access data from other modules.
  • You can use the best DBMS (Database Management System) technology for each module.
  • A module’s database can be scaled independently from the other modules.
  • It is more microservice-ready since that is the de facto approach for a microservice architecture.

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.

Monorepo vs Multirepo

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.

The Monorepo Approach

If you implement the following structure, then you use the monorepo approach;

  • The source code of all the modules is stored in a single repository.
  • Modules depend on each other with local project references.
  • All the modules are in the same version and deployed together.

Here are the pros of the monorepo approach;

  • Access to changes immediately: When you make a change (bugfix or new feature), you can immediately use it in other modules. This dramatically reduces the integration efforts of multiple modules. You don’t need to wait for the other module to be versioned and deployed to use its new features.
  • Implementing backward compatibility is unnecessary since all the modules use the latest versions of other modules. If you make breaking changes (on the module’s service interfaces), you can easily detect the problem, fix the depending code in the other modules and commit the changes together.

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.

The Multirepo Approach

In a multirepo approach,

  • Every module is located in a separate Git repository.
  • Modules use each other via package (e.g., NuGet, NPM) references, and each module uses the other’s specific version.
  • Modules are versioned and deployed separately.

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:

  • You want to develop, test and deploy your modules independently.
  • You have many teams (or departments) specialized in different modules.
  • Modules are mature, boundaries are stable, and integration is limited.

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.

Structure of a Module Codebase

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:
Implementation

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.

Interfaces

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:

UI (User Interface)

Contains UI pages and components related to the module.

Tests

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

Communication Between Modules

Once you create your modules, you can establish dependencies (project/package references) between the modules and communicate them.

Inter-Module Dependencies

The following diagram shows that the two modules depend on each other:

  • The 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.
  • The 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.

Integration Services

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:

  • Read only: Integration services are mainly for reading/querying data from other modules. They are not for making changes in other modules. If you need to make a change in the other module, consider using integration events.
  • Caching: Use caching if you frequently request the same data from a module. If multiple modules use the same module for querying purposes, it can be a bottleneck in the system and decrease the overall performance.
  • Do not reuse application services: Application services are designed to be used by the presentation layer. They implement use cases of the application. Do not reuse them for integration purposes. Otherwise, changes done to the UI may break your integrations or changes in the integration logic may break your UI layer. It is best to follow the Single Responsibility Principle and create separate integration services for integration purposes, even if the service logic is initially similar to the application service.

Integration Events

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.

Building a Modular User Interface

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:

Ignore modularity

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.

Modular UI

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.

Need for an Infrastructure

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.

Base Classes & Utilities

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.

Automating Cross-Cutting Concerns

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.

Abstracting 3-rd Party Libraries

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;

  • Simplifying and standardizing the configuration and usage of the library and service will make it easy to change the configuration at a single point. Also, the application code will be much simpler to implement and will not care about the details of the library API.
  • When you need to change or upgrade a library and service, it will be much easier since the library usage code is not distributed throughout your application.
  • Mocking the library access code in your unit tests will be possible.

So, abstraction and integration code for such libraries and services should also be located in some shared libraries.

Enterprise Building Blocks

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.

The Infrastructure Team

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.

Modular Applications with the ABP Platform

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.

ABP: A Modular Framework

The core ABP Framework is open-source and provides the fundamental infrastructure for modular development.

The Infrastructure for All Your Needs

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.

Building Module Packages

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

Communication Infrastructure

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.

Modularity on User Interface

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 Infrastructure

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.

Pre-Built Application Modules

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: Architect and Build Modular Applications

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.


What Customers Say About the ABP Platform?

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

Phillip Schulte
Phillip Schulte
P&M Agentur

Get Started with Your Modular Application Today

Use the following resources to start building your modular monolithic application today using the ABP Platform:

External Resources and Further Reading

Use the following resources to start building your modular monolithic application today using the ABP Platform: