Modular Monolith Architecture: Why Smart Teams Are Rejecting Microservices
The microservices backlash is real. Companies like Shopify, Segment, and Amazon Prime Video are moving back to monoliths—not because they failed to scale, but because they discovered that logical boundaries matter more than distributed deployment. Here's how to build a modular monolith that ships faster and scales smarter.

The pendulum is swinging back. After a decade of reflexive microservices adoption, engineering teams are rediscovering a truth that was always there: most systems don't need the operational complexity of distributed architectures. What they need is disciplined modularity—and you can have that without deploying 47 services.
I've watched teams spend six months extracting microservices from a monolith, only to realize they've built a distributed monolith that's harder to debug and costs 3x more to run. The real problem wasn't the single deployment unit. It was the lack of boundaries.
The Microservices Tax Nobody Warned You About
When Segment moved from microservices back to a monolith, they weren't admitting defeat. They were acknowledging a fundamental truth: distributed systems have a tax, and you need to be big enough to afford it.
Here's what that tax looks like in practice:
Operational overhead: You need distributed tracing (Jaeger, Zipkin), service mesh (Istio, Linkerd), service discovery (Consul, etcd), centralized logging (ELK stack), and sophisticated monitoring. That's not just infrastructure cost—it's engineering time. One team I worked with spent 40% of their sprint capacity on "keeping the lights on" for their 23 microservices.
Network latency: Every service boundary is now a network call. What was a function call taking microseconds is now an HTTP request taking milliseconds. Multiply that across a request chain hitting 8 services, and you've added 200-500ms of latency before you've done any real work.
Data consistency: ACID transactions are gone. You're now in the world of eventual consistency, saga patterns, and compensating transactions. A simple e-commerce checkout that was 50 lines of transactional code becomes a distributed state machine with failure modes you didn't know existed. Consider this scenario: a customer places an order, the payment service successfully charges their card, but the inventory service fails to update stock levels due to a network partition. Now you've charged the customer but haven't reserved their items. You need compensating transactions to refund the payment, idempotency keys to prevent double-charging on retry, and saga orchestration to manage the whole flow. What was a single database transaction is now a multi-step distributed protocol with partial failure states at every step.
Debugging complexity: When something breaks, you're hunting through logs across multiple services, correlating request IDs, and trying to reconstruct what happened. The stack trace that used to point you directly to the problem now ends at a service boundary.
Amazon Prime Video's engineering team published a case study where they consolidated their distributed video quality analysis system into a monolith and reduced costs by 90% while improving performance. The microservices architecture was costing them $1,000 per stream analysis. The monolith brought it down to $100.
What Makes a Monolith "Modular"
A modular monolith isn't just a monolith with folders. It's a single deployable unit with enforced architectural boundaries. The key word is "enforced."
Here's what that means in practice:
Clear Module Boundaries
Each module owns its domain logic, data access, and internal state. Modules communicate through well-defined interfaces—not by reaching into each other's databases or calling internal functions.
// ❌ Bad: Direct database access across modules
class OrderService {
async createOrder(userId, items) {
// Reaching into the users database directly
const user = await db.users.findById(userId);
// Reaching into the inventory database
const inventory = await db.inventory.checkStock(items);
// ...
}
}
// ✅ Good: Communication through module interfaces
class OrderService {
constructor(userModule, inventoryModule) {
this.userModule = userModule;
this.inventoryModule = inventoryModule;
}
async createOrder(userId, items) {
// userModule.getUser returns Promise<User | null>
const user = await this.userModule.getUser(userId);
if (!user) {
throw new Error('User not found');
}
// inventoryModule.checkStock returns Promise<{ available: boolean, items: Array }>
const stockCheck = await this.inventoryModule.checkStock(items);
if (!stockCheck.available) {
throw new Error('Insufficient inventory');
}
// Now we can safely create the order
return await this.orderRepository.save({
userId: user.id,
items: stockCheck.items,
status: 'pending',
createdAt: new Date()
});
}
}
The difference is subtle but critical. In the second example, OrderService doesn't know or care how UserModule stores its data. That boundary makes it possible to extract UserModule into a separate service later without rewriting OrderService.
Enforcing Boundaries with Tooling
The challenge with modular monoliths is that module boundaries are conventions, not physical barriers. Without enforcement, developers will take shortcuts. Here's how to prevent that at development time:
Option 1: dependency-cruiser for comprehensive boundary enforcement
// .dependency-cruiser.js
module.exports = {
forbidden: [
{
name: 'no-cross-module-internals',
comment: 'Modules should not access other modules internal code',
severity: 'error',
from: { path: '^src/modules/([^/]+)/.+' },
to: {
path: '^src/modules/(?!$1)[^/]+/(domain|infrastructure|application)/.+',
pathNot: '^src/modules/[^/]+/api/index\\.(js|ts)$'
}
}
]
};
Run npx depcruise src --config .dependency-cruiser.js in your CI pipeline. This will fail the build with:
✖ error no-cross-module-internals: src/modules/orders/application/OrderService.js →
src/modules/users/infrastructure/UserRepository.js
Modules should not access other modules internal code
Option 2: ESLint rules for import restrictions
// .eslintrc.js
module.exports = {
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: ['**/modules/*/infrastructure/**', '**/modules/*/domain/**'],
message: 'Do not import from other modules internals. Use the public API only.'
}]
}]
}
};
This rule fails the build if any module imports from another module's infrastructure or domain folders. Only imports from the module's public API (typically an index.js that exports the interface) are allowed.
Option 3: TypeScript path mapping with namespace isolation
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@users": ["src/modules/users/api/index.ts"],
"@orders": ["src/modules/orders/api/index.ts"],
"@inventory": ["src/modules/inventory/api/index.ts"]
}
}
}
// This compiles - importing from public API
import { UserService } from '@users';
// This fails at compile time - path not mapped
import { UserRepository } from '../users/infrastructure/UserRepository';
TypeScript will refuse to compile if you try to import from unmapped paths, effectively making internal module code invisible to other modules.
Option 4: Dependency injection with validation
// Module registry that validates dependencies
class ModuleRegistry {
constructor() {
this.modules = new Map();
this.dependencies = new Map();
}
register(moduleName, moduleFactory, allowedDependencies = []) {
this.dependencies.set(moduleName, new Set(allowedDependencies));
this.modules.set(moduleName, moduleFactory);
}
initialize() {
const instances = new Map();
for (const [moduleName, factory] of this.modules) {
const deps = {};
const allowed = this.dependencies.get(moduleName);
for (const depName of allowed) {
if (!instances.has(depName)) {
throw new Error(
`Module ${moduleName} depends on ${depName} which hasn't been initialized`
);
}
deps[depName] = instances.get(depName);
}
instances.set(moduleName, factory(deps));
}
return instances;
}
}
// Usage - explicitly declare allowed dependencies
const registry = new ModuleRegistry();
registry.register('users', (deps) => new UserModule());
registry.register('inventory', (deps) => new InventoryModule());
registry.register('orders', (deps) =>
new OrderModule(deps.users, deps.inventory),
['users', 'inventory'] // Allowed dependencies
);
const modules = registry.initialize(); // Fails if circular or undeclared dependencies
This approach makes dependencies explicit and validated at startup. If a developer adds a new dependency without declaring it, the application won't start.
Separate Database Schemas (Within the Same Database)
You don't need separate databases to have data isolation. Use schemas or namespaces within a single database instance.
Python with SQLAlchemy - schema-based isolation:
# Python with SQLAlchemy - schema-based isolation
from sqlalchemy import create_engine, MetaData, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
Base = declarative_base()
# Users module owns the 'users' schema
users_metadata = MetaData(schema='users')
class User(Base):
__table_args__ = {'schema': 'users'}
id = Column(Integer, primary_key=True)
email = Column(String)
# Orders module owns the 'orders' schema
orders_metadata = MetaData(schema='orders')
class Order(Base):
__table_args__ = {'schema': 'orders'}
id = Column(Integer, primary_key=True)
user_id = Column(Integer) # Reference, not foreign key
# No foreign key constraints across schemas
# Referential integrity is enforced at the application level
This gives you the data isolation benefits of microservices (each module owns its schema) without the operational complexity of managing multiple databases. You still get ACID transactions when you need them, and you can use database-level tooling for backups, migrations, and monitoring.
For PostgreSQL, you can also use separate databases within the same cluster:
-- Create separate databases for each module
CREATE DATABASE users_db;
CREATE DATABASE orders_db;
CREATE DATABASE inventory_db;
-- Each module connects to its own database
-- Cross-database queries are not possible, enforcing boundaries
For MySQL, schema and database are synonymous, so:
CREATE SCHEMA users;
CREATE SCHEMA orders;
CREATE SCHEMA inventory;
-- Tables are namespaced
CREATE TABLE users.user (id INT PRIMARY KEY, email VARCHAR(255));
CREATE TABLE orders.order (id INT PRIMARY KEY, user_id INT);
The key principle: modules cannot use foreign key constraints across schema boundaries. If the orders schema needs to reference a user, it stores the user_id as a plain integer and validates the reference at the application level by calling the users module's API. This maintains the architectural boundary while keeping everything in one database instance.
Event-Driven Communication for Async Operations
Modules shouldn't call each other synchronously for everything. Use an event bus for operations that don't need immediate responses.
// Node.js with an in-process event emitter
const EventEmitter = require('events');
const eventBus = new EventEmitter();
// Orders module publishes events
class OrderService {
async createOrder(userId, items) {
const order = await this.saveOrder(userId, items);
// Publish event - fire and forget
eventBus.emit('order.created', {
orderId: order.id,
userId: userId,
items: items,
timestamp: new Date()
});
return order;
}
}
// Inventory module subscribes to events
class InventoryService {
constructor() {
eventBus.on('order.created', this.handleOrderCreated.bind(this));
}
async handleOrderCreated(event) {
await this.reserveStock(event.items);
}
}
// Email module subscribes to the same event
class EmailService {
constructor() {
eventBus.on('order.created', this.handleOrderCreated.bind(this));
}
async handleOrderCreated(event) {
await this.sendOrderConfirmation(event.userId, event.orderId);
}
}
This is identical to how microservices communicate via message queues, except it's in-process and synchronous by default. When you need durability or async processing, swap the EventEmitter for Redis Pub/Sub or a proper message queue—the module code doesn't change.
The Operational Complexity Comparison
Let's be concrete about what you're signing up for with each approach.
Modular Monolith Operations
Deployment: Single artifact (Docker image, JAR file, etc.). One deployment pipeline. Rollback is atomic—if something breaks, you roll back the entire application to the last known good state.
Monitoring: One application to monitor. Standard APM tools (New Relic, DataDog, Application Insights) work out of the box. You're looking at request rates, error rates, and response times for a single service.
Debugging: Stack traces work. When an error occurs, you get a complete trace from the entry point to the failure. Logs are in one place. You can attach a debugger and step through the code.
Infrastructure: One load balancer, one set of application servers, one database (with multiple schemas). Horizontal scaling means adding more instances of the same application.
Team coordination: Teams need to coordinate on deployment windows, but they're working in the same codebase with the same build pipeline. Merge conflicts are visible immediately.
Microservices Operations
Deployment: 10-50+ separate deployment pipelines. Each service can deploy independently, but you need to manage compatibility matrices. Service A version 2.3 works with Service B version 1.8 but not 1.7. Rollback is complex—if a deployment causes issues in downstream services, you need to identify which service to roll back.
Monitoring: Distributed tracing is mandatory. You need to correlate request IDs across services, aggregate logs from multiple sources, and build dashboards that show the health of the entire system, not just individual services. Tools like Jaeger or Zipkin become essential, not optional.
Debugging: When a request fails, you're looking at logs from 5-10 services. You need to reconstruct the request flow, identify where it failed, and determine if the failure was due to the service itself or a dependency. Distributed debugging tools help, but they're never as good as a local debugger.
Infrastructure: Service mesh (Istio, Linkerd), service discovery (Consul, Kubernetes DNS), API gateway, multiple databases, message queues, and caching layers. Each service needs its own resources, and you're managing inter-service networking, security policies, and rate limiting.
Team coordination: Teams can deploy independently, but they need to maintain API contracts, version their interfaces, and communicate breaking changes. A change in Service A's API requires coordinating with all downstream consumers.
The operational complexity gap is real. Google's research identified this as one of the five principal challenges with microservices. For teams under 30-40 developers, the coordination overhead of distributed systems often exceeds the benefits.
Decision Matrix: Which Architecture Should You Choose?
Here's a concrete framework for making the modular monolith vs microservices decision:
Choose Modular Monolith When:
Team size < 50 engineers: With smaller teams, the coordination overhead of a shared codebase is lower than the operational overhead of distributed systems. One deployment pipeline, one codebase, unified testing.
Request volume < 10,000 requests/second: A well-optimized modular monolith can handle this load on commodity hardware. Horizontal scaling (adding more instances) works fine at this scale.
Scaling needs vary by < 10x: Your checkout flow gets 5,000 req/s while your admin panel gets 500 req/s. This is easily handled by tuning thread pools or worker processes within a single application.
Deployment cadence < 10 deploys/day: If you're deploying a few times per day, coordinating a single deployment across teams is manageable. The overhead of managing multiple service deployments isn't justified.
ACID transactions are critical: You need strong consistency guarantees for financial transactions, inventory management, or any domain where eventual consistency creates business problems.
Team is still learning the domain: Early in a product's life, domain boundaries aren't clear. A monolith lets you refactor cheaply. Moving code between modules is trivial; moving it between services requires API changes and coordination.
Limited operational maturity: No dedicated platform team, basic monitoring, manual deployment processes. Adding distributed systems complexity will multiply your problems.
Choose Microservices When:
Team size > 50 engineers: Merge conflicts, test suite runtime (30+ minutes), and deployment coordination become bottlenecks. Independent deployment becomes a productivity multiplier worth the operational cost.
Request volume > 50,000 requests/second: At this scale, you need independent scaling. Your authentication service might need 100 instances while your reporting service needs 5. The infrastructure cost savings justify the operational complexity.
Scaling needs vary by > 100x: Your public API gets 1M req/s, your admin dashboard gets 10K req/s. Running them together wastes resources. Independent scaling has clear ROI.
Deployment independence is blocking teams weekly: Team A is waiting for Team B's deploy to finish, or can't deploy because Team C broke the build. This happens > 3 times per week per team.
Regulatory isolation required: PCI DSS compliance for payment processing, HIPAA for healthcare data, or SOX for financial data. Physical isolation simplifies compliance and reduces audit scope.
Polyglot requirements: Your ML team needs Python/TensorFlow, your API needs Go for performance, your web app is in Node.js. Technology diversity has legitimate technical justification (not just preference).
Operational maturity is high: You have a platform team, mature CI/CD, comprehensive monitoring (distributed tracing, centralized logging), experience running distributed systems, and clear service ownership.
Domain boundaries are stable: You've been operating for 2+ years, domain boundaries are well understood, and extracting services won't require frequent refactoring.
Quantifiable Thresholds Summary:
| Metric | Modular Monolith | Microservices |
|---|---|---|
| Team Size | < 50 engineers | > 50 engineers |
| Request Volume | < 10K req/s | > 50K req/s |
| Scaling Variance | < 10x difference | > 100x difference |
| Deploy Frequency | < 10/day | > 20/day |
| Deploy Independence Blocking | < 1x/week | > 3x/week |
| Operational Maturity | Basic monitoring, manual deploys | Platform team, distributed tracing |
| Domain Stability | < 2 years, evolving | > 2 years, stable |
When Microservices ARE the Right Choice
Microservices aren't wrong—they're just expensive. Here's when that expense is justified:
You Have 50+ Engineers
Once you cross the threshold of about 50 engineers working on the same system, coordination costs in a shared codebase start to exceed the operational costs of distributed systems. Merge conflicts become daily occurrences, deployment windows require coordinating across multiple teams, and the test suite takes 30+ minutes to run.
At this scale, the ability for teams to deploy independently without waiting for other teams becomes a genuine productivity multiplier. Shopify, with 2,000+ developers, uses this as their primary criterion: if a team is blocked on another team's deployment more than once per week, that's a signal to consider extraction.
Truly Independent Scaling Requirements
Your checkout service handles 10,000 requests per second during flash sales, but your admin dashboard gets 50 requests per hour. Deploying them together means you're scaling the admin dashboard to handle checkout traffic, which is wasteful.
With microservices, you can run 50 instances of the checkout service and 2 instances of the admin service. That's a legitimate infrastructure cost saving.
The key word is "truly." If your scaling needs differ by 2-3x, you can handle that with a modular monolith by tuning thread pools or using read replicas. If they differ by 100x, microservices start to make economic sense.
Organizational Deploy Independence is Critical
You have a team in San Francisco and a team in Berlin, both working on different parts of the product. They work in different time zones and have different release schedules. Forcing them to coordinate deployments creates a bottleneck.
Microservices allow each team to deploy on their own schedule without coordination. This is particularly valuable for organizations with distributed teams or those that have acquired other companies and need to integrate systems gradually.
Technology Diversity for Legitimate Reasons
Your machine learning team wants to use Python with TensorFlow, but your web application is in Node.js. Your video transcoding pipeline needs to use Go for performance, but your API is in Java. Microservices let each team use the best tool for their domain.
This is a real benefit, but it's also a trap. Technology diversity sounds great until you're managing five different deployment pipelines, three different monitoring stacks, and two different database systems. Make sure the benefit outweighs the cost.
Regulatory or Security Isolation
Your payment processing needs PCI DSS compliance, but the rest of your application doesn't. Isolating payment processing into a separate service with strict network boundaries and audit logging makes compliance easier.
Similarly, if you handle healthcare data (HIPAA) or financial data (SOX), physical isolation of those services can simplify compliance and reduce the scope of audits.
This is one of the strongest arguments for microservices. Regulatory requirements often force architectural boundaries that map naturally to service boundaries.
You Already Have the Operational Maturity
If you already have a platform team, mature CI/CD pipelines, comprehensive monitoring, and experience running distributed systems, the incremental cost of microservices is lower. Companies that have already paid the "distributed systems tax" for some services can more easily justify additional services.
Conversely, if you're still figuring out basic monitoring and deployment automation, adding distributed systems complexity will multiply your problems.
The pattern I've observed: companies that successfully use microservices started with a monolith, grew to the point where it became a bottleneck, and then selectively extracted services. Companies that started with microservices often regret it until they grow into the architecture.
Migration Timing: When to Split
The best time to move from a modular monolith to microservices is when you have a specific, measurable problem that microservices solve.
Don't split because:
- "We might need to scale in the future"
- "Microservices are best practice"
- "Our monolith is getting big"
- "We want teams to move faster"
Do split when:
- One module has 10x the traffic of others and needs independent scaling
- Deployment coordination is blocking multiple teams weekly
- A module needs a different technology stack for legitimate technical reasons
- Regulatory requirements demand physical isolation
Concrete migration timeline:
Months 1-2: Preparation
- Identify the module to extract based on scaling needs or team blocking
- Ensure module boundaries are clean (no direct database access from other modules)
- Add adapter layer in monolith to route requests internally or externally
- Set up monitoring and metrics for the target module
Months 3-4: Service development
- Build the new service with the same API as the module's public interface
- Deploy service to staging environment
- Run parallel tests (send requests to both monolith and service, compare results)
- Fix any discrepancies in behavior
Month 5: Gradual rollout
- Week 1: Route 1% of production traffic to new service
- Week 2: Increase to 10% if no errors
- Week 3: Increase to 50%
- Week 4: Increase to 100%
- Monitor error rates, latency, and data consistency at each step
Month 6: Cleanup
- Remove old module code from monolith
- Update documentation and runbooks
- Train team on operating the new service
- Conduct retrospective on the extraction process
Segment's story is instructive. They started with microservices, realized the operational complexity was killing their velocity, moved back to a monolith, and then selectively extracted services when they had clear scaling needs. That's the pattern: start simple, add complexity only when you have evidence it's needed.
Building a Modular Monolith in Practice
Here's a realistic example of a modular monolith structure in Node.js:
// Project structure
// src/
// modules/
// users/
// domain/
// application/
// infrastructure/
// api/
// orders/
// domain/
// application/
// infrastructure/
// api/
// inventory/
// domain/
// application/
// infrastructure/
// api/
// shared/
// events/
// database/
// app.js
// src/modules/users/api/routes.js
const express = require('express');
const router = express.Router();
class UserRoutes {
constructor(userService) {
this.userService = userService;
}
setupRoutes() {
router.post('/users', async (req, res) => {
const user = await this.userService.createUser(req.body);
res.json(user);
});
router.get('/users/:id', async (req, res) => {
const user = await this.userService.getUser(req.params.id);
res.json(user);
});
return router;
}
}
// src/modules/users/application/userService.js
class UserService {
constructor(userRepository, eventBus) {
this.userRepository = userRepository;
this.eventBus = eventBus;
}
async createUser(userData) {
const user = await this.userRepository.save(userData);
this.eventBus.emit('user.created', {
userId: user.id,
email: user.email
});
return user;
}
async getUser(userId) {
return this.userRepository.findById(userId);
}
}
// src/modules/orders/application/orderService.js
class OrderService {
constructor(orderRepository, userModule, inventoryModule, eventBus) {
this.orderRepository = orderRepository;
this.userModule = userModule;
this.inventoryModule = inventoryModule;
this.eventBus = eventBus;
}
async createOrder(userId, items) {
// Call other modules through their public interfaces
const user = await this.userModule.getUser(userId);
if (!user) throw new Error('User not found');
const available = await this.inventoryModule.checkAvailability(items);
if (!available) throw new Error('Items not available');
const order = await this.orderRepository.save({
userId,
items,
status: 'pending'
});
this.eventBus.emit('order.created', {
orderId: order.id,
userId,
items
});
return order;
}
}
// src/app.js - Application composition
const express = require('express');
const EventEmitter = require('events');
const eventBus = new EventEmitter();
const app = express();
// Initialize modules
const userModule = initializeUserModule(eventBus);
const inventoryModule = initializeInventoryModule(eventBus);
const orderModule = initializeOrderModule(eventBus, userModule, inventoryModule);
// Mount routes
app.use('/api', userModule.routes);
app.use('/api', orderModule.routes);
app.use('/api', inventoryModule.routes);
app.listen(3000);
The key principles:
- Modules are self-contained: Each module has its own domain logic, data access, and API routes
- Communication through interfaces: OrderService doesn't access the users database directly—it calls userModule.getUser()
- Event-driven for async operations: When an order is created, other modules can react without tight coupling
- Single deployment: Despite the modular structure, this is one application with one deployment pipeline
The Python Version
Here's the same pattern in Python with FastAPI:
# src/modules/users/service.py
from dataclasses import dataclass
from typing import Protocol
class EventBus(Protocol):
def emit(self, event: str, data: dict) -> None: ...
class UserRepository(Protocol):
async def save(self, user_data: dict) -> dict: ...
async def find_by_id(self, user_id: int) -> dict | None: ...
@dataclass
class UserService:
repository: UserRepository
event_bus: EventBus
async def create_user(self, user_data: dict) -> dict:
user = await self.repository.save(user_data)
self.event_bus.emit('user.created', {
'user_id': user['id'],
'email': user['email']
})
return user
async def get_user(self, user_id: int) -> dict | None:
return await self.repository.find_by_id(user_id)
# src/modules/users/api.py
from fastapi import APIRouter, Depends
def create_user_router(user_service: UserService) -> APIRouter:
router = APIRouter(prefix="/users", tags=["users"])
@router.post("/")
async def create_user(user_data: dict):
return await user_service.create_user(user_data)
@router.get("/{user_id}")
async def get_user(user_id: int):
return await user_service.get_user(user_id)
return router
# src/modules/orders/service.py
@dataclass
class OrderService:
repository: OrderRepository
user_service: UserService
inventory_service: InventoryService
event_bus: EventBus
async def create_order(self, user_id: int, items: list[dict]) -> dict:
# Call other modules through their interfaces
user = await self.user_service.get_user(user_id)
if not user:
raise ValueError("User not found")
available = await self.inventory_service.check_availability(items)
if not available:
raise ValueError("Items not available")
order = await self.repository.save({
'user_id': user_id,
'items': items,
'status': 'pending'
})
self.event_bus.emit('order.created', {
'order_id': order['id'],
'user_id': user_id,
'items': items
})
return order
# src/main.py - Application composition
from fastapi import FastAPI
app = FastAPI()
event_bus = InProcessEventBus()
# Initialize modules with dependency injection
user_service = UserService(
repository=UserRepository(db),
event_bus=event_bus
)
inventory_service = InventoryService(
repository=InventoryRepository(db),
event_bus=event_bus
)
order_service = OrderService(
repository=OrderRepository(db),
user_service=user_service,
inventory_service=inventory_service,
event_bus=event_bus
)
# Mount routers
app.include_router(create_user_router(user_service))
app.include_router(create_order_router(order_service))
app.include_router(create_inventory_router(inventory_service))
Python boundary enforcement with import-linter:
# .importlinter
[importlinter]
root_package = src
[importlinter:contract:layers]
name = Module boundaries must be respected
type = layers
layers =
src.modules.users.api
src.modules.orders.api
src.modules.inventory.api
containers =
src.modules.users
src.modules.orders
src.modules.inventory
[importlinter:contract:no-cross-module]
name = Modules cannot import from other module internals
type = forbidden
source_modules =
src.modules.orders
forbidden_modules =
src.modules.users.infrastructure
src.modules.users.domain
src.modules.inventory.infrastructure
src.modules.inventory.domain
Run lint-imports in CI. It will fail with:
Contract 'no-cross-module' BROKEN
src.modules.orders.service imports src.modules.users.infrastructure.repository
The Strangler Fig Pattern for Extraction
When you do need to extract a service, the strangler fig pattern makes it safe:
- Add an adapter layer in the monolith that can route to either the internal module or an external service
- Deploy the new service alongside the monolith
- Route a small percentage of traffic to the new service (1%, then 10%, then 50%)
- Monitor for issues and roll back if needed
- Complete the migration once you're confident
- Remove the old module from the monolith
// Adapter pattern for gradual extraction
class UserServiceAdapter {
constructor(internalUserService, externalUserServiceClient, config) {
this.internal = internalUserService;
this.external = externalUserServiceClient;
this.config = config;
}
async getUser(userId) {
// Route based on configuration
if (this.shouldUseExternalService(userId)) {
return this.external.getUser(userId);
}
return this.internal.getUser(userId);
}
shouldUseExternalService(userId) {
// Gradual rollout: route 10% of traffic to external service
return (userId % 10) === 0 && this.config.externalServiceEnabled;
}
}
This is how you de-risk the migration. You're not doing a big-bang cutover—you're gradually shifting traffic and validating at each step.
FAQ
Q: Doesn't a modular monolith become a "big ball of mud" eventually?
Only if you let it. The same discipline that keeps microservices clean (clear boundaries, interface contracts, event-driven communication) keeps a modular monolith clean. The difference is enforcement: with microservices, the network boundary enforces separation. With a modular monolith, you enforce it through code review and architecture tests.
You can write tests that fail if a module reaches into another module's internals:
// Architecture test in Jest
const fs = require('fs');
const glob = require('glob');
test('orders module should not import from users internals', () => {
const ordersFiles = glob.sync('src/modules/orders/**/*.js');
ordersFiles.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
expect(content).not.toMatch(/require.*users\/infrastructure/);
expect(content).not.toMatch(/require.*users\/domain/);
});
});
Q: How do you handle database migrations with separate schemas?
Each module owns its schema and its migrations. Use a migration tool that supports schemas (Flyway, Liquibase, or Alembic for Python) and organize migrations by module:
migrations/
users/
V1__create_users_table.sql
V2__add_email_index.sql
orders/
V1__create_orders_table.sql
V2__add_status_column.sql
Run migrations in dependency order: users before orders if orders references users.
Q: What about team ownership? Can different teams own different modules?
Absolutely. Shopify has hundreds of teams working on different modules in their monolith. The key is clear ownership boundaries and a good code review process. Teams own their module's code and are responsible for maintaining its interfaces.
Use CODEOWNERS files to enforce this:
# .github/CODEOWNERS
/src/modules/users/** @team-identity
/src/modules/orders/** @team-commerce
/src/modules/inventory/** @team-fulfillment
Now GitHub (or GitLab) requires approval from the owning team for any changes to their module.
Q: How do you prevent one module from bringing down the entire application?
Circuit breakers and bulkheads, same as microservices. If the email module is slow, wrap calls to it in a circuit breaker that fails fast after a timeout. Use separate thread pools for different modules so one module can't exhaust all resources.
const CircuitBreaker = require('opossum');
const emailCircuit = new CircuitBreaker(emailService.send, {
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 30000
});
emailCircuit.fallback(() => {
// Log the failure and continue
logger.error('Email service unavailable, skipping notification');
});
The difference is that with a monolith, you have more options. You can also just make the call synchronous and accept that emails might be delayed if the service is slow. With microservices, you're forced into async patterns whether you need them or not.
Q: What's the performance difference between in-process calls and HTTP calls?
In-process function calls are measured in nanoseconds to microseconds. HTTP calls are measured in milliseconds. For a request that touches 5 modules, that's the difference between 5 microseconds and 50 milliseconds of overhead—before you've done any actual work.
That said, if your modules are doing real work (database queries, external API calls), the difference might not matter. But if you're building a high-throughput system where every millisecond counts, in-process communication is significantly faster.
The Bottom Line
Microservices are a tool, not a goal. They solve specific problems: independent scaling, team autonomy at scale, and technology diversity. But they come with significant operational complexity.
For most teams—especially those under 30-40 developers—a modular monolith delivers the majority of benefits with a fraction of the complexity. In my experience working with dozens of engineering teams over the past decade, I've observed that approximately 80% of the value attributed to microservices actually comes from logical boundaries and disciplined modularity, not from distributed deployment. You get clear boundaries, independent development, and a path to microservices when you need them. You avoid distributed systems complexity, network latency, and operational overhead.
The companies moving back to monoliths aren't admitting failure. They're optimizing for their actual constraints: team size, operational maturity, and cost. That's engineering judgment, not architecture religion.
Start with a modular monolith. Enforce boundaries with tooling. Use events for async communication. Extract services only when you have evidence they're needed. That's the path that ships faster and scales smarter.


