In this article, we will discuss the concept of Modular Monolith Applications, and how they compare with traditional Monoliths and Microservices. We will also explain the different communication patterns we can implement between our modules.

To download the source code for this article, you can visit our GitHub repository.

In the last decade, most enterprise businesses have switched from monolith applications to microservice applications because monolith applications have coupling and scalability issues. However, microservice applications have some downsides such as being expensive to maintain and complex to develop.

What Is a Modular Monolith?

A Modular Monolith is a monolith application where the different business modules are loosely coupled, and each module packs a different set of functionalities that serve this business module:

Modular Monolith Architecture

Modular monolith applications come as a middle ground between traditional monoliths and microservice applications, providing loosely coupled modules that interact with each other using various communication patterns, without all the expenses and complexity shipped with microservice applications.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!
For a deep dive into monoliths, you can check out our article Monolith and Distributed Monolith Architectural Patterns in C#.

Setting Up Our Modular Monolith

Let’s consider an e-commerce application that manages an inventory of items and the orders created. Since we are creating a modular monolith application, we should divide our applications into modules. In our case, we have an inventory module and an order module.

Inventory Module in Our Modular Monolith

Let’s start by implementing the inventory module. First, we introduce a class library project named Inventory.

In the Inventory class library, let’s add our entity:

internal class Item
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public required string Name { get; set; }
    public int Quantity { get; set; }
    public double Price { get; set; }
}

The Item class has four properties: Id, Name, Quantity and Price. We set our entities as internal to prevent any coupling with other modules.

To learn more about the internal access modifier, check out our article C# Access Modifiers.

Next, we create a DTO class:

public class ItemDto
{
    public Guid Id { get; set; }
    public required string Name { get; set; }
    public int Quantity { get; set; }
    public bool IsOutOfStock => Quantity == 0;
    public double Price { get; set; }
}

In addition to the Item class’s properties, our ItemDto also contains an IsOutOfStock property which checks if the Quantity property is equal to zero.

To gain a better understanding of DTOs, feel free to check out our great article What is the Difference Between a DTO and a POCO?

Now, we create an ItemService class that will hold the business logic related to the inventory module:

internal class ItemService(IItemRepository itemRepository) : IItemService
{
    public ItemDto? Get(Guid id)
    {
        return itemRepository.Get(id);
    }

    public List<ItemDto> GetAll()
    {
        return itemRepository.GetAll();
    }

    public bool UpdateQuantity(UpdateQuantityDto updateQuantityDto)
    {
        return itemRepository.UpdateQuantity(updateQuantityDto);
    }
}

Here, we implement the Get(), GetAll(), and UpdateQuantity() methods and use the IItemRepository interface to alter the database.

Finally, we create our ItemController class:

[Route("api/[controller]")]
[ApiController]
public class ItemController(IItemService itemService) : ControllerBase
{
    [HttpGet("{id:guid}")]
    public IActionResult Get(Guid id)
    {
        var item = itemService.Get(id);

        return item is null ?
            NotFound(id) : 
            Ok(item);
    }

    [HttpGet]
    public IActionResult GetAll()
    {
        var items = itemService.GetAll();

        return Ok(items);
    }
}

Our ItemController contains two endpoints: Get(), which fetches a single item by its Id property, and GetAll(), which fetches all the items.

The Order Module

Now let’s implement the Order module.Ā Similar to the Inventory module,we create a new class library project Order.

In the Order class library, let’s create our entities:

internal class Order
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public List<OrderItem> Items { get; set; } = [];
}

internalĀ classĀ OrderItemĀ 
{Ā 
    public Guid Id { get; set; } = Guid.NewGuid(); 
    public Guid ItemId { get; set; } 
    public int Quantity { get; set; } 
    public double PricePerUnit { get; set; } 
    publicĀ doubleĀ TotalĀ {Ā get;Ā set;Ā }Ā 
}

Our Order class consists of Id and Items properties and the OrderItem class consists of Id, ItemId, Quantity, PricePerUnit, and Total properties.

Then we create our DTO classes:

public class OrderDto
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public double Total { get; set; }
    public List<OrderItemDto> Items { get; set; } = [];
}

public class OrderItemDto
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public Guid ItemId { get; set; }
    public string ItemName { get; set; } = string.Empty;
    public int Quantity { get; set; }
    public double PricePerUnit { get; set; }
    public double Total { get; set; }
}

Similar to the Order class, our OrderDto class includes the same properties and an extra Total property. Our OrderItemDto class introduces an ItemName property in addition to the same properties as the OrderItem class.

Next, let’s create our OrderService class:

public class OrderService(IOrderRepository orderRepository) : IOrderService
{
    public void Add(OrderDto orderDto)
    {
        orderRepository.Add(orderDto);
    }

    public List<OrderDto> GetAll()
    {
        var orders = orderRepository.GetAll();

        return orders;
    }
}

Our OrderService class implements two methods, Add() and GetAll(), which add an order to the database and retrieves all orders respectively.

Finally, let’s implement our OrderController class:

