25.2. Modular Monoliths
Table of Contents
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:
- One big application
- All features share the same project, same process, often the same database schema
- Code can directly call any other code
The problem is not being “single” but being entangled. Typical symptoms:
- Any module can import any other module.
- Circular dependencies pop up.
- A change in one feature accidentally breaks many others.
- You cannot understand the system without understanding everything.
You still deploy and run only one artifact, like:
uvicorn app.main:appor
python manage.py runserverbut internally it is a mess.
Modular Monolith
A modular monolith is also:
- One application
- One process
- Usually one database
But:
- Code is intentionally split into modules (also called components or bounded contexts).
- Each module has its own data models, logic, and API that other modules use.
- Modules communicate only in controlled ways.
Think of it as:
- Physically one building
- But with clear rooms, walls, and doors
- People can only enter through the doors, not break through the walls
Microservices
Microservices are:
- Many small applications (services)
- Each service has its own process, often its own database
- Services talk through the network (HTTP, message queues, etc.)
- Each service is deployed separately
This gives:
- Strong isolation
- Independent scaling and deployments
But also:
- Network overhead
- More operational complexity (monitoring, logging, deployment, failures between services)
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:
- Simplicity of deployment
One container, one server process, one CI/CD pipeline. - Separation of concerns
Modules encapsulate different parts of the domain. - Maintainability
Reason about each module with minimal knowledge of others. - Option to evolve to microservices later
If a module starts to grow or needs to scale separately, you can extract it as a service.
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:
| Module | Responsibility |
|---|---|
users | User accounts, profiles, authentication support |
catalog | Products, categories, product search |
orders | Shopping cart, orders, order history |
payments | Payment intents, payment confirmations |
notifications | Emails, SMS, push notifications |
All of these live in one codebase and run inside one FastAPI application.
A minimal folder structure could look like this:
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 internallyEach module is like a small, internal service with:
- Its own models
- Its own business logic in
services.py - Its own HTTP routes in
api.py(if it exposes endpoints) - Its own data access layer in
repository.py
Defining Clear Module Boundaries
The boundary of a module is extremely important. It defines:
- What is inside the module:
Entities, business logic, repositories. - What is exposed to others:
Public functions, classes, or endpoints. - What is hidden:
Internal helpers, implementation details, private models.
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:
- Use clear naming and package structure
- Put all public functions in
api.pyorservices.py - Avoid importing internal parts of other modules
Example: Orders Using Catalog Only Through a Service Function
Imagine that the orders module needs product information from the catalog module.
Bad design:
# 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:
# 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,
}# 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 saveNow 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:
usersorderscatalogpayments
Typical shared modules:
| Module | Responsibility |
|---|---|
core | Shared abstractions, base classes, errors |
db | Database session setup, base models |
auth | JWT handling, password utilities |
config | Application configuration loading |
You must be careful with shared modules:
- They must not become a place to put “everything”.
- They should not depend on feature modules.
For example,coreshould not importorders.
Think of dependencies like this:
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:
orderscan import from:orders.*(itself)core,db,configcatalog.services(public API)ordersmust not import from:catalog.repositorycatalog.models
A simple import pattern in code:
# 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 ProductIf 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:
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:
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:
- HTTP layer per module is local to that module.
- Service functions encapsulate business logic.
- Other modules do not call
api.pydirectly. They call services or domain functions.
Data Management in a Modular Monolith
In a typical modular monolith, you still have one database. However, modules should:
- Have separate tables or schemas that logically belong to them.
- Avoid cross-module foreign keys if possible, or at least control them carefully.
- Avoid having one giant “everything” table.
Example high level mapping:
| Module | Tables |
|---|---|
users | users, user_profiles |
catalog | products, categories |
orders | orders, order_items, carts |
payments | payments, payment_attempts |
Example: Simple SQLAlchemy Model per Module
app/catalog/models.py:
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:
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.
# 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# 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 creationPros:
- Very fast
- Simple to debug
- No network
Cons:
- Modules are still somewhat coupled at runtime.
But this is fine inside one process.
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:
# 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:
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:
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:
- Keeps modules loosely coupled
- Still uses simple function calls in the same process
- Looks similar to what you might later use in an event-driven architecture
Advantages of a Modular Monolith
1. Simpler Operations Than Microservices
With a modular monolith you:
- Build one Docker image
- Deploy one app
- Have one place to configure logging, security, and monitoring
You avoid:
- Many separate deployments
- Distributed tracing across multiple services
- Network errors between services
For beginners and small teams, this is a big win.
2. Better Structure Than a Spaghetti Monolith
You get:
- Clear module folders and boundaries
- Fewer accidental cross-module dependencies
- Easier refactoring
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:
- Identify the module’s public API (already exists).
- Extract this API into its own service.
- 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:
- Most imports point to
common,utils, orcore. - Feature logic leaks into shared modules.
- Feature modules have almost no code.
To avoid this:
- Only put truly cross-cutting concerns in shared modules, such as:
- Logging configuration
- Database session management
- Error base classes
- Keep domain-specific logic inside feature modules.
2. Feature Modules Calling Each Other’s Internals
Example:
# 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:
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.pyEach module has its own:
api.py: FastAPI routers for that modulemodels.py: ORM modelsrepository.py: Database operationsservices.py: Business logicschemas.py: Pydantic models for requests / responses
Sample Code: tasks/services.py
# 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 tasksSample Code: tasks/api.py
# 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:
- Knows about users only through
get_current_userdependency. - Does not know user storage details.
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 are a small team or a solo developer.
- You are building an MVP or early version of a product.
- Your domain is still changing often.
- You want to learn good architecture but not fight distributed systems from day one.
You might consider microservices later when:
- Your team is large and organized around separate features.
- Different parts of your system need very different scaling patterns.
- You need strong isolation for legal or business reasons.
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”
utilsorcommonmodule with everything inside.
If you follow these rules, you will have a backend that is:
- Easier to maintain
- Easier to evolve
- Easier to scale into more complex architectures when needed
Views: 9
KAHIBARO