DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

Transitioning from Monolith to Modular Monolith: 3 Pragmatic Reasons

While working on an ERP for a manufacturing company, or developing the backend for one of my side products, one of the most critical decisions I often faced regarding software architecture was whether to go with a monolith or a more distributed structure. In the past, I experienced many difficulties arising from monolithic architectures. For this reason, before fully transitioning to a microservice architecture, I wanted to discuss the 3 pragmatic reasons that the modular monolith approach offered me.

This transition decision was not just a technological choice, but also a business decision that directly impacted how we would handle our development processes, error management, and technical debt. For me, it meant breaking down the "big picture" into more manageable parts.

1. Modularization for Development Speed and Independent Teams

One of the most common problems I encountered in large and complex monolithic projects was the decline in development speed over time. Adding a new feature or fixing an existing bug could slow down due to the fear of creating potential side effects across the entire codebase. Especially when working on critical flows like "purchase/produce/ship/invoice" in a manufacturing ERP, even the smallest change could lead to weeks of regression testing. This severely limited the ability of teams to act independently.

When we transitioned to a modular monolith, because each module had its own area of responsibility, different teams could work much more independently on the same codebase but on different modules. For example, one team could develop the order management module while another team simultaneously progressed on the inventory management module. This also made our CI/CD pipelines more efficient. Previously, running all ERP tests took up to 45 minutes, but thanks to modularization, relevant module tests could be completed in 5-10 minutes, which increased our deployment frequency by 30%.

💡 A Note from My Experience

In one project, we isolated the "Production Planning" module, one of the most critical modules of the ERP. This module had its own database schema and API layer, but it still operated within the main application. This allowed us to develop AI-supported production planning algorithms with minimal risk of affecting other parts of the main ERP. Our team was able to develop and deploy this module completely independently.

This independence not only increases development speed but also, to some extent, provides teams with the freedom to choose their own technologies or library versions. For example, one module might use FastAPI while another still works with Flask, because their interfaces communicate through clearly defined APIs. This flexibility also slows down the accumulation of technical debt, as we don't have to update older modules immediately.

2. Reducing the Risk of Widespread Regression

In monolithic architectures, the risk of a single code change affecting the entire system has always been one of my biggest fears. I recall an incident, especially in a client project, during the application of a critical security patch. A seemingly simple update led to a broken dependency in an unexpected part of the system, causing the entire application to be inaccessible in production for 2 hours. Such widespread regressions not only erode customer trust but also demotivate the team.

In a modular monolith structure, since each module has its own boundaries and responsibilities, the likelihood of changes in one module affecting others is greatly reduced. Inter-module communication typically occurs via clearly defined APIs or message queues, which makes dependencies more visible and manageable. If an error occurs in one module, it usually affects only that module, preventing the entire system from crashing. This approach provides a critical advantage, especially in fast-paced development environments.

# A potential risk in a monolith: A single common helper function
# When this function changes, every place that uses it can potentially be affected.

# finance_module/utils.py
def calculate_tax(amount):
    # Complex tax calculation logic
    return amount * 0.18

# order_module/views.py
def create_order(request_data):
    total = request_data['price'] * request_data['quantity']
    tax_amount = calculate_tax(total) # Common function usage
    # ...

# inventory_module/tasks.py
def adjust_stock_value(item_id, quantity):
    item_price = get_item_price(item_id)
    tax_on_item = calculate_tax(item_price) # Again, common function
    # ...
Enter fullscreen mode Exit fullscreen mode

In a scenario like the one above, a change in the calculate_tax function could lead to unexpected behavior in the order_module and inventory_module. In a modular monolith, however, each module would have its own calculate_tax implementation or a clear API call through a TaxService module. This allows us to better predict the impact of changes by clearly defining dependencies. Last year, while changing the database schema of a critical module in a client project, thanks to this modular approach, we only ran tests for the relevant module, reducing the total system test time from 35 minutes to 7 minutes. This provided great benefits in early detection and isolation of errors.

3. Ease of Maintenance and Technical Debt Management

One of the most common problems I've seen in my twenty years of experience is the accumulated technical debt in monolithic applications and the maintenance difficulties it brings. As the codebase grows, old technologies, poor design decisions, and outdated libraries accumulate. This makes it particularly challenging for new developers to adapt to the project and can turn understanding and modifying existing code into a nightmare. While developing an internal platform for a bank, we spent months just trying to optimize the "boot-up" time of a 10-year-old monolithic application.

A modular monolith offers the opportunity to break down this technical debt into smaller, manageable pieces. Each module can have its own consistent technology stack, and these modules can be refactored or updated independently. This allows old and new technologies to coexist while maintaining the overall system's flexibility. For example, one module might still run on an old Python 2.7 codebase, while a newly developed module uses Python 3.10 and FastAPI. This eliminates the obligation to immediately update non-critical older modules, allowing technical debt to be managed more strategically.

ℹ️ A Pragmatic Approach

When managing technical debt, aiming for "zero debt" is rarely realistic. The important thing is to recognize, measure, and strategically decide where to pay off the debt. A modular monolith breaks this debt into atomic pieces, offering a clearer roadmap for where and when to intervene. For the financial calculators of one of my side products, when I needed to replace an old calculation module with a new algorithm, I was able to isolate and rewrite only that module. This allowed me to complete the transition in 3 weeks without affecting the entire system.

Ease of maintenance is not limited to technical debt. It also simplifies debugging and monitoring processes. When module boundaries are clear, it becomes much easier to pinpoint which module is causing a performance issue or an error. By using journald and cgroup limits, I can monitor the resource consumption of each module separately. For example, if a particular module uses more memory than expected, I prevented other modules from being affected by simply adjusting that module's cgroup memory.high value. This increases overall system stability while speeding up maintenance processes.

Challenges of Modular Monolith and My Solutions

The transition to a modular monolith was not always a bed of roses. In my own experiences, I saw that this approach also had its own challenges. First and foremost, drawing correct module boundaries was a major challenge. Separations made without understanding business flows and data dependencies could actually lead to worse "distributed monolith" structures. In a client project, we tried to separate two modules without realizing they were actually very tightly coupled, which led to unnecessary communication layers and data inconsistencies.

In such situations, my solution has always been to base it on the "business flow." I learned from my experiences that software architecture is often about organizational flow, not just software. Clear business flows, such as a "purchase" process, a "production order" process, or a "shipment" process, became my best guide in defining module boundaries. Each module should be responsible for its own data domain and communicate with other modules only through well-defined APIs.

# Example of module routing with Nginx in a modular monolith
# Even though the main application still runs on a single server,
# the APIs of logically separate modules can be made accessible from different paths.

