Blog
navigate_next
Technology
Modular Monolith
Gaurav Sharma
November 20, 2024

Briefing:

So, the microservices honeymoon phase is over now. I'm not suggesting a stalemate, but rather that the initial excitement has subsided. For the past 6 or 7 years, at every conference—whether online or offline—everyone has been talking about microservices and how effectively they address the challenges faced by monoliths (often described as a "big ball of mud").

"Big ball of mud" refers to the tightly coupled nature of a monolith, where even minor changes can trigger a chain reaction of major bugs in production.

An image of a bunch of people trying to move a big ball of rope and mud in a team sportm

Microservices address this problem, but at what cost?

Illustration of a Services Constellation

Image: <span class="pink">Services Constellation</span>

While microservices address significant challenges associated with monolithic architectures, they also introduce their own set of complexities.

In the image above, each blue dot represents an independent microservice, and every line connecting them signifies an RFC. Managing this intricate web of services is a challenging task in itself.

Microservices are complex because when one service fails, it can disrupt others connected to it. Debugging becomes difficult, as it requires checking multiple services and their logs. Additionally, maintaining data consistency across services is a tricky endeavor, and being on-call often means dealing with an entire network of interconnected issues rather than focusing on a single application.

Microservices sound excellent in theory—they promise scalability, flexibility, and independent deployment, but in reality they are the perfect example of overengineering if not implemented properly.Never consider a microservice architecture unless your application is handling millions of users.

The modular monolith architecture style combines the best of monoliths and microservices to bridge the gap. Instead of having one massive complex and hard-to-understand codebase or dealing with the overhead of microservices, the modular monolith breaks the application into smaller, manageable modules within a single system.

The simplest way to describe a modular monolith is as follows:

The simplest way to describe a modular monolith is as follows: Physical Architecture of Monolith + Logical Architecture of Microsevices

💡 Experienced engineers' reimagining of modular monoliths is hardly surprising. In fact, it's not new; it's a time-tested approach that simply faded into the background for a while—and now it's back in the spotlight.

<span class="pink">The challenge before was that developers could easily break module boundaries and create unwanted dependencies, as there was no enforcement.The code would still work, but the modular structure would gradually break down.</span>

Thanks to recent technological advancements, developers now have the tools to enforce module boundaries effectively, preventing unwanted dependencies and ensuring the modular structure remains robust and sustainable over time.

There are various methods to enforce module boundaries, such as the new Spring Modulith dependency in a Spring Boot application, which we will discuss later. The system actively prevents these mistakes. If you try to access code you shouldn't or create incorrect dependencies between modules, your tests will fail, ensuring the modular structure stays clean and maintainable over time.

What distinguishes modular monolith from microservices is how it is deployed.

Microservices deploy as independent services, often requiring complex orchestration tools like Kubernetes, whereas the modular monolith maintains a single deployment unit.

This means you still develop and manage the application as one cohesive entity, but you structure it internally into smaller, self-contained modules.
Snapshot of Elon's Tweet from 2022 talking about "turning off" microservices bloatware in Twitter, of all of which only 20% were actually needed for twitter to work.

Microservices overhead—as Elon Musk tweeted on 14th Nov,2022, they had about 1200 RPCs (remote procedure calls) between their microservices and discovered that less than 20% of their microservices were actually needed for Twitter to work. This kind of over-engineering and unnecessary service splitting is exactly what modular monoliths try to prevent by keeping everything in a single deployable unit with clear internal boundaries.

Some common types of monolithic architecture:

Diagram of the monolith architecture
Diagram of the modular monolith architecture
Diagram of the distributed monolith architecture

A monolith tightly couples all layers—UI, business logic, and data—into a single deployable unit. While simple to manage, it becomes difficult to scale or update as the application grows.

The modular monolith improves on this by dividing the application into distinct modules with clear boundaries. Despite communicating through APIs, these modules deploy as a single unit, providing improved maintainability without increasing deployment complexity.

The distributed monolith separates modules into independent deployments but often retains tight coupling, creating operational challenges similar to a monolith with added complexity.

The modular monolith balances modularity and clean code with the simplicity of single deployment, making it the perfect choice for scalable and maintainable applications if simple monoliths are becoming increasingly challenging to maintain.

Number of Deployment Units Required for Different Kinds of Architecture:

<span class="pink">Remember: As deployment units increase , so do the complexity, costs, and maintenance overhead of the system.</span>

Below is an example of a general e-commerce application with code structured using different architectural styles.

Example graph of a general e-commerce application with code structured using different architectural styles.

The total number of deployment units per architecture for an e-commerce application is:

  • Monolith: 1 unit
  • Modular Monolith: 1 unit
  • Microservices: 6 units (one each for Payments, Reviews, Users, Bookings, Disputes, and Notifications)
  • Distributed Monolith: 4 units (same monolithic code deployed multiple times)

Modules: The Heart of Modular Monolith-Based Applications:

<span class="pink">Modules form the core of modular monolith applications</span>. A module handles a specific business function with its own logic and data, keeping its internal workings private while offering clear interfaces for other modules to interact with.

In a single deployable application, these modules work together but remain separate, making the code organized and simple to maintain. Different teams can work on different modules without interfering with each other's work.

Since modules are already separated by business functions, transitioning to microservices later becomes simpler if needed, as the logical separation already exists.

Definition of a "Module" and it's features

technical view of a module:

Technical view diagram of a module

An architecture is said to be modular monolith when they follow these:

