Skip to content

Modular Monolith vs Microservices — Start Modular (2026)

DodaTech Updated 2026-06-20 7 min read

In this tutorial, you'll learn the modular monolith pattern — organizing a single-deployment application into well-defined modules with clear boundaries, and when to extract those modules into microservices. Why does this matter? Microservices introduce massive complexity (network latency, distributed transactions, service discovery, operational overhead) that most applications never need. A modular monolith gives you the architectural benefits of microservices without the operational cost. Real-world use: Shopify, Klarna, and Segment all use modular monoliths that serve millions of users with a single deployment.

What Is a Modular Monolith?

A modular monolith is a single deployment unit with logically separated modules. Each module has its own domain, data, and public API — just like a microservice — but everything runs in the same process. Modules communicate through explicit interfaces (in-process), not network calls.

This is different from a "big ball of mud" monolith where concerns are tangled. In a modular monolith, module boundaries are enforced at the code level.

graph TB
    subgraph ModularMonolith[Modular Monolith]
        API[API Layer / Entry Point]
        
        subgraph OrdersModule[Orders Module]
            O_API[Public API]
            O_CORE[Domain Logic]
            O_DB[(Orders DB Schema)]
        end
        
        subgraph InventoryModule[Inventory Module]
            I_API[Public API]
            I_CORE[Domain Logic]
            I_DB[(Inventory DB Schema)]
        end
        
        subgraph UsersModule[Users Module]
            U_API[Public API]
            U_CORE[Domain Logic]
            U_DB[(Users DB Schema)]
        end
        
        API --> O_API
        API --> I_API
        API --> U_API
        O_API --> I_API
        O_API --> U_API
    end
    
    subgraph Future[Extractable Services]
        OrdersSvc[Orders Microservice]
        InventorySvc[Inventory Microservice]
        UsersSvc[Users Microservice]
    end
    
    OrdersModule -.-> OrdersSvc
    InventoryModule -.-> InventorySvc
    UsersModule -.-> UsersSvc
    
    style ModularMonolith fill:#4A90D9,color:#fff
    style Future fill:#95a5a6,color:#fff

How Modular Monoliths Work

Module Boundaries

Each module is a self-contained unit with:

  1. Public interface — what other modules can call
  2. Internal implementation — hidden from other modules
  3. Private data — tables or schemas owned exclusively by the module
  4. Domain events — emitted when something interesting happens
# --- Orders Module (public API) ---
class OrdersModule:
    def create_order(self, user_id: str, items: list[OrderItem]) -> Order:
        # Uses Inventory Module through its public API
        inventory = self.inventory.check_availability(items)
        if not inventory.available:
            raise InventoryError("Items not available")
        
        order = Order(user_id=user_id, items=items)
        self.orders_repo.save(order)
        self.event_bus.publish(OrderCreated(order.id))
        return order
    
    def get_order(self, order_id: str) -> Order:
        return self.orders_repo.find_by_id(order_id)

# --- Inventory Module (public API) ---
class InventoryModule:
    def check_availability(self, items: list[OrderItem]) -> Availability:
        stock = self.inventory_repo.get_all_stock()
        return Availability(
            available=all(
                stock.get(i.sku, 0) >= i.quantity for i in items
            )
        )
    
    def reserve_items(self, items: list[OrderItem]) -> None:
        for item in items:
            self.inventory_repo.decrement(item.sku, item.quantity)

Communication

Modules communicate in-process through explicit interfaces or events. This avoids network overhead:

# Event bus within the monolith
class InProcessEventBus:
    def __init__(self):
        self._handlers: dict[type, list[callable]] = {}
    
    def publish(self, event: object) -> None:
        for handler in self._handlers.get(type(event), []):
            handler(event)
    
    def subscribe(self, event_type: type, handler: callable) -> None:
        self._handlers.setdefault(event_type, []).append(handler)

# Usage
event_bus = InProcessEventBus()
event_bus.subscribe(OrderCreated, send_confirmation_email)
event_bus.subscribe(OrderCreated, reserve_inventory)

Database Strategy

Modules can share a database instance but own separate schemas. No module directly queries another module's tables — only through that module's public API:

-- Orders module owns 'orders' schema
CREATE SCHEMA orders;
CREATE TABLE orders.orders (...);
CREATE TABLE orders.order_items (...);

-- Inventory module owns 'inventory' schema
CREATE SCHEMA inventory;
CREATE TABLE inventory.products (...);
CREATE TABLE inventory.stock_levels (...);

When to Extract to Microservices

graph LR
    MM[Modular Monolith] -->|Team grows beyond
2 pizza teams| Microservices MM -->|Module needs
independent scaling| Microservices MM -->|Different deployment
cadence| Microservices MM -->|Technology
divergence needed| Microservices MM -->|Performance isolation
required| Microservices style MM fill:#2ECC71,color:#fff style Microservices fill:#E67E22,color:#fff

Extraction Process

  1. Identify module that benefits from independence
  2. Extract data — move its schema to a separate database
  3. Replace in-process calls with HTTP/gRPC/async calls
  4. Add service discovery and resilient communication patterns
  5. Deploy independently once extraction is complete

Pros and Cons

Modular Monolith Microservices
Single deployment — easy ops Complex deployments — many moving parts
Low latency — in-process calls Network latency — inter-service calls
Atomic transactions — ACID across modules Eventual consistency — sagas, compensating transactions
Simple testing — one process Complex testing — integration, contract, end-to-end tests
Refactoring across modules is easy Refactoring across services requires coordination
Less code — no service discovery, serialization layers More code — each service needs its own boilerplate
All or nothing scaling Fine-grained scaling

Real-World Examples

Shopify

Shopify's core runs as a monolith serving 2M+ merchants. They use modular boundaries (orders, products, payments, shipping) with strict internal APIs. They've extracted only a few services (like image processing) where scaling requirements demand it.

Klarna

Klarna's "KOH" system is a modular monolith handling millions of transactions. Modules communicate via an internal event bus with clear contracts. They extract services only when a module needs independent deployment.

Segment

Segment's pipeline engine started as a modular monolith. Each source, transformation, and destination is a module. They extracted high-throughput destinations as needed.

When to Choose Each

Choose Modular Monolith when:

  • Team <10 people
  • Application is <3 years old
  • Transactional consistency is critical
  • Deployment complexity should be minimized

Choose Microservices when:

  • Multiple independent teams (>10)
  • Different scaling needs per domain
  • Polyglot technology stack required
  • Independent release cycles needed

FAQ

Is a modular monolith just a monolith with good code organization?

No. A modular monolith enforces strict boundaries at the module level — private data, public interfaces, event-based communication. Good code organization within a module is different from having module-level encapsulation with architectural enforcement (e.g., separate schemas, no direct data access across modules).

When should I migrate from modular monolith to microservices?

When a module needs independent scaling (different resource profile), different deployment cadence, or a different technology stack — and the team size supports managing multiple services. Never migrate just because microservices are trendy.

Do modular monoliths support polyglot programming?

Not easily. Since everything runs in the same process, you're limited to one language/runtime. However, you can use different databases per module (polyglot persistence) within a monolith — PostgreSQL for orders, Redis for caching, Elasticsearch for search.

How do I enforce module boundaries?

Use separate namespaces, packages, or Java modules. Enforce dependency direction with tools (e.g., ArchUnit in Java, import linters). No direct database access across modules — only through the module's public API. Consider a shared kernel for truly cross-cutting concerns.

What is the shared kernel in a modular monolith?

The shared kernel is a small set of types (interfaces, base classes, utilities) that all modules depend on. It's the only allowed shared dependency. Everything else belongs to individual modules. The kernel should be stable — changes affect all modules

Practice Questions

  1. What distinguishes a modular monolith from a traditional monolith?

  2. List three signals that indicate it's time to extract a module into a microservice.

  3. How does in-process communication benefit a modular monolith compared to microservices?

  4. Why should each module own its own database schema?

  5. What is the role of a shared kernel in a modular monolith?

Challenge

Take a traditional monolith. Identify 3-4 bounded contexts. Refactor the codebase into modules with explicit public APIs, private data, and event-based communication between modules. Ensure no module directly accesses another module's database tables.

Real-World Task

Audit an existing monolith. Count how many modules/bounded contexts exist. Check if data access crosses module boundaries. If so, add a public API to the owning module and refactor cross-module data access through it. This is the first step toward microservice readiness.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro