Modular Monolith: Single Deployable with Module Boundaries
How to build a modular monolith with strict internal module boundaries. Covers module isolation, shared kernel, inter-module communication, and migration to microservices.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
A modular monolith is a single deployable application with strict internal module boundaries. Each module owns its data, exposes a clear API, and communicates with other modules only through well-defined contracts. Unlike a traditional monolith where code is tangled across layers, a modular monolith enforces isolation: modules can’t access each other’s database tables or internal classes. This gives you the simplicity of a single deployment (one build, one database, one process) with the organizational benefits of microservices (team ownership, independent modules, clear contracts). When the time comes, modules can be extracted into separate microservices with minimal refactoring.
When to Use
- Small to medium teams that want clean architecture without operational overhead
- Startups that need to move fast but plan for future microservices extraction
- Applications where deployment simplicity matters more than independent scaling
- Domains that can be clearly partitioned into bounded contexts
- Teams transitioning from monolith to microservices gradually
When NOT to Use
- Applications requiring independent scaling of different components
- Teams large enough that a single codebase creates merge conflicts
- Systems where different modules have very different availability requirements
- Real-time systems where cross-module latency must be sub-millisecond
Solution
Module structure (Python)
# order_module/__init__.py — Order module public API
from order_module.services import OrderService
from order_module.api import OrderAPI
__all__ = ["OrderService", "OrderAPI"]
# The module only exposes what's in __all__
# Internal classes are not importable from outside
# order_module/services.py — Order module internal implementation
from order_module.models import Order, OrderItem
from order_module.repository import OrderRepository
from shared_kernel.events import DomainEvent
from shared_kernel.ids import OrderId, CustomerId
from typing import Optional, List
class OrderService:
"""Internal service — not part of the module's public API."""
def __init__(self, repo: OrderRepository, event_bus: 'EventBus'):
self._repo = repo
self._event_bus = event_bus
def create_order(self, customer_id: CustomerId, items: List[OrderItem]) -> Order:
order = Order.create(customer_id, items)
self._repo.save(order)
self._event_bus.publish(OrderCreatedEvent(
order_id=order.id,
customer_id=customer_id,
total=order.total
))
return order
def get_order(self, order_id: OrderId) -> Optional[Order]:
return self._repo.find_by_id(order_id)
def cancel_order(self, order_id: OrderId) -> None:
order = self._repo.find_by_id(order_id)
if order is None:
raise ValueError(f"Order {order_id} not found")
order.cancel()
self._repo.save(order)
self._event_bus.publish(OrderCancelledEvent(order_id=order_id))
# order_module/api.py — Order module public API (the contract)
from order_module.services import OrderService
from shared_kernel.ids import OrderId, CustomerId
from typing import Optional, List, Dict, Any
class OrderAPI:
"""Public API of the Order module. Other modules use only this."""
def __init__(self, service: OrderService):
self._service = service
def create_order(self, customer_id: str, items: List[Dict[str, Any]]) -> Dict[str, Any]:
customer = CustomerId.from_string(customer_id)
order_items = [OrderItem.from_dict(item) for item in items]
order = self._service.create_order(customer, order_items)
return order.to_dict()
def get_order(self, order_id: str) -> Optional[Dict[str, Any]]:
order = self._service.get_order(OrderId.from_string(order_id))
return order.to_dict() if order else None
def cancel_order(self, order_id: str) -> None:
self._service.cancel_order(OrderId.from_string(order_id))
Shared kernel
# shared_kernel/ids.py — Shared value objects used across modules
from dataclasses import dataclass
import uuid
@dataclass(frozen=True)
class OrderId:
value: str
@staticmethod
def from_string(s: str) -> 'OrderId':
return OrderId(s)
@staticmethod
def generate() -> 'OrderId':
return OrderId(str(uuid.uuid4()))
@dataclass(frozen=True)
class CustomerId:
value: str
@staticmethod
def from_string(s: str) -> 'CustomerId':
return CustomerId(s)
@dataclass(frozen=True)
class ProductId:
value: str
@staticmethod
def from_string(s: str) -> 'ProductId':
return ProductId(s)
# shared_kernel/events.py — Event bus for inter-module communication
from typing import Callable, Dict, List, Type, Any
from dataclasses import dataclass
import logging
logger = logging.getLogger(__name__)
@dataclass
class DomainEvent:
"""Base class for all domain events."""
pass
class EventBus:
"""In-process event bus for inter-module communication.
Modules communicate through events, not direct calls."""
def __init__(self):
self._handlers: Dict[Type[DomainEvent], List[Callable]] = {}
def subscribe(self, event_type: Type[DomainEvent], handler: Callable):
if event_type not in self._handlers:
self._handlers[event_type] = []
self._handlers[event_type].append(handler)
def publish(self, event: DomainEvent):
event_type = type(event)
handlers = self._handlers.get(event_type, [])
for handler in handlers:
try:
handler(event)
except Exception as e:
logger.error(f"Event handler failed for {event_type.__name__}: {e}")
# Event definitions
@dataclass
class OrderCreatedEvent(DomainEvent):
order_id: str
customer_id: str
total: float
@dataclass
class OrderCancelledEvent(DomainEvent):
order_id: str
@dataclass
class PaymentProcessedEvent(DomainEvent):
order_id: str
amount: float
Module wiring
# app.py — Application composition root
from order_module import OrderService, OrderAPI
from order_module.repository import OrderRepository
from customer_module import CustomerService, CustomerAPI
from customer_module.repository import CustomerRepository
from inventory_module import InventoryService, InventoryAPI
from inventory_module.repository import InventoryRepository
from shared_kernel.events import EventBus, OrderCreatedEvent
from database import Database
def create_app():
# Shared infrastructure
db = Database("postgresql://localhost/shop")
event_bus = EventBus()
# Order module
order_repo = OrderRepository(db)
order_service = OrderService(order_repo, event_bus)
order_api = OrderAPI(order_service)
# Customer module
customer_repo = CustomerRepository(db)
customer_service = CustomerService(customer_repo, event_bus)
customer_api = CustomerAPI(customer_service)
# Inventory module
inventory_repo = InventoryRepository(db)
inventory_service = InventoryService(inventory_repo, event_bus)
inventory_api = InventoryAPI(inventory_service)
# Cross-module event subscriptions
# When an order is created, reserve inventory
event_bus.subscribe(OrderCreatedEvent, inventory_service.reserve_items)
return {
"orders": order_api,
"customers": customer_api,
"inventory": inventory_api,
}
Module isolation enforcement (Python)
# enforce_boundaries.py — static analysis to enforce module boundaries
import ast
import os
from pathlib import Path
from typing import Set, Dict, List
class ModuleBoundaryChecker:
"""Check that modules don't import each other's internals."""
def __init__(self, root: str, modules: Dict[str, Set[str]]):
self.root = Path(root)
self.modules = modules # {module_name: {allowed_imports}}
def check(self) -> List[str]:
violations = []
for py_file in self.root.rglob("*.py"):
rel_path = py_file.relative_to(self.root)
parts = rel_path.parts
# Determine which module this file belongs to
current_module = parts[0] if parts else None
if current_module not in self.modules:
continue
# Parse imports
tree = ast.parse(py_file.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom):
module = node.module or ""
for import_name in [alias.name for alias in node.names]:
full_import = f"{module}.{import_name}" if module else import_name
# Check if importing from another module's internals
for other_module in self.modules:
if other_module == current_module:
continue
if full_import.startswith(other_module) and \
full_import not in self.modules[other_module]:
violations.append(
f"{rel_path}: imports {full_import} from "
f"{other_module} (not in public API)"
)
return violations
# Usage
checker = ModuleBoundaryChecker("src/", {
"order_module": {"order_module.OrderService", "order_module.OrderAPI"},
"customer_module": {"customer_module.CustomerService", "customer_module.CustomerAPI"},
"inventory_module": {"inventory_module.InventoryService", "inventory_module.InventoryAPI"},
})
violations = checker.check()
for v in violations:
print(f"VIOLATION: {v}")
Java modular monolith with Spring
// OrderModule.java — Spring module with package-private internals
package com.shop.order;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
// Public API — only what's exposed to other modules
@Configuration
@Import(OrderPublicConfig.class)
public class OrderModule {
@Bean
public OrderFacade orderFacade(OrderService service) {
return new OrderFacade(service);
}
}
// OrderFacade — the public interface other modules use
package com.shop.order.api;
public interface OrderFacade {
OrderDto createOrder(String customerId, List<OrderItemDto> items);
OrderDto getOrder(String orderId);
void cancelOrder(String orderId);
}
// Internal service — package-private, not accessible outside
package com.shop.order.internal;
import org.springframework.stereotype.Service;
import org.springframework.context.ApplicationEventPublisher;
@Service
class OrderServiceImpl implements OrderService {
private final OrderRepository repository;
private final ApplicationEventPublisher eventPublisher;
OrderServiceImpl(OrderRepository repository,
ApplicationEventPublisher eventPublisher) {
this.repository = repository;
this.eventPublisher = eventPublisher;
}
@Override
public Order createOrder(CustomerId customerId, List<OrderItem> items) {
var order = Order.create(customerId, items);
repository.save(order);
eventPublisher.publishEvent(new OrderCreatedEvent(
order.getId(), customerId, order.getTotal()
));
return order;
}
}
Database schema isolation
-- Each module owns its tables — prefixed by module name
-- No cross-module foreign keys (use reference IDs instead)
-- Order module tables
CREATE TABLE order_orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL, -- reference, not FK
status VARCHAR(50) NOT NULL,
total DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE order_items (
id UUID PRIMARY KEY,
order_id UUID NOT NULL REFERENCES order_orders(id),
product_id UUID NOT NULL, -- reference, not FK
quantity INT NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
-- Customer module tables
CREATE TABLE customer_customers (
id UUID PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
status VARCHAR(50) NOT NULL
);
-- Inventory module tables
CREATE TABLE inventory_products (
id UUID PRIMARY KEY,
name VARCHAR(255) NOT NULL,
stock INT NOT NULL,
price DECIMAL(10, 2) NOT NULL
);
-- No FK from order_orders.customer_id to customer_customers.id
-- The relationship is enforced in application code, not the database
Variants
Module-as-package (lightweight isolation)
# Lightweight: modules are Python packages with __all__ controlling exports
# No separate database schemas, but code-level isolation
# catalog/__init__.py
from catalog.service import CatalogService
__all__ = ["CatalogService"]
# Other modules can only import CatalogService
# from catalog import CatalogService ✅
# from catalog.service import _internal_helper ❌ (convention, not enforced)
Module-as-subsystem (strict isolation with separate schemas)
-- Each module gets its own database schema
CREATE SCHEMA IF NOT EXISTS order_module;
CREATE SCHEMA IF NOT EXISTS customer_module;
CREATE SCHEMA IF NOT EXISTS inventory_module;
-- Tables are fully isolated
CREATE TABLE order_module.orders (...);
CREATE TABLE customer_module.customers (...);
CREATE TABLE inventory_module.products (...);
-- Modules access only their own schema
GRANT USAGE ON SCHEMA order_module TO order_app_role;
GRANT USAGE ON SCHEMA customer_module TO customer_app_role;
Best Practices
-
For a deeper guide, see Microservices Architecture — When to Use and When Not To.
-
Define a public API for each module — only expose what other modules need
-
Use an event bus for cross-module communication — don’t call other modules directly
-
No cross-module database access — each module owns its tables, use reference IDs not FKs
-
Enforce boundaries with static analysis — catch violations in CI before they reach production
-
Keep the shared kernel small — only share value objects, IDs, and event definitions
-
One team per module — avoid multiple teams modifying the same module
-
Design contracts carefully — the module API is the contract; changing it breaks consumers
-
Plan for extraction — design modules so they can be extracted to microservices later
Common Mistakes
- No boundary enforcement: modules import each other’s internals. The monolith becomes a tangled mess. Use static analysis to enforce boundaries.
- Shared database tables: modules directly query each other’s tables. Extracting a module to a microservice requires rewriting all cross-module queries.
- Direct service calls:
OrderServicecallsCustomerServicedirectly. Use events or a thin facade instead. - Bloated shared kernel: putting business logic in the shared kernel. Keep it to IDs, events, and value objects only.
- No module ownership: anyone can modify any module. Modules lose their coherence. Assign clear ownership.
FAQ
What is a modular monolith?
A single deployable application with strict internal module boundaries. Each module owns its data, exposes a public API, and communicates with other modules through events or facades. It’s a monolith in deployment but modular in design.
How is a modular monolith different from microservices?
A modular monolith deploys as one unit with one database. Microservices deploy independently with separate databases. A modular monolith is simpler to operate but can’t scale modules independently. Modules can be extracted to microservices later.
How do modules communicate in a modular monolith?
Through events (event bus) or public APIs (facades). Module A publishes an event when something happens; Module B subscribes and reacts. For synchronous queries, Module A calls Module B’s facade — never its internal service.
Should modules share a database?
They can share a database server but should have separate schemas or table prefixes. No cross-module foreign keys. This allows extracting a module to its own database later without changing table relationships.
When should I extract a module to a microservice?
When the module needs independent scaling, has a different availability requirement, or is owned by a team that would benefit from independent deployment. The modular monolith makes extraction easier because boundaries already exist.
Related Resources
Strangler Fig: Gradually Replace Legacy by Intercepting
How to gradually replace a legacy system by intercepting routes and routing traffic to new services. Covers strangler fig, incremental migration, and cutover.
PatternAnti-Corruption Layer: Isolate Legacy with Adapters
How to isolate legacy systems with translation adapters. Covers ACL facade, domain translation, bidirectional mapping, and gradual legacy replacement.
PatternBackends for Frontends: Dedicated Backend per Client Type
How to create dedicated backends per client type. Covers BFF for web, mobile, and desktop. Covers API aggregation, client-specific optimization, and GraphQL BFF.
PatternAmbassador: Offload Cross-Cutting Concerns to a Proxy
How to offload cross-cutting concerns to a proxy ambassador. Covers connection pooling, retry logic, circuit breaking, monitoring, and TLS termination for client services.
PatternSidecar Pattern: Extend Services with Companion Containers
How to extend services with companion containers for cross-cutting concerns. Covers sidecar containers, shared volumes, health probes, and service mesh sidecars.
GuideModular Monolith: Module Boundaries, Shared Kernel
Master modular monolith architecture: module boundaries, shared kernel, dependency rules, communication patterns, and incremental migration to microservices.