Skip to main content

Monolith / Modular Monolith

Single deployable unit with internal modularity and clear domain boundaries

TL;DR

A monolith is a single deployable unit where all business logic lives in one codebase and runs in one process. A modular monolith improves on this by organizing code into internal modules representing domain boundaries (order module, payment module, shipping module), reducing coupling while maintaining single deployment. Ideal for teams building systems that don't yet require independent scaling or deployment.

Learning Objectives

  • Understand the characteristics of monolithic vs modular monolithic architecture
  • Identify domain boundaries and design internal modules around them
  • Recognize when a monolith has crossed the "big ball of mud" threshold
  • Apply refactoring strategies to improve internal structure
  • Know when to transition from monolith to microservices

Motivating Scenario

Your SaaS platform started as a simple Django/Rails monolith. Three years later, 50k lines of code, three teams (order, payment, inventory). The single codebase is still manageable, but no team wants to wait for others' deploys. Instead of fragmenting into microservices, you reorganize internally: define order, payment, and inventory as separate modules with published interfaces. Teams can develop in isolation, but deploy together. This is a modular monolith.

Core Concepts

Monolith: All code in one repository, deployed as one unit.

Modular Monolith: Same single deployment, but organized into domain-aligned modules with explicit boundaries and APIs, reducing internal coupling.

Monolith evolution from big ball of mud to modular

Key Characteristics of Modular Monoliths

Single Deployment Unit: All modules deployed together. No partial rollouts per module.

Internal Domain Modules: Order, Payment, Inventory, etc. Each is a cohesive unit with a published API.

Module Boundaries: Clear, documented interfaces. Modules don't call internal functions of other modules.

Shared Infrastructure: Single database (or schema per module), shared logging, shared caching.

Internal Communication: Direct function calls, not RPC or message queues (yet).

Modular monolith with domain boundaries

Module Design Principles

  1. High Cohesion: All related logic lives together. Order module has order create, update, refund, etc.
  2. Low Coupling: Minimize dependencies between modules. Use event-driven callbacks instead of direct calls.
  3. Published API: Each module exposes a small, versioned interface. Internal implementation changes don't leak.
  4. Explicit Dependencies: Module A clearly declares it needs module B, not hidden in code.
  5. Shared Infrastructure: Typically one database, but clear schema/table ownership.

Practical Example

src/
├── modules/
│ ├── order/
│ │ ├── __init__.py # Public API
│ │ ├── controllers.py # HTTP handlers
│ │ ├── services.py # Business logic
│ │ ├── repositories.py # Data access
│ │ ├── models.py # Domain models
│ │ └── events.py # Events published by this module
│ │
│ ├── payment/
│ │ ├── __init__.py
│ │ ├── controllers.py
│ │ ├── services.py
│ │ ├── repositories.py
│ │ ├── models.py
│ │ └── events.py
│ │
│ └── inventory/
│ ├── __init__.py
│ ├── controllers.py
│ ├── services.py
│ ├── repositories.py
│ ├── models.py
│ └── events.py

├── shared/
│ ├── database.py # Shared DB connection
│ ├── events.py # Event bus / mediator
│ └── logging.py # Shared logging

└── main.py # App entry point

When to Use / When Not to Use

Use Monolith / Modular Monolith When:
  1. Team is small (< 30 people) and coordination cost is low
  2. Domain doesn't require independent scaling of components
  3. You want to avoid distributed system complexity
  4. Single deployment schedule works for all teams
  5. Code reuse across modules is high and benefits from shared libraries
  6. Building MVP; premature microservices are a waste
Avoid Monolith When:
  1. Multiple large teams need independent release cycles
  2. Components have dramatically different scaling needs (e.g., one service needs 100x scale of another)
  3. Teams use conflicting technology stacks (Python vs Go vs Java critical)
  4. Failure of one component must not crash the entire system
  5. You need to update one module without coordinating with others

Patterns and Pitfalls

Patterns and Pitfalls

Modules exist on paper (separate packages) but code directly accesses private members. Order module calls PaymentRepository directly. Enforce boundaries via code review, package visibility (Java protected/private), and dependency injection. Use event-driven calls instead of direct dependencies.
All modules share a single table schema; schema change in one module breaks all others. Define clear table/schema ownership per module. Avoid cross-module joins. Use APIs for cross-module data access.
50k lines in a single codebase; startup time, testing time, compile time all degrade. Consider multimodule build systems (Maven modules, Lerna workspaces). Or split into microservices when module count exceeds team capacity.
Each module exports a small, stable API in its init.py or public class. Internal details stay internal. Define interfaces/contracts clearly. Use semantic versioning if modules have external consumers.
Modules publish events; other modules subscribe. Loosely coupled compared to direct function calls. Implement a simple event bus/mediator. Modules publish domain events; subscribers handle reactions.
Wire modules together at startup, not scattered throughout code. Use DI framework or composition root pattern to explicit declare module dependencies.

Design Review Checklist

  • Are modules organized around business domains (Order, Payment, Inventory)?
  • Does each module have a published API / __init__ file restricting what's exported?
  • Can you understand a module without reading code in other modules?
  • Are cross-module dependencies declared explicitly (constructor injection)?
  • Do modules communicate via published APIs, not by accessing private functions?
  • Is there an event mechanism for loosely-coupled cross-module communication?
  • Could you move a module to a separate service without major refactoring?
  • Is startup time acceptable? (Monoliths often get slow at 50k+ lines)
  • Can one module be tested in isolation by mocking its dependencies?
  • Is database schema organized by module, with no cross-module foreign keys?

Self-Check

  1. What's the key difference between a monolith and a modular monolith? Monolith is all code in one codebase; modular monolith has the same single deployment but organizes code into domain-aligned modules with clear boundaries and APIs.
  2. When might you transition a monolith to microservices? When teams need independent deployment cycles, components have different scaling requirements, or multiple language/framework choices are critical.
  3. How does event-driven communication help in a modular monolith? Decouples modules; instead of OrderService directly calling PaymentService, it publishes an OrderCreated event. Any module can subscribe without tight coupling.
info

One Takeaway: Start with a modular monolith. It offers structure and clarity without the operational complexity of microservices. Refactor to microservices only when you have a concrete reason (scaling, independent deployment, team independence).

Next Steps

  • Hexagonal / Ports & Adapters: Better isolation of domain logic within modules
  • Microservices: When and how to split modules into independent services
  • Domain-Driven Design: Apply bounded contexts to design module boundaries
  • Event Sourcing & CQRS: Advanced patterns for managing state across modules
  • Modular Monolith in Practice: Spring modulith, Gradle multi-module, Nx workspace tools

References

  • Richards, M., & Ford, N. (2020). Fundamentals of Software Architecture. O'Reilly. ↗️
  • Evans, E. (2003). Domain-Driven Design. Addison-Wesley. ↗️
  • Newman, S. (2015). Building Microservices. O'Reilly. (Chapter 2: The Microservice World) ↗️
  • Larsen, G., & Muller, R. (2023). Building Modular Cloud Apps with OSGi. Pragmatic Bookshelf. ↗️