server {
    listen 80;
    server_name myapp.com;

    location /api/orders/ {
        proxy_pass http://localhost:8001; # Order Module
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location /api/inventory/ {
        proxy_pass http://localhost:8002; # Inventory Module
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }

    location / {
        proxy_pass http://localhost:8000; # Main Monolith (other parts)
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
    }
}
Enter fullscreen mode Exit fullscreen mode

Inter-module data sharing was another problem. While having each module own its database initially seemed appealing, this could lead to complex distributed transaction issues in "real-world" scenarios. I generally preferred a shared database approach, but I ensured that each module used its own schema prefix or clearly defined ownership of specific tables. This was supported by strategies like optimizing read replicas for separate modules using PostgreSQL's logical replication. [related: PostgreSQL replication strategies]

Implementation Strategies: Where to Start?

Modularizing an existing monolith is always harder than starting from scratch. The most successful strategy I've applied in this regard is the "Strangler Fig Pattern." In this model, instead of immediately breaking apart the existing monolith, you develop new functionalities as separate modules and gradually wrap them around the monolith. You slowly redirect calls from the old monolith to the new modules. This can be done by minimizing risk and ensuring a seamless transition.

For example, in a manufacturing ERP, we decided to move the main "shipment" flow to a new module. The first step was to define a separate API for the new shipment module and create its own database tables. Then, we added a feature flag or reverse proxy rule to redirect all shipment-related calls within the monolith to the new module's API.

# Modular monolith example in docker-compose.yml
# Each service represents a separate module.

version: '3.8'

services:
  nginx:
    image: nginx:latest
    ports:
      - "80:80"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - main_app
      - order_service
      - inventory_service

  main_app:
    build: ./main_app
    environment:
      DATABASE_URL: postgresql://user:pass@db:5432/main_db
    # ... other configs

  order_service:
    build: ./order_service
    environment:
      DATABASE_URL: postgresql://user:pass@db:5432/order_db
    # ... other configs

  inventory_service:
    build: ./inventory_service
    environment:
      DATABASE_URL: postgresql://user:pass@db:5432/inventory_db
    # ... other configs

  db:
    image: postgres:14
    environment:
      POSTGRES_DB: main_db
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
    volumes:
      - db_data:/var/lib/postgresql/data
    # ... other configs

volumes:
  db_data:
Enter fullscreen mode Exit fullscreen mode

This docker-compose structure shows how the modular monolith fits into a "bare-metal + container hybrid deployment" approach. Each module runs in its own Docker container and is routed by Nginx or an API Gateway. This allows each module's systemd units, journald logs, and cgroup limits to be managed separately. [related: Bare-metal and Container hybrid deployment] As a result, when an OOM build issue occurred in the "order module," it did not affect other modules. Last month, when I used sleep 360 for a long-term sleep instead of a polling-wait mechanism within order_service, I was OOM-killed. After this error, I switched to polling-wait to solve the problem, but at least it was reassuring to see that only that module was affected.

My Experience: Tips and Lessons Learned

One of the most important lessons I learned when transitioning to or designing a modular monolith architecture is that module boundaries must be clear and consistent. If the boundaries are blurry, the risk of turning into a distributed monolith is very high. This means experiencing the complexity of microservices without benefiting from their advantages. I always separated modules based on "business domains" and "data ownership" principles.

Another important tip is to simplify inter-module communication as much as possible. REST APIs or message queues (e.g., Redis pub/sub) are usually sufficient. Complex RPC mechanisms or distributed transactions often create unnecessary complexity, and the eventual consistency principle offers an acceptable trade-off for most business flows. In a manufacturing ERP, the production planning module, instead of making direct API calls to the inventory module, published a "product produced" event to a message queue. The inventory module listened to this event and updated its internal stock status. This made the modules less dependent.

⚠️ Beware of ORM Traps

Even in a modular monolith, when using an ORM, it's important to avoid falling into ORM traps like the N+1 query problem or eager-load explosions. Although each module has its own data access layer, such performance issues can still arise. Regularly checking queries using EXPLAIN ANALYZE in PostgreSQL and performing connection pool tuning helped me catch these issues early. Sometimes the problem isn't with the ORM, but with the planner's incorrect index selection.

Finally, observability (metrics, logs, traces) is indispensable in this architecture. Having each module send its logs to journald, expose its metrics to Prometheus, and track its traces with OpenTelemetry is critical for understanding the overall health of the system and quickly identifying problems. In the backend of my own side product, I defined separate SLOs (Service Level Objectives) and error budgets for each module. This provided concrete data indicating which module required more attention.

Conclusion

For me, the transition from monolith to modular monolith was not just an architectural change, but a pragmatic evolution that made our development processes more agile, maintenance easier, and risks more manageable. Increasing development speed, reducing the risk of widespread regression, and managing technical debt more effectively were the main motivations for embarking on this path. Of course, there were challenges, but with the right strategies and continuous learning, it's possible to overcome them.

This approach offers an excellent interim solution for teams or projects that are not yet ready to handle the operational complexity of a full-fledged microservice architecture. In my opinion, the "right architecture" is the one that best meets the current needs of the project and the team. In my next post, I will detail how I used event-sourcing and CQRS patterns within a modular monolith.

Top comments (0)