A modular monolith aims to reduce tight coupling by following three key principles:

  • First, each module is logically separated from others, meaning they operate as distinct units within the same codebase.
  • Second, modules communicate only through well-defined interfaces—either via public APIs or event systems—rather than directly accessing each other's internals.
  • Finally, each module maintains its own separate database schema or storage, preventing data dependencies between modules.

Visual representation of a "Tight coupling"

Tight coupling in traditional monoliths creates a fragile system. When different parts of the code are heavily interlinked, changes in one area can cause unexpected failures elsewhere. For example, updating a service method might break multiple other services that directly depend on its implementation details.

Spring Modulith addresses this by promoting loose coupling between modules. Each module operates as a self-contained unit with minimal dependencies on other modules. When modules need to interact, they do so through well-defined interfaces rather than accessing internal implementations directly.

This modular approach means that when you need to modify code within a module, the changes remain contained. Other modules won't break because they only interact with your module's stable public interface, not its internal implementation.

Suppose for an e-commerce commerce application, if you refactor the order processing logic, the inventory module remains unaffected as long as the public contract stays the same.

💡 <span class="pink"> Spring Modulith enforces the boundaries between modules through compile-time checks and testing support. It helps developers identify and prevent unwanted dependencies between modules early in development. </span>

Domain-driven design(DDD):

Diagrammatic representation of Domain-Driven-Design

DDD sits at the heart of  modular monoliths along side modules. Domain-Driven Design is about building software that closely matches how a business works. It uses the same terms and concepts that business experts use, and organizes code around business domains rather than technical layers like controllers, service, repository, models etc.

The goal is to have software that business people can understand and developers can maintain easily because it reflects real-world business operations.

The business logic defines how we split our system into modules. Each module represents a distinct business capability, with its own rules and data. This makes the code structure match real business operations.

Like in  an e-commerce app built using modular monolith, DDD shapes the structure. For example, orders become an Orders module handling all order-related logic, while customers become a Customers module managing customer data and operations. Each module owns its own business rules and data.

<span class="pink">Since this is not a DDD article, more details are not needed.</span>

But how do these modules connect with each other?

In a modular monolith, communication happens at two levels:

Internal Communication (Between Modules):

  • Modules interact through well-defined public APIs or internal event systems such as Spring Events for a Spring Boot application.
  • These calls happen in-memory since modules are part of the same application
  • No network calls are needed, unlike microservices

External Communication (With Outside Systems):

  • When modules need to talk to external systems, they also use the same event system through message brokers: publish event , consume event.
  • Common options include Kafka, RabbitMQ, or ActiveMQ for asynchronous communication
  • REST APIs for synchronous communication with external services
  • This creates a clear boundary between internal and external interactions

Diagram showing internal communication between modules and external communication with database

Different modules communicate with each other using public APIs.

Public API:

The public API approach involves exposing a set of well-defined endpoints or interfaces that allow modules to communicate with each other.

Modules access functionality from other modules by making calls to these public APIs, ensuring a clean separation of concerns and preventing direct inter-module dependencies.

If we maintain the public API contract, we can make changes within a module without affecting the rest of the application.

Events:

Logos of Kafka, Rabbit MQ, Apache Active MQ, and Events in Spring

The events mechanism enables modules to publish and subscribe to events, thereby enabling a more event-driven style of communication. Modules have the ability to raise specific events that other interested modules can then consume, facilitating loosely coupled interactions.

This approach promotes a more reactive and scalable architecture, as modules can respond to events asynchronously without tightly coupling the sender and receiver.

Spring offers a variety of messaging tools and mechanisms for demonstrating events.ile Kafka and RabbitMQ are common choices for asynchronous communication.

Spring Events:

Spring events also provide an internal event-publishing mechanism, ideal for modular applications where modules need to communicate inside the application without tight coupling.

Conventional Approach vs. Modular Approach Project Structure:

Snapshot of a conventional project structure

The conventional monolithic approach structures code by technical concerns. Applications are divided into layers like controllers, services, repositories, and models. Each layer contains code related to all business features. This organization makes it simple to locate components by their technical role but creates tight coupling between business features since they share the same layers.

Structuring the code modular way

Snapshot of a modular project structure

In contrast, the modular monolith organizes code around business domains. Instead of technical layers, the application is divided into business modules like customer, order, inventory, and payments. Each module encapsulates all its technical components—controllers, services, and repositories—within its domain boundary. This creates a clear separation between different business functionalities.

The key difference lies in how these approaches handle dependencies and change. In conventional monoliths, changes to a single business feature often require modifications across multiple technical layers. This increases the risk of unintended side effects and makes it harder for multiple teams to work simultaneously.

Modular monoliths solve these issues by establishing clear boundaries between business domains. Each module operates independently, with explicit interfaces for inter-module communication. Teams can work on different modules without stepping on each other's toes, and changes remain contained within module boundaries.

This modular structure also provides better maintainability and evolution paths. Code related to specific business capabilities stays together, making it easier to understand and modify. If needed, modules can be extracted into separate services more easily than in conventional monoliths, providing a natural path toward microservice architecture.

Conclusion

When it comes to choosing the architecture for your application, discussions can go on all day. However, my personal recommendation would be to start with a monolithic architecture.

If, after a few months, the project begins to show signs of becoming complex and difficult to manage (a "big ball of mud"), refactor it into a modular monolith.

As the application scales further, start by converting the most heavily utilized module into a microservice. If multiple modules begin to face high demand and require frequent updates, it may be a good time to transition the entire modular monolith into a set of small, independent microservices.

Cheers!

Happy Coding

References:

Gaurav Sharma
November 20, 2024
Use Unlogged to
mock instantly
record and replay methods
mock instantly
Install Plugin