KAHIBARO
Discord Login Register

25.2. Modular Monoliths

Why Modular Monoliths Matter

In backend development you will often hear two big words: monolith and microservices. A modular monolith sits between them. It is still a single deployable application, but it is designed as clear, separate modules inside that one codebase.

You will not build microservices reliably if you first cannot build a good modular monolith. For beginners, modular monoliths are usually the most practical and safe architecture for real projects.

This chapter focuses on what is unique about modular monoliths, how they differ from a “spaghetti” monolith and from microservices, and how to structure them in code.

Key idea: A modular monolith is one application, one database, one deployment, but with strong internal boundaries between feature modules.
You should treat modules like “mini services” inside one process.


Monolith vs Modular Monolith vs Microservices

To understand modular monoliths, compare three styles.

Classic (Spaghetti) Monolith

A classic monolith is:

The problem is not being “single” but being entangled. Typical symptoms:

You still deploy and run only one artifact, like:

bash
uvicorn app.main:app

or

bash
python manage.py runserver

but internally it is a mess.

Modular Monolith

A modular monolith is also:

But:

Think of it as:

Microservices

Microservices are:

This gives:

But also:

A modular monolith gives you many of the design benefits of microservices, but keeps the operational simplicity of one deployable app.


Goals of a Modular Monolith

A modular monolith tries to balance:

In other words:

A good modular monolith lets you grow in complexity only when you really need it.


Example Domain: Simple E‑Commerce Modular Monolith

To make it concrete, imagine a basic e‑commerce backend. We might define modules like:

ModuleResponsibility
usersUser accounts, profiles, authentication support
catalogProducts, categories, product search
ordersShopping cart, orders, order history
paymentsPayment intents, payment confirmations
notificationsEmails, SMS, push notifications

All of these live in one codebase and run inside one FastAPI application.

A minimal folder structure could look like this:

text
app/
  main.py                 # App entrypoint
  containers.py           # DI wiring (if you use it)
  users/
    __init__.py
    api.py
    models.py
    services.py
    repository.py
    schemas.py
  catalog/
    __init__.py
    api.py
    models.py
    services.py
    repository.py
    schemas.py
  orders/
    __init__.py
    api.py
    models.py
    services.py
    repository.py
    schemas.py
  payments/
    __init__.py
    api.py
    models.py
    services.py
    repository.py
    schemas.py
  notifications/
    __init__.py
    services.py          # Often no public API, used internally

Each module is like a small, internal service with:

Defining Clear Module Boundaries

The boundary of a module is extremely important. It defines:

For modular monoliths, follow this rule:

Other modules must access your module only through its public API, not directly through its database tables or internal helpers.

In Python, you cannot enforce visibility as strictly as in some languages, but you can still:

Example: Orders Using Catalog Only Through a Service Function

Imagine that the orders module needs product information from the catalog module.

Bad design:

python
# orders/services.py
from catalog.models import Product  # Direct dependency on internals
def create_order(user_id: int, product_id: int):
    product = Product.objects.get(id=product_id)  # Uses catalog's DB details
    # ...

If the catalog module changes its database structure, orders breaks.

Better design:

python
# catalog/services.py
from .repository import get_product_by_id
def get_product_details(product_id: int):
    product = get_product_by_id(product_id)
    # You might return a dataclass or DTO instead of ORM model
    return {
        "id": product.id,
        "name": product.name,
        "price": product.price,
        "currency": product.currency,
    }
python
# orders/services.py
from catalog.services import get_product_details
def create_order(user_id: int, product_id: int, quantity: int = 1):
    product = get_product_details(product_id)
    total_price = product["price"] * quantity
    # Now use order repository to save

Now orders depends on the public interface of catalog, not its internal database details.


Local vs Shared Modules

Not every piece of code belongs to its own feature module. You often need some shared infrastructure.

Typical local (feature) modules:

Typical shared modules:

ModuleResponsibility
coreShared abstractions, base classes, errors
dbDatabase session setup, base models
authJWT handling, password utilities
configApplication configuration loading

You must be careful with shared modules:

Think of dependencies like this:

