The microservices vs monolith debate is dead. The real question is: when do you choose modular monoliths over microservices? 🏗️⚖️
The New Architecture Landscape:
Microservices promised to solve all scaling problems. Instead, they created new ones. Meanwhile, companies like Shopify, GitHub, and Basecamp are thriving with modular monoliths. The pendulum is swinging back to pragmatism.
What is a Modular Monolith?
A modular monolith is a single deployable unit with clear internal boundaries. Think of it as microservices architecture without the network calls.
Key Characteristics: • Single deployment unit • Clear module boundaries • Shared database with logical separation • Internal APIs between modules • Potential for future extraction
Example Structure:
modular-ecommerce-app/
├── modules/
│ ├── user-management/
│ │ ├── api/
│ │ ├── domain/
│ │ └── infrastructure/
│ ├── inventory/
│ │ ├── api/
│ │ ├── domain/
│ │ └── infrastructure/
│ ├── billing/
│ │ ├── api/
│ │ ├── domain/
│ │ └── infrastructure/
│ └── shared/
│ ├── events/
│ ├── utils/
│ └── interfaces/
├── infrastructure/
├── config/
└── main.ts
The Decision Matrix:
🏢 Choose Modular Monolith When:
Team Size: < 50 developers • Small teams can coordinate effectively • Communication overhead is manageable • Shared context is maintainable
Domain Understanding: Evolving • Business requirements still changing • Unclear where to draw service boundaries • Need flexibility to refactor quickly
Technical Complexity: Moderate • Standard CRUD operations • Limited integration requirements • Straightforward data consistency needs
Operational Maturity: Limited • Small DevOps team • Limited monitoring infrastructure • Simple deployment needs
Performance Requirements: Predictable • Known traffic patterns • Moderate scale (< 10M requests/day) • Low latency requirements
Real-World Example: E-commerce Platform
// Modular Monolith Approach
class OrderService {
constructor(
private userModule: UserModule,
private inventoryModule: InventoryModule,
private billingModule: BillingModule
) {}
async createOrder(orderData: CreateOrderRequest): Promise<Order> {
// All operations in single transaction
return this.db.transaction(async (tx) => {
const user = await this.userModule.validateUser(orderData.userId, tx);
const items = await this.inventoryModule.reserveItems(orderData.items, tx);
const payment = await this.billingModule.processPayment(orderData.payment, tx);
return this.createOrderRecord({
user,
items,
payment
}, tx);
});
}
}
🚀 Choose Microservices When:
Team Size: 50+ developers • Multiple independent teams • Teams can own entire service lifecycle • Need for parallel development
Domain Understanding: Well-defined • Clear business boundaries • Stable service interfaces • Understood data flows
Technical Complexity: High • Different technology requirements per domain • Complex integration patterns • Varying performance characteristics
Operational Maturity: Advanced • Strong DevOps capabilities • Comprehensive monitoring • Automated deployment pipelines • 24/7 operations team
Performance Requirements: Diverse • Different scaling needs per service • High availability requirements • Global distribution needs
Real-World Example: Netflix Architecture
User Request → API Gateway
↓
┌─[User Service]──[Auth Service]──[Profile Service]
│
├─[Content Service]──[Recommendation Service]
│ ↓
├─[Video Service]──[Encoding Service]──[CDN]
│
└─[Billing Service]──[Payment Service]──[Analytics]
The Hybrid Approach: Gradual Evolution
Stage 1: Well-Designed Monolith
// Clear module boundaries from day 1
class EcommerceApp {
private readonly modules = {
users: new UserModule(this.db),
inventory: new InventoryModule(this.db),
billing: new BillingModule(this.db),
orders: new OrderModule(this.db)
};
// Internal APIs between modules
getModule<T extends keyof typeof this.modules>(name: T) {
return this.modules[name];
}
}
Stage 2: Extract High-Value Services
// Extract services that benefit most from independence
// 1. Different scaling needs (e.g., image processing)
// 2. Different technology requirements (e.g., ML services)
// 3. Different teams (e.g., payments team)
class OrderService {
constructor(
private userModule: UserModule,
private inventoryModule: InventoryModule,
private paymentService: PaymentServiceClient // ← Now external
) {}
}
Stage 3: Selective Extraction
// Only extract what truly benefits from separation
// Keep related functionality together
class CoreEcommerce {
// Keep tightly coupled domains together
private modules = {
users: new UserModule(),
orders: new OrderModule(),
inventory: new InventoryModule()
};
}
// Separate services for different requirements
// - PaymentService (compliance, security)
// - RecommendationService (ML, different tech stack)
// - NotificationService (different scaling pattern)
Modular Monolith Best Practices:
🏗️ 1. Enforce Module Boundaries
Dependency Rules:
// ✅ Good: Modules depend on abstractions
class OrderModule {
constructor(private userService: IUserService) {}
}
// ❌ Bad: Direct dependency on implementation
class OrderModule {
constructor(private userRepository: UserRepository) {}
}
Architecture Tests:
// Prevent unwanted dependencies
describe('Architecture Rules', () => {
it('billing module should not depend on inventory', () => {
const billingFiles = glob('src/modules/billing/**/*.ts');
const inventoryImports = checkImports(billingFiles, 'modules/inventory');
expect(inventoryImports).toHaveLength(0);
});
});
🔄 2. Internal Event System
class EventBus {
private subscribers = new Map<string, Function[]>();
publish(event: DomainEvent): void {
const handlers = this.subscribers.get(event.type) || [];
handlers.forEach(handler => handler(event));
}
subscribe(eventType: string, handler: Function): void {
const existing = this.subscribers.get(eventType) || [];
this.subscribers.set(eventType, [...existing, handler]);
}
}
// Usage
class OrderModule {
createOrder(orderData: CreateOrderRequest) {
const order = this.repository.save(orderData);
this.eventBus.publish(new OrderCreated(order));
return order;
}
}
class InventoryModule {
constructor(eventBus: EventBus) {
eventBus.subscribe('OrderCreated', this.reserveItems.bind(this));
}
}
📊 3. Module-Level Metrics
class ModuleMetrics {
private metrics = new Map<string, number>();
trackModuleInteraction(from: string, to: string): void {
const key = `${from}->${to}`;
const current = this.metrics.get(key) || 0;
this.metrics.set(key, current + 1);
}
getCouplingReport(): CouplingReport {
// Identify highly coupled modules
// Candidates for microservice extraction
return this.analyzeCoupling(this.metrics);
}
}
Microservices Best Practices:
🎯 1. Service Boundaries
Domain-Driven Design:
Bounded Contexts:
┌─[User Management]─┐ ┌─[Order Management]─┐
│ • Authentication │ │ • Order Processing │
│ • User Profiles │ │ • Order History │
│ • Permissions │ │ • Order Status │
└───────────────────┘ └───────────────────┘
│ │
└──── Shared Kernel ────┘
(Customer, Product)
API Contract First:
# orders-api.yaml
openapi: 3.0.0
info:
title: Order Service API
version: 1.0.0
paths:
/orders:
post:
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/CreateOrderRequest'
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/Order'
🔍 2. Observability
class OrderService {
async createOrder(orderData: CreateOrderRequest): Promise<Order> {
const span = tracer.startSpan('order.create');
const timer = metrics.timer('order_creation_duration');
try {
span.setTag('user.id', orderData.userId);
span.setTag('order.items.count', orderData.items.length);
const order = await this.processOrder(orderData);
metrics.increment('orders.created');
span.setTag('order.id', order.id);
return order;
} catch (error) {
span.setTag('error', true);
metrics.increment('orders.creation.failed');
throw error;
} finally {
timer.stop();
span.finish();
}
}
}
🔄 3. Data Consistency Patterns
Saga Pattern:
class OrderSaga {
async execute(orderData: CreateOrderRequest): Promise<void> {
const saga = new SagaTransaction();
try {
// Step 1: Reserve inventory
await saga.addStep(
() => this.inventoryService.reserve(orderData.items),
() => this.inventoryService.release(orderData.items)
);
// Step 2: Process payment
await saga.addStep(
() => this.paymentService.charge(orderData.payment),
() => this.paymentService.refund(orderData.payment)
);
// Step 3: Create order
await saga.addStep(
() => this.orderService.create(orderData),
() => this.orderService.cancel(orderData.orderId)
);
await saga.execute();
} catch (error) {
await saga.compensate();
throw error;
}
}
}
Migration Strategies:
🔄 Strangler Fig Pattern:
class LegacyOrderService {
// Gradually replace with new service
}
class ModernOrderService {
// New implementation
}
class OrderServiceProxy {
constructor(
private legacyService: LegacyOrderService,
private modernService: ModernOrderService,
private featureFlags: FeatureFlags
) {}
async createOrder(orderData: CreateOrderRequest): Promise<Order> {
if (await this.featureFlags.isEnabled('modern_orders', orderData.userId)) {
return this.modernService.createOrder(orderData);
}
return this.legacyService.createOrder(orderData);
}
}
📊 Decision Metrics:
Complexity Indicators:
Modular Monolith Indicators:
• Team size < 50 developers
• < 10 distinct business domains
• Shared data access patterns
• Simple deployment requirements
• Limited operational expertise
Microservices Indicators:
• Team size > 50 developers
• > 10 distinct business domains
• Different scaling requirements
• Advanced operational capabilities
• Complex integration needs
Cost Analysis:
Modular Monolith Costs:
• Lower operational overhead
• Simpler debugging and testing
• Faster development initially
• Single deployment pipeline
Microservices Costs:
• Higher operational complexity
• Distributed system challenges
• Network latency and failures
• Multiple deployment pipelines
• Advanced monitoring requirements
Remember: Architecture is not about choosing the latest trend. It's about choosing the right tool for your specific context—team size, domain complexity, operational maturity, and business requirements.
Start simple, measure carefully, and evolve thoughtfully.
What's your team's context? 🤔
