Modular Monoliths Explained: A Beginner’s Journey

Table of contents
- Why Should You Care?
- Introduction
- Challenges I Faced & How I Overcame Them
- How to Structure Your Learning?
- What Will Help You Learn Faster?
- My Learning Approach (Step-by-Step Guide)
- UNDERSTANDING MODULAR DESIGN
- Modularization vs. Modularity
- COHESION vs. COUPLING
- Separation of Concerns (SoC)
- Packaging & Dependency Management in Modular Codebases
- Dependency Injection (DI) for Modular Codebases
- Inter-Module Communication in a Modular Monolith
- FINAL COMPARISON
- MONOLITH vs MODULAR MONOLITH vs MICROSERVICES
- Spring Modulith – What’s Next?
- Final Thoughts

Why Should You Care?
Modern software architecture has evolved significantly, and microservices have become the default buzzword. But do you really need microservices?
In many cases, a well-structured modular monolith can be just as effective, if not better.
Here’s why modular monoliths matter:
Less complexity: Microservices introduce challenges like distributed systems, network latency, and service coordination. Modular monoliths let you avoid those headaches while still keeping code modular and scalable.
Easier to manage: Unlike traditional monoliths, modular monoliths provide clean boundaries between modules, making development and maintenance smoother.
Microservices-ready: If needed, you can gradually evolve a modular monolith into microservices—but only when the business truly requires it.
If you're someone exploring software architecture and looking for a structured, scalable approach without immediately jumping to microservices, then modular monoliths are worth learning.
Introduction
I started building backend applications with microservices (obviously, I knew monolith architecture, but I mostly used microservices).
When I decided to learn about modular monolith architecture, I got confused and faced a few challenges.
This blog documents my journey as a beginner—what I learned, what problems I faced, and how I tackled them. If you're new to modular monoliths, this might help you get started.
Challenges I Faced & How I Overcame Them
Forget About Microservices (For Now)
- Take microservices out of your mind while learning about modular monoliths. Constantly comparing modular monoliths to microservices makes everything confusing—monolith, modular monolith, and microservices all get tangled up.
Understand That Comparisons Are Mostly Between Modular Monolith & Traditional Monolith
How have monoliths improved?
How can we refine a traditional monolith into something better without jumping to microservices?
Learn Modular Structures/Modular Software Design First
Before even touching modular monoliths, understand:
Clean Architecture Package Structure
Domain-Driven Design (DDD)
Dependency Inversion Principle (DIP)
Once you understand these, modular monolith architecture will make more sense.
How to Structure Your Learning?
It’s up to you, but here’s how I approached it and my thought process:
Shift Your Mindset
Think : “If I don’t break my system into microservices, how can I organize and refine it?”
Modular principles (cohesion, separation of concerns (SoC), encapsulation, DDD, etc.)
How modules communicate inside a single codebase (well-defined APIs, package structures, etc.)
How teams can work on different modules without interfering with each other (clear ownership of modules)
Revisit the Problems of Traditional Monoliths
i. Issues With Traditional Monoliths:
As the codebase grows, it becomes tangled and difficult to manage.
Developers interfere with each other’s work, making team collaboration challenging.
Scaling individual components is difficult.
Deployment risk: A single broken feature can bring the entire application down.
ii. Understand Modular Structure Problems :
It may sound similar to modular monoliths, but they are different.
Traditional modular structures break the code into self-contained modules, but these modules may still be tightly coupled.
Modules can have deep interdependencies, making maintenance harder.
Module boundaries may not be strictly enforced, leading to blurred separation.
Instead of actual isolation, it may just feel like better folder organization.
Modules communicate through direct method calls, which can introduce tight coupling.
Now, Dive Into the Modular Monolith Approach
Properly enforces module boundaries (similar to microservices but within the same codebase).
Reduces cross-module dependencies.
Allows independent development and testing at the module level.
Modules communicate through explicit contracts (interfaces, events, or APIs) instead of direct method calls.
Once You’ve Understood Modular Monoliths, Explore:
Why are microservices considered better?
When do we actually need microservices?
What are the trade-offs of each approach?
What Will Help You Learn Faster?
Before diving into modular monoliths, having knowledge of the following will be beneficial:
SOLID Principles (Check out my in-depth blog: SOLID Principles – A Clarity Journey)
Design Patterns (I recommend Head First Design Patterns by Eric Freeman and Elisabeth Robson)
My Learning Approach (Step-by-Step Guide)
Core Principles of Modular Design (Foundation)
High cohesion & low coupling
Encapsulation
Separation of concerns (SoC)
Dependency Inversion Principle (DIP)
Packaging & Dependency Management in Modular Codebases
Package structures (Java, Spring, or your language of choice)
Dependency Injection (DI) in modular codebases
Module Communication Patterns
Inter-module communication
API calls
Direct calls
Shared libraries vs. separate modules
Key Questions to Ask Yourself
How do modular structures help scale a monolith?
When do modular monoliths fail and require microservices?
How do you identify when microservices are necessary?
UNDERSTANDING MODULAR DESIGN
Modularization vs. Modularity
→ MODULARIZATION (The Process)
Modularization is the process of breaking a software system into independent, self-contained modules.
It involves structuring a system into smaller, logically grouped components that function independently while contributing to the larger system.
Example: In a Java project, structuring features like Order Service and Cart Service into separate modules is an example of modularization.
In short, modularization is the process of making a system modular, but it doesn't necessarily mean breaking it into independent deployable services like microservices.
→ MODULARITY (The Property)
Modularity refers to how well a system's components can be separated and recombined without breaking the overall functionality.
Example: In a car, different parts like the brake system and engine can be designed, replaced, or improved independently—making them modular.
A modular codebase means components (modules) can be developed, tested, and maintained separately while still working together as a unified system.
→ MODULES
Modules are the building blocks of a modular system.
Each module is a self-contained unit responsible for a specific part of the system.
A module should:
Be self-contained (handle its own functionality).
Have clearly defined interfaces to communicate with other modules.
Be designed to perform a specific task.
Think of a module as a mini-program that handles a specific responsibility within the larger system.
Key Components of a Module Each module consists of three main parts:
Interface → Defines how the module interacts with the outside world.
Implementation → The actual code and logic inside the module (hidden from external modules).
Data Structures → The internal data the module needs to perform its tasks.
Subsystem: A group of modules that work together to perform a specific task within the overall system.
- But how much should we modularize? (Think about it.)
……………………
COHESION vs. COUPLING
→ COHESION
Cohesion refers to how closely related and focused the elements inside a module are.
It ensures that each module has a single, well-defined purpose.
High Cohesion → The module’s elements work together tightly and focus on a single responsibility.
Low Cohesion → The module handles multiple unrelated responsibilities, leading to a messy and hard-to-maintain system.
Cohesion is an intra-module concept—it defines the relationship within a module.
→ COUPLING
Coupling refers to how dependent modules are on each other.
High Coupling → Modules are tightly connected, meaning a change in one module affects others.
Low Coupling → Modules are loosely connected, meaning they can be modified independently without breaking the system.
Coupling is an inter-module concept—it defines the relationship between modules.
Our goal is to achieve high cohesion and low coupling for a scalable, maintainable system.
If you want to read and learn more about Modularization, Cohesion and Coupling, you can check out -
……………………
Separation of Concerns (SoC)
(i) Concerns
A concern refers to a feature or behavior specified as part of a requirement.
It is an aspect or functionality that needs to be isolated based on specific criteria.
(ii) Separation
- When we separate different concerns within a system, it is called Separation of Concerns (SoC).
(iii) Why SoC?
Allows independent testing of modules.
Simplifies testing, making it less complex.
Enables easy refactoring and maintenance.
→ Example: Food Ordering App (SoC Applied) A food ordering app has different concerns, such as:
User Interface Concern – Manages the UI (React/Angular frontend).
Business Logic Concern – Handles orders, payments, and restaurant operations (Backend - Spring Boot).
Data Storage Concern – Stores users, orders, and menu items (Database - MySQL).
Each concern is separated, making the system modular and scalable.
If I tweak the SOC (Separation of Concerns) concept my way, I’d say it groups similar concerns together.
Group similar concerns together (e.g., all business logic in the service layer, all data access in the repository layer).
(Do not confuse this with microservices.)
Project Structure (SoC Applied)- com.foodapp
├── controller (Handles API calls - Presentation Layer)
│ ├── OrderController.java
│ ├── UserController.java
│
├── service (Business Logic - Service Layer)
│ ├── OrderService.java
│ ├── UserService.java
│
├── repository (Data Access - Repository Layer)
│ ├── OrderRepository.java
│ ├── UserRepository.java
│
├── model (Data Model - Domain Layer)
│ ├── Order.java
│ ├── User.java
For more in-depth knowledge, check out:
Additional Notes:
I haven’t written about Encapsulation since it's an OOP concept (along with Abstraction), and most developers are already familiar with it.
I’ve explained Dependency Inversion Principle (DIP) in detail in my blog:
Packaging & Dependency Management in Modular Codebases
Why Is This Important?
A poorly structured package/module system leads to tangled dependencies, making code difficult to maintain and scale. A well-defined structure ensures clarity, modularity, and ease of development.
Package Structures (Java, Spring, or Your Preferred Language)
When designing a modular monolith, your package structure should establish clear boundaries between modules.
Example: Food Ordering App - Monolithic but Modular Structure com.foodapp
├── order # Order Module
│ ├── controller
│ │ ├── OrderController.java
│ ├── service
│ │ ├── OrderService.java
│ ├── repository
│ │ ├── OrderRepository.java
│ ├── model
│ ├── Order.java
│
├── user # User Module
│ ├── controller
│ │ ├── UserController.java
│ ├── service
│ │ ├── UserService.java
│ ├── repository
│ │ ├── UserRepository.java
│ ├── model
│ ├── User.java
│
├── common # Shared Code (Utilities, Configs, Constants)
│ ├── exceptions
│ ├── security
│ ├── logging
│
├── application # Main Application Entrypoint
│ ├── FoodApp.java
Key Takeaways:
Each module has its own controller, service, repository, and model, keeping it self-contained.
The common package stores shared utilities to avoid code duplication.
Modules do not access each other's internal details—communication happens through the service layer or events instead.
……………………
Dependency Injection (DI) for Modular Codebases
Why Dependency Injection (DI)?
Reduces tight coupling between modules.
Makes modules independent and easily replaceable.
Follows the Dependency Inversion Principle (DIP) from SOLID principles.
DI states: “*High-level components should not depend on low-level components. Both should depend on abstractions.*”
In simple terms, instead of depending on concrete implementations, we rely on interfaces.
Example: Without Dependency Injection (Tightly Coupled Code)
public interface PaymentGateway {
void processPayment(double amount);
}
public class PayPalGateway implements PaymentGateway {
@Override
public void processPayment(double amount) {
System.out.println("Processing payment: $" + amount);
}
}
public class Payment {
void process(double amount) {
PaymentGateway pg = new PayPalGateway(); // Directly tied to PayPalGateway
pg.processPayment(amount);
}
}
Problems in This Approach:
Tightly Coupled: Payment is directly dependent on PayPalGateway, making it difficult to switch implementations.
Hard to Test: If we want to mock PaymentGateway, we need to modify Payment.
Example: With Dependency Injection (Loosely Coupled Code)
public class Payment {
private PaymentGateway paymentGateway;
public Payment(PaymentGateway paymentGateway) {
this.paymentGateway = paymentGateway;
}
public void process(double amount) {
paymentGateway.processPayment(amount);
}
}
Advantages of This Approach:
Loosely Coupled: Payment depends on PaymentGateway (interface), not a specific implementation.
Easily Replaceable: If we introduce StripeGateway, we can inject it without modifying Payment.
Better Testing: We can easily mock PaymentGateway in unit tests.
Inter-Module Communication in a Modular Monolith
(A) Direct Calls
Use when: Modules are tightly related and require direct interaction.
In a modular monolith, if two modules (e.g., b1 and b2) are unlikely to change in the future, they can be tightly coupled.
Direct calls involve instantiating dependencies manually using new, leading to tight coupling.
Example: Hardcoded Instantiation (Tight Coupling)
public class UserService {
private OrderService orderService = new OrderService(); // Direct instantiation
public void placeOrder(String user, String item) {
orderService.createOrder(user, item);
}
}
Or something like -
// OrderService.java (Inside Order Module)
@Service
public class OrderService {
private final UserService userService; // Direct dependency
@Autowired
public OrderService(UserService userService) {
this.userService = userService;
}
public void placeOrder(Long userId, Order order) {
User user = userService.getUserById(userId); // Direct method call
if (user != null) {
// Process the order...
}
}
}
Issues:
Hard to replace OrderService with another version.
Difficult to unit-test UserService independently.
Now what about Dependency Injection but still Tightly Coupled ?
@Service
public class OrderService {
private final UserService userService;
@Autowired
public OrderService(UserService userService) {
this.userService = userService;
}
}
Dependency Injection (DI) still results in tight coupling because OrderService directly depends on UserService.
However, Spring manages the dependency instead of manual instantiation.
→ How Does DI Create Loose Coupling?
Instead of depending on a concrete class, depend on an interface.
Interface-Based Programming for Loose Coupling.
Example:
public interface UserService {
User getUserById(Long id);
}
//Multiple Implementations
@Service
public class DefaultUserService implements UserService {
@Override
public User getUserById(Long id) {
return new User(id, "Default User");
}
}
@Service
public class MockUserService implements UserService {
@Override
public User getUserById(Long id) {
return new User(id, "Mock User for Testing");
}
}
//Injecting the Interface Instead of Concrete Class
@Service
public class OrderService {
private final UserService userService;
@Autowired
public OrderService(UserService userService) {
this.userService = userService;
}
}
Now, OrderService doesn’t care which UserService implementation is injected, enabling easy replacements and testing.
Summary:
Tight Coupling with (new) → Hardcoded direct creation (new UserService()).
Tight Coupling with DI → Spring manages creation but still depends on a specific class.
Loose Coupling with DI → Depend on an interface, allowing flexibility.
……………………
(B) APIs (Even Inside Monoliths)
Use when: Modules should communicate without direct dependencies.
Instead of calling methods directly (orderService.createOrder()), one module exposes an API, and the other interacts like a client.
Example: REST API Communication
User Service (Exposes API)
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
User user = new User(id, "Alice");
return ResponseEntity.ok(user);
}
}
Order Service (Calls User Service API)
@Service
public class OrderService {
private final RestTemplate restTemplate;
@Autowired
public OrderService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public User getUserById(Long userId) {
String url = "http://localhost:8080/users/" + userId;
return restTemplate.getForObject(url, User.class);
}
}
Pros:
Loose coupling (Order module doesn’t directly depend on User module).
Easier transition to microservices in the future.
Cons:
Higher latency (even inside a monolith).
More boilerplate (requires HTTP calls).
Alternative: Using Feign Client
@FeignClient(name = "user-service", url = "http://localhost:8080")
public interface UserServiceClient {
@GetMapping("/users/{id}")
User getUserById(@PathVariable Long id);
}
@Service
public class OrderService {
private final UserServiceClient userServiceClient;
@Autowired
public OrderService(UserServiceClient userServiceClient) {
this.userServiceClient = userServiceClient;
}
public void placeOrder(Long userId, Order order) {
User user = userServiceClient.getUserById(userId);
if (user != null) {
System.out.println("Order placed for user: " + user.getName());
}
}
}
@SpringBootApplication
@EnableFeignClients
public class OrderServiceApplication {
public static void main(String[] args) {
SpringApplication.run(OrderServiceApplication.class, args);
}
}
……………………
(C) Shared Libraries (Common Interfaces / Event Bus)
Use when: Loose coupling is required with modular reusability.
Instead of direct method calls, use an event-based approach to communicate asynchronously between modules.
Useful resources for event-based communication:
FINAL COMPARISON
MONOLITH vs MODULAR MONOLITH vs MICROSERVICES
At last, I want to say—don’t get confused between Traditional Monoliths, Microservices, and Modular Monoliths.
I get it, they all sound similar. But after going through everything, this is how I understood them:
In a monolith, everything is bundled together in a single project. Everything is in a single project, often structured by layers (controller, service, repository, model).
In a modular monolith, each module is structured with its own controller, service, repository, and model. Each module is structured like a mini-application inside the same project, with its own controller, service, repository, and model. Modules communicate with each other internally instead of being completely separate services.
And In microservices, each service is its own independent project. Each service is a completely separate project with its own database, APIs, and infrastructure.
Monolith (Traditional Layered Architecture)
com.foodapp
├── controller (Handles API calls - Presentation Layer)
│ ├── OrderController.java
│ ├── UserController.java
│
├── service (Business Logic - Service Layer)
│ ├── OrderService.java
│ ├── UserService.java
│
├── repository (Data Access - Repository Layer)
│ ├── OrderRepository.java
│ ├── UserRepository.java
│
├── model (Data Model - Domain Layer)
│ ├── Order.java
│ ├── User.java
Modular Monolith (Feature-Based Modularization)
com.foodapp
├── order (Order Module)
│ ├── controller
│ │ ├── OrderController.java
│ ├── service
│ │ ├── OrderService.java
│ ├── repository
│ │ ├── OrderRepository.java
│ ├── model
│ │ ├── Order.java
│ ├── OrderModuleConfig.java
│
├── user (User Module)
│ ├── controller
│ │ ├── UserController.java
│ ├── service
│ │ ├── UserService.java
│ ├── repository
│ │ ├── UserRepository.java
│ ├── model
│ │ ├── User.java
│ ├── UserModuleConfig.java
Microservices (Each service is independent)
order-service
├── controller
│ ├── OrderController.java
├── service
│ ├── OrderService.java
├── repository
│ ├── OrderRepository.java
├── model
│ ├── Order.java
├── OrderApplication.java
user-service
├── controller
│ ├── UserController.java
├── service
│ ├── UserService.java
├── repository
│ ├── UserRepository.java
├── model
│ ├── User.java
├── UserApplication.java
Now If you haven’t already, try building a traditional monolith first and then transform it into a modular monolith. That’s when things really start making sense.
Spring Modulith – What’s Next?
Once you’ve built a modular monolith, you might wonder: Can this be simplified further?
This is where Spring Modulith
comes in.
Spring Modulith is a relatively new approach introduced by the Spring team that makes it easier to build modular monoliths using Spring Boot.
It provides built-in support for:
Enforcing module boundaries within a monolithic application.
Event-driven communication between modules without unnecessary coupling.
Better observability and integration with Spring’s ecosystem.
→ If you’re serious about modular monoliths, you can check out :
Final Thoughts
With that, I’ll wrap up this blog.
This isn’t meant to be a textbook or a definitive guide—it’s a reflection of my learning journey, filled with trial and error.
I may have misunderstood or overlooked certain aspects, and I’m completely open to corrections. After all, we’re all constantly evolving in this field.
If you find anything that could be improved or have insights to share, feel free to reach out. Discussions and feedback only make learning stronger.
I hope that by sharing my experiences, I’ve helped bring clarity to modular monoliths and inspired your own exploration of these concepts.
Let’s continue learning together!
Thank you for reading!
Subscribe to my newsletter
Read articles from dumbestprogrammer directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

dumbestprogrammer
dumbestprogrammer
Master's Student| Math & Comp Apps Grad| Aspiring Java Dev| Aspiring Java Backend Dev | Spring & Spring Boot| Adaptive Learner| Persistent and Positive