text
core, db, config
     ↑
  feature modules (users, catalog, orders, payments)

A feature module must not depend on another feature module’s internals. If a feature needs something from another feature, it should call its public API.


Enforcing Module Boundaries With Imports

Although Python cannot enforce modularity, you can create rules for imports.

For example:

A simple import pattern in code:

python
# Good: use public service
from catalog.services import get_product_details
# Bad: direct access to persistence layer
from catalog.repository import get_product_by_id
from catalog.models import Product

If your project grows, you can add tools or custom scripts to scan imports and check that they obey rules.


Modular Monolith in a FastAPI Application

Even with one FastAPI app instance, you can structure routes by module.

Example main.py:

python
from fastapi import FastAPI
from app.users.api import router as users_router
from app.catalog.api import router as catalog_router
from app.orders.api import router as orders_router
from app.payments.api import router as payments_router
app = FastAPI(title="Modular Monolith Shop")
# Include routers per module
app.include_router(users_router, prefix="/users", tags=["users"])
app.include_router(catalog_router, prefix="/catalog", tags=["catalog"])
app.include_router(orders_router, prefix="/orders", tags=["orders"])
app.include_router(payments_router, prefix="/payments", tags=["payments"])

Each module defines its own routes.

Example app/orders/api.py:

python
from fastapi import APIRouter, Depends
from . import services
from .schemas import OrderCreate, OrderRead
router = APIRouter()
@router.post("/", response_model=OrderRead)
def create_order(order_in: OrderCreate):
    return services.create_order(order_in)

Here:

Data Management in a Modular Monolith

In a typical modular monolith, you still have one database. However, modules should:

Example high level mapping:

ModuleTables
usersusers, user_profiles
catalogproducts, categories
ordersorders, order_items, carts
paymentspayments, payment_attempts

Example: Simple SQLAlchemy Model per Module

app/catalog/models.py:

python
from sqlalchemy import Column, Integer, String, Numeric
from app.db.base import Base
class Product(Base):
    __tablename__ = "products"
    id = Column(Integer, primary_key=True)
    name = Column(String, nullable=False)
    price = Column(Numeric(10, 2), nullable=False)
    currency = Column(String(3), default="USD")

app/orders/models.py:

python
from sqlalchemy import Column, Integer, ForeignKey, Numeric
from app.db.base import Base
class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, nullable=False)  # refers to users.id logically
    total_price = Column(Numeric(10, 2), nullable=False)

You might reference other modules only with plain IDs, not with ORM relationships, to reduce tight coupling.


Communication Patterns Between Modules

Modules can communicate in multiple ways. In a modular monolith, you can choose the simplest pattern that still respects boundaries.

1. Direct Service Calls (Synchronous, In-Process)

Most common and simplest.

Example: orders needs to validate a user before creating an order.

python
# users/services.py
from .repository import get_user_by_id
def get_user(user_id: int):
    user = get_user_by_id(user_id)
    if not user:
        raise UserNotFoundError(user_id)
    return user
python
# orders/services.py
from users.services import get_user
def create_order(user_id: int, order_data):
    user = get_user(user_id)  # Direct in-process call
    # continue with order creation

Pros:

Cons:

2. Domain Events (In-Process, Possibly Asynchronous)

Instead of one module calling another directly, you can “raise an event” and let other modules react.

Example: when an order is paid, you raise an OrderPaid event. The notifications module listens and sends an email.

Pseudo-code:

python
# core/events.py
from dataclasses import dataclass
from typing import Callable, Dict, List, Type
@dataclass
class OrderPaid:
    order_id: int
_handlers: Dict[Type, List[Callable]] = {}
def subscribe(event_type: Type, handler: Callable):
    _handlers.setdefault(event_type, []).append(handler)
def publish(event):
    for handler in _handlers.get(type(event), []):
        handler(event)

orders/services.py:

python
from core.events import publish, OrderPaid
def mark_order_as_paid(order_id: int):
    # update DB, mark paid
    publish(OrderPaid(order_id=order_id))

notifications/handlers.py:

python
from core.events import subscribe, OrderPaid
from .services import send_order_paid_email
def on_order_paid(event: OrderPaid):
    send_order_paid_email(order_id=event.order_id)
# During app startup:
subscribe(OrderPaid, on_order_paid)

This pattern:

Advantages of a Modular Monolith

1. Simpler Operations Than Microservices

With a modular monolith you:

You avoid:

For beginners and small teams, this is a big win.

2. Better Structure Than a Spaghetti Monolith

You get:

You can reason “inside the orders module” without worrying about the whole world.

3. Easier Step Toward Microservices

If one module becomes huge or requires independent scaling, you can:

  1. Identify the module’s public API (already exists).
  2. Extract this API into its own service.
  3. Replace in-process calls with HTTP or messaging calls.

Less rewriting is needed if the module was already well isolated.


Common Mistakes in Modular Monoliths

1. Everything in One “Shared” Module

Putting too much in a common or shared module destroys boundaries.

Signs of trouble:

To avoid this:

2. Feature Modules Calling Each Other’s Internals

Example:

python
# Bad: orders imports catalog.repository directly
from catalog.repository import get_product_by_id

Instead, use a public function in catalog.services.

3. Shared Global State and Singletons Everywhere

If modules share a lot of global state, they become tightly coupled.

Use dependency injection or clearly defined parameters instead of global variables. For example, pass a database session explicitly or use FastAPI dependencies.


Example: Small Modular Monolith for a Task App

To make it more tangible, here is a very simple modular monolith for a task management API.

Folder structure:

text
app/
  main.py
  db/
    __init__.py
    base.py
    session.py
  users/
    __init__.py
    api.py
    models.py
    services.py
    repository.py
    schemas.py
  tasks/
    __init__.py
    api.py
    models.py
    services.py
    repository.py
    schemas.py
  core/
    __init__.py
    security.py
    errors.py

Each module has its own:

Sample Code: tasks/services.py

python
# app/tasks/services.py
from .repository import create_task_db, list_tasks_db
from .schemas import TaskCreate
from app.core.errors import NotFoundError
def create_task(user_id: int, task_in: TaskCreate):
    # Basic business rules
    if len(task_in.title) < 3:
        raise ValueError("Title is too short")
    task = create_task_db(user_id=user_id, task_in=task_in)
    return task
def list_tasks_for_user(user_id: int):
    tasks = list_tasks_db(user_id=user_id)
    if not tasks:
        # Maybe not an error, but you could signal if you want
        pass
    return tasks

Sample Code: tasks/api.py

python
# app/tasks/api.py
from fastapi import APIRouter, Depends
from . import services
from .schemas import TaskCreate, TaskRead
from app.users.dependencies import get_current_user
router = APIRouter()
@router.post("/", response_model=TaskRead)
def create_task(
    task_in: TaskCreate,
    current_user=Depends(get_current_user),
):
    return services.create_task(user_id=current_user.id, task_in=task_in)
@router.get("/", response_model=list[TaskRead])
def list_tasks(current_user=Depends(get_current_user)):
    return services.list_tasks_for_user(user_id=current_user.id)

The tasks module:

This is how modular monolith boundaries look in practice.


When to Use a Modular Monolith

A modular monolith is usually a good fit when:

You might consider microservices later when:

Until then, a well-structured modular monolith is often the fastest and safest path.


Practical Checklist for a Modular Monolith

Use this as a quick reference when designing your own modular monolith.

Modular Monolith Checklist

  • One deployable application and usually one database.
  • Codebase split into feature modules with their own folders.
  • Each feature module has:
    • HTTP routes (if needed)
    • Business logic
    • Data access
    • Data models (ORM, schemas)
  • Shared modules only for cross-cutting concerns.
  • Modules communicate only through public functions or events, not internal DB details.
  • Import rules:
    • Feature modules depend on shared modules.
    • Feature-feature dependencies go through public interfaces.
  • Database tables grouped logically by module.
  • No “god” utils or common module with everything inside.

If you follow these rules, you will have a backend that is:

Views: 9

Comments

Please login to add a comment.

Don't have an account? Register now!