[Route("api/[controller]")]
[ApiController]
public class OrderController(IOrderService orderService) : ControllerBase
{
    [HttpPost]
    public IActionResult Add(OrderViewModel viewModel)
    {
        try
        {
            orderService.Add(new OrderDto
            {
                Items = viewModel.Items.Select(i => new OrderItemDto
                {
                    ItemId = i.ItemId,
                    Quantity = i.Quantity
                }).ToList()
            });

            return Ok();
        }
        catch(Exception ex) 
        { 
            return BadRequest(ex.Message);
        }
    }

    [HttpGet]
    public IActionResult GetAll()
    {
        try
        {
            var orders = orderService.GetAll();

            return Ok(orders);
        }
        catch(Exception ex)
        {
            return BadRequest(ex.Message);
        }
    }
}

Our OrderController contains two endpoints, Add() and GetAll(), which makes calls to the matching OrderService methods.

As we can see in our solution, the Order and Inventory modules are completely separated without any coupling. This way, we ensure that we separate the modules in a loosely coupled manner, making it easier to switch to microservices if needed:

Modular Web Application

Traditional Monolith vs Modular Monolith

Traditional and Modular Monolith applications consist of a single deployment comprising different business modules. The key difference is that, unlike in a traditional monolith application where all the business modules are tightly coupled, in a modular monolith, the code base consists of separate modules that are loosely coupled and independent.

Traditional Monoliths are effective in small-scale applications that do not contain a lot of modules. On the other hand, Modular monoliths better serve mid to enterprise-level applications that contain several modules.

Let’s take a look at the differences between traditional and modular monoliths across different aspects such as architecture, codebase, resilience, and technology stack:

AspectTraditional MonolithModular Monolith
ArchitectureFollows a single-tier architecture where the modules are tightly coupled.Follows a modular architecture where each component is organized to a separate modules that are loosely coupled.
CodebaseConsists of a single codebase that contains all the functionality.Each module has its own codebase and functionality.
MaintenanceUpdates can be risky since changes might has unintended consequences.Easier to update and maintain due to modular design.
Resilience to ChangeChanges may require extensive re-architecting and refactoring.New modules can be easily introduced without re-architecting.

Microservices vs Modular Monolith

Although microservice and modular monolith applications may seem similar, the two architectures have their set of differences. While we deploy the different modules in a microservice application separately, the modules in a modular monolith are packed in a single service.

If you want to learn more about microservices, check out our article Using the API Gateway Pattern In .NET to Encapsulate Microservices.

We typically use Microservices where we favor scalability and isolation over simplicity and cost-effectiveness.

Let’s cover the differences between microservices and modular monoliths across different aspects such as simplicity, performance, deployment, and cost:

AspectMicroservicesModular Monolith
SimplicityTypically used for more complex projects.Tends to be simpler in development and deployment
PerformanceMight provide slower performance due to increased network overhead cause by inter-service communication.Might provide better performance due to reduced network overhead.
DeploymentRequires a more complex deployment and orchestration tools to manage the different services.Easier to deploy since the deployment consists of a single unit.
CostUsually costs more due to higher infrastructure costs for managing the deployments and network.Requires lower initial cost as it involves a single deployment unit. However cost may increase when scaling the application.

Communication Patterns in Modular Monoliths

Since the modules in a modular monolith are loosely coupled, we need different means of communication so that the modules can interact. We can achieve this either by using synchronous communication or asynchronous communication, each with its positives and negatives.

Synchronous Communication

Synchronous communication is when the client application waits for a response from the server after sending a request. We use synchronous communication when the response impacts our process. Examples of synchronous communication protocols include REST API, gRPC, and SOAP.

Let’s create an InventoryRestClient class that handles the REST API synchronous calls using the IHttpClientFactory interface. We should note that although we are using the async and await keywords, from the perspective of the client and server, this is still considered synchronous communication because the client has to wait for the server to reply to continue its work:

public class InventoryRestClient(
      IHttpClientFactory clientFactory, 
      IConfiguration configuration) : IInventoryRestClient
{
    public async Task<ItemDto?> GetItem(Guid id)
    {
        var httpClient = clientFactory.CreateClient();
        httpClient.BaseAddress = new Uri(configuration["BaseAddress"]!);
        var result = await httpClient.GetAsync($"/api/Item/{id}");

        result.EnsureSuccessStatusCode();

        return await result.Content.ReadFromJsonAsync<ItemDto?>();
    }
}

Our GetItem() method creates a synchronous call to the Inventory module and retrieves a single item as JSON, and then deserializes it into our ItemDto class.

Now, let’s update our OrderService class to make use of the IInventoryRestClient interface:

