Production engineering guide for modular monolith covering patterns, implementation strategies, and operational best practices.
A modular monolith is a software architecture approach where a single application is organized into distinct functional modules that maintain clear boundaries and dependencies, but remain deployed as one unit. This pattern is appropriate when you need bounded contexts within a single service while avoiding the complexity of distributed systems.
Use a consistent naming convention for modules based on business domains. For example, in a system with user management and order processing, create directories like user-service/ and order-processing/.
Structure your codebase with clear module boundaries:
src/
├── user-service/
│ ├── models/
│ ├── controllers/
│ ├── services/
│ └── repository/
├── order-processing/
│ ├── models/
│ ├── controllers/
│ ├── services/
│ └── repository/
└── shared/
├── utils/
└── exceptions/
Avoid circular dependencies between modules by using dependency inversion principles. When one module needs to access another, define interfaces in the shared layer or use dependency injection patterns.
Modules communicate through well-defined APIs using either direct function calls or message passing. For internal communication, prefer synchronous function calls within the same process:
# user_service.py
from order_processing import get_orders_by_user
def get_user_profile(user_id):
user = fetch_user(user_id)
orders = get_orders_by_user(user_id)
return {**user, 'orders': orders}
For inter-module communication, use a message broker like RabbitMQ or Kafka:
# config.yaml
message_broker:
host: localhost
port: 5672
exchanges:
- name: user_events
type: topic
Create module-specific configuration files that define their dependencies and behavior:
# user-service/config.yaml
database:
url: postgres://localhost:5432/user_db
pool_size: 10
logging:
level: INFO
format: json
Use a dependency management system like pip or npm to track module dependencies. For Python, define requirements in separate files:
# user-service/requirements.txt
sqlalchemy==1.4.23
pydantic==1.8.2
Each module should have its own error types and handling:
class UserNotFoundError(Exception):
def __init__(self, user_id):
self.user_id = user_id
super().__init__(f"User {user_id} not found")
def get_user(user_id):
try:
return database.get_user(user_id)
except DatabaseError:
raise UserNotFoundError(user_id)
Use a deployment tool that supports module-level deployments. For example, with Docker Compose:
# docker-compose.yml
version: '3.8'
services:
user-service:
build: ./user-service
ports:
- "8001:8000"
order-processing:
build: ./order-processing
ports:
- "8002:8000"
Each module should expose its own metrics endpoint:
# In each module's HTTP handler
@app.route('/metrics')
def get_metrics():
return jsonify({
'request_count': request_counter,
'error_count': error_counter,
'response_time': response_timer
})
When operations span multiple modules, implement distributed transaction patterns:
# Example using saga pattern
def process_order(user_id, items):
order_id = create_order_in_order_processing(user_id, items)
try:
# Update inventory in inventory module
update_inventory(items)
# Charge user in payment module
charge_user(user_id, calculate_total(items))
# Confirm order in order processing
confirm_order(order_id)
except Exception as e:
# Rollback in reverse order
rollback_payment(user_id, calculate_total(items))
rollback_inventory(items)
raise
Set up structured logging for each module with consistent log formats:
import logging
logger = logging.getLogger('user_service')
logger.setLevel(logging.INFO)
handler = logging.StreamHandler()
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
Each module should have its own test suite:
# user_service/test_user_service.py
import unittest
from user_service import get_user
class TestUserService(unittest.TestCase):
def test_get_user_exists(self):
user = get_user(123)
self.assertEqual(user['id'], 123)
if __name__ == '__main__':
unittest.main()
Use code organization patterns to enforce boundaries:
# user_service/__init__.py
from .controllers import user_router
from .services import UserService
# Export only the public API
__all__ = ['user_router', 'UserService']
Each module should have its own database schema or schema namespace:
-- user-service/schema.sql
CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
Use environment variables or configuration files that are scoped to each module:
# Environment variables for user service
USER_SERVICE_DB_URL=postgresql://localhost:5432/user_db
USER_SERVICE_LOG_LEVEL=INFO
Use build tools to prevent circular imports:
# Makefile
check-modules:
pip-check-reqs user-service/
pip-check-reqs order-processing/
Each module should have its own deployment configuration:
# kubernetes/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: user-service:latest
ports:
- containerPort: 8000
Each module should expose health check endpoints:
@app.route('/health')
def health_check():
# Check database connectivity
db_status = check_database()
# Check dependencies
dep_status = check_dependencies()
if db_status and dep_status:
return jsonify({'status': 'healthy'})
else:
return jsonify({'status': 'unhealthy'}), 503
Each module should manage its own secrets:
# config/secrets.yaml
user-service:
database_password: "secret_password"
api_key: "api_key_value"
Implement caching strategies specific to each module:
from functools import lru_cache
@lru_cache(maxsize=128)
def get_user_cached(user_id):
return fetch_user(user_id)
Each module should track its own metrics:
from prometheus_client import Counter, Histogram
request_count = Counter('user_service_requests_total', 'Total requests')
response_time = Histogram('user_service_request_duration_seconds', 'Request duration')
Each module should enforce its own rate limits:
from flask_limiter import Limiter
limiter = Limiter(
key_func=lambda: request.remote_addr,
default_limits=["100 per hour"]
)
Each module should manage its own authentication logic:
def authenticate_user(token):
# Validate token against module-specific requirements
if not verify_token(token):
raise AuthenticationError("Invalid token")
return get_user_from_token(token)
Configure code coverage for individual modules:
# .coveragerc
[run]
source = user-service/
omit =
*/tests/*
*/migrations/*
Each module should have independent CI/CD workflows:
# .github/workflows/user-service.yml
name: User Service CI
on: [push]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Run tests
run: python -m pytest user-service/
Use package managers to track dependencies for each module:
{
"name": "user-service",
"dependencies": {
"express": "^4.18.0",
"sequelize": "^6.20.0"
}
}
Each module should manage its own versioning strategy:
# package.json
{
"name": "user-service",
"version": "1.2.3",
"description": "User management service"
}
Set up monitoring tools specific to each module:
# prometheus.yml
scrape_configs:
- job_name: 'user-service'
static_configs:
- targets: ['localhost:8001']
Each module should support independent rollbacks:
# Rollback user service to previous version
docker run --rollback user-service:v1.2.1
Each module should have its own documentation:
# User Service
This module handles user-related operations including registration, authentication, and profile management.
## API Endpoints
- GET /users/{id}
- POST /users
Each module should implement its own security measures:
from flask import request
from functools import wraps
def require_auth(f):
@wraps(f)
def decorated_function(*args, **kwargs):
token = request.headers.get('Authorization')
if not authenticate_user(token):
return {'error': 'Unauthorized'}, 401
return f(*args, **kwargs)
return decorated_function
Each module should handle its own database migrations:
# Run migrations for user service
python -m alembic -c user-service/alembic.ini upgrade head
Each module should define its own backup procedures:
# Backup user database
pg_dump -h localhost -U user_db_user user_db > backup_user_service.sql
Each module should have independent recovery plans:
# disaster-recovery.yaml
user-service:
backup_location: s3://backups/user-service/
recovery_time_objective: 1 hour
recovery_point_objective: 15 minutes
The sharp edges of modular monoliths include silent dependency violations that only surface under load, incorrect configuration scoping that breaks module isolation, and the tendency to treat modules as separate services without proper boundary enforcement. Module boundaries become fragile when not strictly enforced through code organization, CI checks, and deployment practices.
This page was rewritten on 15 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy