System DesignIntermediate 13 min Lesson 30 of 42

Modular Monolith

Keep one deployment but enforce internal boundaries, so the codebase stays workable and services can be extracted later if needed.

System Design · Lesson 30 of 42
0/42 done(0%)

What is it? #

A modular monolith is one deployable application with strictly enforced internal boundaries between modules.

It takes the operational simplicity of a monolith and adds the structural discipline usually associated with services.

Each module owns its data and exposes a defined interface. Other modules call that interface; they do not reach into its tables or internals.

The payoff is twofold. The codebase stays understandable as it grows, and if you later need to extract a module into a service, the boundary already exists.

Think of it like this #

An office building where each department has its own floor, its own filing and a reception desk.

Everyone still shares one building, one entrance and one set of utilities. But nobody walks into another department and rifles through their cabinets — requests go through the desk.

Simple example #

An e-commerce system where orders, catalogue, payments and notifications are separate modules. Orders needs product details, so it calls the catalogue module's public interface rather than querying the products table.

Code #

TEXT
shop/
├── catalogue/
│   ├── api.py          the ONLY entry point other modules may import
│   ├── models.py       internal
│   ├── service.py      internal
│   └── repository.py   owns the products and categories tables
├── orders/
│   ├── api.py
│   ├── models.py       owns the orders and order_lines tables
│   └── service.py
├── payments/
│   └── api.py
└── shared/
    └── events.py       the in-process event bus
PYTHON
# catalogue/api.py — the public surface, deliberately small
from dataclasses import dataclass


@dataclass(frozen=True)
class ProductSummary:
    sku: str
    name: str
    price: float
    in_stock: bool


def get_product(sku: str) -> ProductSummary | None:
    """Other modules use this. They never touch catalogue tables directly."""
    row = _repository.find(sku)
    return None if row is None else ProductSummary(
        sku=row.sku, name=row.name, price=row.price, in_stock=row.stock > 0
    )


# orders/service.py — depends on the interface, not the internals
from catalogue.api import get_product, ProductSummary


def add_to_cart(cart, sku: str, quantity: int) -> None:
    product: ProductSummary | None = get_product(sku)
    if product is None or not product.in_stock:
        raise ValueError("product unavailable")
    cart.add_line(sku=product.sku, price=product.price, quantity=quantity)

# NOT allowed: from catalogue.models import Product
#              SELECT * FROM catalogue_products
PYTHON
# Cross-module reactions go through events, not direct calls
from shared.events import bus

# orders publishes
bus.publish("order.placed", {"order_id": order.id, "email": user.email})

# notifications subscribes, and orders knows nothing about it
bus.subscribe("order.placed", send_confirmation_email)
TEXT
Enforcing the boundaries (they will be broken otherwise)

- an import linter rule: only module.api may be imported across modules
- table naming prefixes, and a review rule against cross-module queries
- one team owns each module in CODEOWNERS
- integration tests that use only public interfaces

How it works #

Each module has an api.py that defines its public surface. Everything else in the module is internal and may change freely.

ProductSummary is a deliberate boundary type. It is not the database model; it carries only what other modules need. That keeps the catalogue free to change its storage without breaking anyone.

Orders imports from catalogue.api only. The commented lines show the two violations that matter most: importing internal models, and querying another module's tables. Both create hidden coupling that makes later extraction impossible.

The event bus handles reactions. Orders announces that an order was placed; notifications reacts. Orders has no import of notifications at all, so the dependency runs one way.

Data ownership is the hardest rule to keep. Each table belongs to exactly one module, and cross-module reads go through interfaces. Breaking this once is easy and quickly becomes the norm.

That is why automated enforcement matters. An import linter that fails the build on a forbidden import is far more effective than a convention in a document.

Extraction later becomes mechanical: replace the function call with an HTTP or queue call, move the module and its tables, and the calling code barely changes.

Real-world use #

This is the architecture many teams arrive at after finding microservices premature and a tangled monolith painful.

The module boundaries usually mirror business capabilities — catalogue, ordering, billing, identity — rather than technical layers. That alignment is what makes them stable, because business capabilities change less often than technical fashions.

It also matches team structure. One team owning one module with a clear interface reduces coordination, which is most of what people want from microservices.

Several well-known engineering organisations have publicly described consolidating services back into a modular monolith after finding the operational cost unjustified at their scale.

The main risk is discipline. Without enforcement, boundaries erode gradually until you have a conventional tangled monolith with extra folders. Automated checks are what keep it real.

Common mistakes #

  • Boundaries that exist only as a convention, with no automated enforcement.
  • Modules reading each other’s tables directly.
  • Exposing database models as the public interface, so internals leak.
  • Circular dependencies between modules, which prevent extraction.
  • Drawing boundaries around technical layers rather than business capabilities.

Practice #

Take a small application and split it into three modules by business capability. Give each an api.py, ensure each owns its tables, and route one cross-module reaction through an event. Then write down one automated rule you would add to stop the boundaries eroding.

Quick quiz

  1. 1. What is a modular monolith?

  2. 2. What must each module own?

  3. 3. Why expose a boundary type rather than the database model?

  4. 4. Why use events for cross-module reactions?

  5. 5. What keeps the boundaries from eroding?

Summary

  • A modular monolith is one deployment with strict internal boundaries.
  • Each module owns its data and exposes a small public interface.
  • Cross-module reactions go through events, keeping dependencies one-way.
  • Boundaries must be enforced automatically, not by convention.
  • It keeps extraction to a service cheap if you later need it.