DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

Monolith vs. Modular Monolith: An Indie Hacker's Choice

For an "indie hacker" embarking on a new idea, developing a product alone or with a small team, architectural decisions are critically important. Over the years, while developing my own side projects or working on large enterprise projects, I've repeatedly seen how decisive these choices can be. Especially the choice between Monolith and Modular Monolith directly impacts the project's future scalability, maintenance costs, and development speed. In this post, I will evaluate these two approaches from my perspective, focusing on their practical benefits and challenges, and explain which path I generally prefer as an indie hacker.

When starting a project, architectural choice often directly affects the product's time-to-market and agility in initial iterations. While a Monolith might seem appealing at first, it's crucial to account for the burdens it can bring over time. A Modular Monolith, on the other hand, offers a way to build a more organized and sustainable structure without getting entangled in the complexity of microservices. In my experience, striking the right balance here is vital, especially for an indie hacker working with limited resources.

Monolith: Quick Start and Hidden Costs

Monolith architecture means all application components reside in a single codebase and are deployed as a single unit. So, imagine a web application: database access, business logic, user interface, APIs—all within the same project, often running in the same server process. This approach provides incredible speed, especially during rapid prototyping and Minimum Viable Product (MVP) stages. I developed many of my side products initially as Monoliths because I needed to bring ideas to life quickly. A single repo, a single deployment process, a single test suite... Everything is simple and straightforward.

However, this simplicity comes with hidden costs that emerge over time. When we first started developing an ERP for a manufacturing company, we also used a similar Monolith structure. Initially, we made very rapid progress with 5-6 people. But as the system grew and features increased, adding a new feature or fixing an existing bug could take days, sometimes weeks. The codebase became so intertwined that a change in one place could lead to an unexpected error elsewhere. For example, in 2018, a minor change in the order module once caused incorrect discount calculations in the invoicing module. Finding and fixing such issues severely consumed our resources.

⚠️ The Dangerous Allure of the Monolith

While a Monolith may seem simple and fast at first glance, as the codebase grows and business logic becomes more complex, it can slow down development speed and make bug finding difficult. As dependencies increase, even a small change can lead to unexpected results in different parts of the system.

The disadvantages of a Monolith become more pronounced, especially in software with large and complex business processes. While examining index strategies to optimize PostgreSQL performance or solving N+1 query problems, the interdependence of everything in a Monolith makes refactoring or trying new approaches difficult. When developing the backend for my Android spam blocker application, I initially started with a simple Monolith. But as different domains like spam filtering algorithms and user data management grew, I realized how fragile the codebase became with each new feature I added. This was a significant experience that pushed me towards a more modular approach.

Modular Monolith: My View as a Bridge Solution

Modular Monolith, as its name suggests, is an approach that embraces modularity internally while retaining the advantages of a Monolith structure. The application still remains a single codebase and a single deployment unit, but within the codebase, it is clearly separated into independent modules. Each module is responsible for its own domain and communicates with other modules only through defined interfaces (APIs or events). This allows us to create a cleaner and more manageable structure without incurring the operational complexity of a full microservices architecture (distributed transactions, service discovery, distributed logging, etc.).

For me, Modular Monolith is an ideal bridge solution, especially for medium-sized or potentially growing indie hacker projects. In a client project, we adopted this approach when developing a system that included production planning and inventory management. We wanted to move quickly at first, but we knew that complex modules like AI-driven production planning would be added in the future. Therefore, we structured the main domains like "Production," "Inventory," and "Sales" under separate Python packages or directories. Each package had its own PostgreSQL tables and communicated with other packages only via FastAPI endpoints or an internal in-memory event bus.

# Example Modular Monolith project structure
# my_project/
# ├── src/
# │   ├── __init__.py
# │   ├── main.py        # Main application entry (FastAPI)
# │   ├── config.py
# │   ├── modules/
# │   │   ├── __init__.py
# │   │   ├── inventory/
# │   │   │   ├── __init__.py
# │   │   │   ├── models.py
# │   │   │   ├── services.py
# │   │   │   ├── api.py     # Inventory API endpoints
# │   │   │   └── events.py
# │   │   ├── production/
# │   │   │   ├── __init__.py
# │   │   │   ├── models.py
# │   │   │   ├── services.py
# │   │   │   ├── api.py     # Production API endpoints
# │   │   │   └── events.py
# │   │   └── sales/
# │   │       ├── __init__.py
# │   │       ├── models.py
# │   │       ├── services.py
# │   │       ├── api.py     # Sales API endpoints
# │   │       └── events.py
# ├── tests/
# └── Dockerfile
Enter fullscreen mode Exit fullscreen mode

Thanks to this structure, development in the "Inventory" module could be done without directly affecting the "Production" module. Since dependencies between modules decreased, code changes became safer, and the time to add new features shortened. Furthermore, if the "Production" module became overloaded in the future, we still had the option to turn it into an independent microservice, as it was already isolated with its own internal logic and API. This is especially valuable for indie hackers, because no matter how small a project is today, you never know where it will evolve tomorrow.

Trade-off Analysis: How Much Complexity, How Much Flexibility?

Software architecture choices are always a matter of trade-offs. We face a similar situation when choosing between Monolith and Modular Monolith. For me, the key question is: "Given my current needs and resources, how much complexity can I tolerate, and how much flexibility will I gain in return?"

Feature / Architectural Approach Monolith Modular Monolith
Development Speed (Initial) Very high, fast MVP High, close to Monolith
Codebase Management Becomes difficult, dependencies increase Easier, clear boundaries between modules
Deployment Complexity Low, single unit Low, single unit (but potential for flexible separation)
Scalability Entire application scales together Modules partially isolated, potential for more flexible scaling
Technology Flexibility Low, entire stack is the same Medium, modules can be separated into different technologies
Team Organization Suitable for small teams, problematic as it grows Suitable for module-based teams (better separation)
Refactoring Ease Difficult Easier, changes within a module remain isolated

As an indie hacker, I usually prioritize development speed at the beginning. It's important to quickly put something out there for market testing and finding product-market fit. This makes it easy to fall for the initial allure of the Monolith. However, my experiences have shown me that this speed comes with a long-term cost. In my own side project, a financial calculators app, I initially proceeded with a simple Monolith. As the number of functions increased and new user requests came in, managing different calculation algorithms and data sets within a single codebase became a nightmare. Especially in late 2023, when I decided to add a new AI-based forecasting module, I saw how cumbersome the existing Monolith structure had become.

ℹ️ The Deciding Moment

The decision to transition from a Monolith to a Modular Monolith is usually made when the project grows, the codebase becomes complex, or there's a need for different domains to become independent. This provides significant flexibility without incurring the cost of a full microservices transition.

At this point, I realized that transitioning to a Modular Monolith was a "savior." Instead of breaking down the existing Monolith, I designed each newly added feature as a separate module. For example, I isolated the "Forecasting" module with its own internal dependencies and API. This module is deployed as part of the main application but has a completely independent lifecycle within itself. This way, when updating the AI model or experimenting with a different AI provider (like Gemini Flash, Groq, or Cerebras), I didn't risk other parts of the main application. This was a balance that provided me with both speed and future flexibility.

Implementation Details: Ensuring Modularity at the Code Level

While Modular Monolith sounds great on paper, the real challenge is how you implement it at the code level. Making a Monolith truly modular requires a disciplined approach. My usual method is to separate the project by domain and ensure these domains act like self-contained packages. In the Python world, this can be done with separate directories, __init__.py files, and clearly defined import rules.

For example, in a manufacturing ERP, I had main modules like "Sales," "Production," and "Accounting." I created each as a separate subdirectory under the main src/modules directory.

# src/modules/sales/api.py
from fastapi import APIRouter
from src.modules.sales.services import create_order, get_order

router = APIRouter(prefix="/sales", tags=["Sales"])

@router.post("/orders/")
async def create_new_order(order_data: dict):
    # Process order_data, perform validation
    order = create_order(order_data)
    # Emit an internal event (e.g., order_created event)
    return {"message": "Order created", "order_id": order.id}

# src/modules/production/events.py
from src.core.event_bus import EventBus # A hypothetical in-memory event bus

def handle_order_created(order_id: int):
    # Listen for the "order_created" event from the Sales module
    # Trigger production planning
    print(f"Order {order_id} created. Initiating production plan.")
    # production_service.plan_production(order_id)

# Main application (main.py)
from fastapi import FastAPI
from src.modules.sales.api import router as sales_router
from src.modules.production.api import router as production_router
from src.modules.production.events import handle_order_created
from src.core.event_bus import EventBus

app = FastAPI()
app.include_router(sales_router)
app.include_router(production_router)

# Register event listeners
EventBus.subscribe("order_created", handle_order_created)
Enter fullscreen mode Exit fullscreen mode

Key points to note in this structure:

  1. Clear APIs: Communication between modules should occur either through clearly defined FastAPI endpoints or via an EventBus (usually in-memory).
  2. Unidirectional Dependencies (Preferably): If possible, I try to establish unidirectional dependencies between modules. For example, the "Sales" module doesn't directly call the "Production" module; it publishes an event, and the "Production" module listens for this event. This prevents dependency cycles.
  3. Database Schema Isolation: It's ideal for each module to have its own database tables (with prefixes or separate schemas). This way, a data schema change in one module affects others less. In an internal banking platform, we achieved this isolation by keeping data from different business units in separate schemas.
  4. Strict Rule Enforcement: It's important to check adherence to inter-module rules during code reviews. If necessary, these rules can be automated with pre-commit hooks or static analysis tools.

This approach provides me with both development flexibility and the ability to separate a specific module from the Monolith and deploy it as a separate microservice if I encounter performance issues or need a different technology in the future. For example, if the AI-driven production planning module starts consuming too many resources, running it in a separate Kubernetes pod becomes easily doable without affecting other ERP components.

Operational Perspective: Differences in Bare-metal and Container Worlds

The operational implications of my architectural choices have always been a priority for me. In my 20 years of field experience, I've seen even the best-designed systems fail due to operational challenges. Regardless of whether it's a Monolith or a Modular Monolith, I have certain preferences regarding application deployment and management.

I'm a fan of the bare-metal + container hybrid deployment method. On my own VPSs or in client projects, I typically set up Docker and docker-compose on Linux machines. Writing a Dockerfile for a Monolith application and running it with docker-compose.yml is quite simple. A single service, a single image.

# docker-compose.yml (For a simple Monolith or Modular Monolith)
version: '3.8'
services:
  my_app:
    build: .
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: "postgresql://user:password@db:5432/mydatabase"
    depends_on:
      - db
    restart: unless-stopped
  db:
    image: postgres:14-alpine
    environment:
      POSTGRES_DB: mydatabase
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    volumes:
      - postgres_data:/var/lib/postgresql/data
volumes:
  postgres_data:
Enter fullscreen mode Exit fullscreen mode

This docker-compose.yml file is valid for both a simple Monolith and an internally modularized Modular Monolith. Since the application still runs within a single container, operational complexity does not increase. I can also separate SSL termination and static file serving from the my_app container by setting up an Nginx reverse proxy.

💡 Operational Simplicity

Modular Monoliths are very similar to Monoliths in terms of deployment and operational management. This is a huge advantage, especially for indie hackers with limited operational resources. Flexibility is gained without getting into the distributed system complexity of microservices.

However, the future operational advantages of a Modular Monolith are hidden here. If a specific module, like the "AI-driven production planning" module, starts consuming excessive resources in the future or requires a different runtime environment (e.g., an ML infrastructure requiring GPUs), it becomes much easier to separate it from the main Monolith and deploy it as a separate Docker container. Since its internal APIs and dependencies are already isolated, this separation process is much less painful than with a Monolith. I use a similar mindset in areas like self-hosted runner economics; when should I separate a service and run it on a separate VM, or when should I keep it on the main machine?

What I've observed while monitoring journald logs or setting cgroup memory limits is that managing an application running as a single process is always easier. Even when tuning PostgreSQL connection pools, configuring settings for a single application instance is less error-prone than managing each service's own pool for distributed microservices. Therefore, as an indie hacker, I always prioritize operational simplicity.

Future Considerations and My Choice

Software architecture is not something that remains static throughout a project's life; it evolves. Starting with a Monolith and transitioning to a Modular Monolith, or converting specific modules from a Modular Monolith into microservices, is part of a natural growth process. For me, the triggers for this evolution have usually been:

  • Team Size: When working alone or with a team of 2-3 people, a Monolith or Modular Monolith is ideal. However, when the team exceeds 10-15 people, moving towards microservices becomes more sensible for different teams to work independently.
  • Performance Bottlenecks: When a specific module's (e.g., my AI-based planning module) resource consumption or performance requirements start affecting other application components, separating it becomes inevitable.
  • Need for Technology Diversity: Being forced to use the same technology stack for the entire application makes it difficult to innovate or experiment with different languages/frameworks. When a module needs to be written in Rust, separating it is a good option.
  • Complexity of Business Processes: In cases where advanced architectural patterns like Event-sourcing or CQRS only make sense in specific domains, it's easier to isolate that domain.

ℹ️ My Clear Stance: Start with Modular Monolith, Evolve as Needed

As an indie hacker, I generally adopt the Modular Monolith approach when starting a project. This offers me the initial speed advantage of a Monolith while providing a flexible foundation for my future growth and scaling needs. A full transition to a microservices architecture should only be considered when a real and measurable need arises.

My clear stance for an indie hacker project is to start with a Modular Monolith. This approach offers me the rapid development advantage of a Monolith while also providing a solid foundation for my future growth and scalability needs. I've experienced a similar trade-off during a VPS migration process: simplicity or flexibility? I usually choose to increase flexibility while maintaining simplicity. Even in technical details like PostgreSQL index strategies or Redis OOM eviction policy choices, I start with a general solution and move to more specific adjustments when concrete problems like performance regressions or WAL bloat arise. This pragmatic approach is the most sustainable path for an indie hacker working with limited time and resources.

In my next post, I will share my experiences on CI/CD reliability and how I implement blue-green or canary deployment strategies in indie hacker projects.

Top comments (0)