public class OrderService(
    IOrderRepository orderRepository,
    IInventoryRestClient inventoryRestClient) : IOrderService
{
    public async Task AddAsync(OrderDto orderDto)
    {
        foreach(var itemModel in orderDto.Items)
        {
            var item = await inventoryRestClient.GetItem(itemModel.ItemId) ?? 
                throw new InvalidOperationException(
                    $"The requested item '{itemModel.ItemId} was not found.'");

            if (item.Quantity < itemModel.Quantity)
                throw new InvalidOperationException(
                    $"There isn't enough stock for item '{itemModel.ItemId}'.");

            itemModel.PricePerUnit = item.Price;
            itemModel.Total = itemModel.Quantity * item.Price;
        }

        orderDto.Total = orderDto.Items.Sum(i => i.Total);

        orderRepository.Add(orderDto);
    }

    public async Task<List<OrderDto>> GetAllAsync()
    {
        var orders = orderRepository.GetAll();

        foreach(var orderItem in orders.SelectMany(i => i.Items))
        {
            var item = await inventoryRestClient.GetItem(orderItem.ItemId) ??
                throw new InvalidOperationException(
                    $"The requested item '{orderItem.ItemId} was not found.'");
            orderItem.ItemName = item.Name;
        }

        return orders;
    }
}

In our AddAsync() method, we loop over the Items list, and for each item, we get its respective item from the Inventory module using the IInventoryRestClient service to calculate the total price. Also, in our GetAllAsync() method, we loop over the Items list to set the name of each item.

Asynchronous Communication

Asynchronous communication is when the client application sends a request to the server and continues executing regardless of whether the server successfully processed the message. This is helpful when the process does not depend on the success of the request. Examples of asynchronous communication protocols include AMQP and MQTT.

One of the most used tools for asynchronous communication is RabbitMQ which uses the Advanced Message Queuing Protocol (AMQP).Ā RabbitMQ uses a Pub-Sub design pattern where the client (Publisher) passes a message through a queue to the server (Subscriber/Consumer).

The plan is to pass a message from the Order module to the Inventory module asynchronously to adjust the item stock after creating an order.

First, let’s define our RabbitMqConsumer class:

internal class RabbitMqConsumer(
    IServiceScopeFactory scopeFactory, 
    IOptions<RabbitMqConfiguration> rabbitMqConfiguration, 
    IRabbitMqConnectionManager rabbitMqConnectionManager) : IRabbitMqConsumer
{   
    public void Consume()
    {
        var queueName = rabbitMqConfiguration.QueueName;
        rabbitMqConnectionManager.Channel.QueueDeclare(
            queue: queueName,
            durable: true,
            exclusive: true,
            autoDelete: false);

        var consumer = new EventingBasicConsumer(_rabbitMqConnectionManager.Channel);

        consumer.Received += HandleEventAsync;

        rabbitMqConnectionManager.Channel.BasicConsume(
            queue: queueName, 
            autoAck: true,
            consumer: consumer);
    }
}

The Consume() method initializes our message queue, registers our callback function with the consumer.Received event, and starts consuming from the queue.

Next, we define our callback function inside our RabbitMqConsumer class:

private void HandleEventAsync(object? model, BasicDeliverEventArgs ea)
{
    var body = ea.Body.ToArray();
    var message = Encoding.UTF8.GetString(body);
    var updateQuantityDto = JsonSerializer.Deserialize<UpdateQuantityDto>(message);

    if (updateQuantityDto is null)
        return;

    UpdateItemQuantity(updateQuantityDto);
}

private void UpdateItemQuantity(UpdateQuantityDto updateQuantityDto)
{
    var scope = scopeFactory.CreateScope();
    var itemService = scope.ServiceProvider.GetRequiredService<IItemService>();

    itemService.UpdateQuantity(updateQuantityDto);
}

Our HandleEventAsync() method is the function we will invoke whenever we consume a message. It parses the event body and then calls the UpdateItemQuantity() method.

The next step is to produce a message from our Order module every time an order is created.

We can achieve this by calling the UpdateQuantity() method from the AddAsync() method in the OrderService class:

public async Task AddAsync(OrderDto orderDto)
{
    foreach(var itemModel in orderDto.Items)
    {
        var item = await inventoryRestClient.GetItem(itemModel.ItemId) ?? 
            throw new InvalidOperationException(
              $"The requested item '{itemModel.ItemId} was not found.'");

        if (item.Quantity < itemModel.Quantity)
            throw new InvalidOperationException(
              $"There isn't enough stock for item '{itemModel.ItemId}'.");

        itemModel.PricePerUnit = item.Price;
        itemModel.Total = itemModel.Quantity * item.Price;

        inventoryRabbitMqClient.UpdateQuantity(new Inventory.Models.UpdateQuantityDto 
        { 
            ItemId = itemModel.ItemId, 
            Amount = itemModel.Quantity * -1
        });
    }

    orderDto.Total = orderDto.Items.Sum(i => i.Total);

    orderRepository.Add(orderDto);
}

The UpdateQuantity() method will publish our message to the queue to be processed by the Inventory module.

Using our RabbitMQ implementation, we pass a message asynchronously from the Order module to the Inventory module. After passing the message, the Order module will continue executing regardless of whether the Inventory module received it successfully.

Conclusion

Modular monolith applications are becoming widely used due to their simplicity and scalability. Although they can’t completely replace traditional monolith or microservice applications, they have a great use case that can be useful for different businesses.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!