From Microservices to Modular Monoliths: When Complexity Becomes a Liability
Microservices aren't always the answer. Learn when and how to consolidate microservices back into well-architected modular monoliths for better performance and maintainability.
From Microservices to Modular Monoliths: When Complexity Becomes a Liability
The microservices revolution promised scalability, flexibility, and team autonomy. Yet increasingly, companies are discovering that the operational overhead of distributed systems outweighs the benefits—especially for small to medium-sized teams.
This isn’t about microservices being “bad.” It’s about recognizing when architectural complexity becomes a liability rather than an asset. If you’re spending more time managing your infrastructure than building features, it might be time to reconsider your architecture.
The Hidden Costs of Microservices
1. Operational Overhead
Every microservice introduces:
- Deployment complexity: 20 services = 20 deployment pipelines
- Monitoring overhead: Distributed tracing across service boundaries
- Network latency: Inter-service communication adds milliseconds (or seconds)
- Data consistency: Managing transactions across service boundaries
// Microservices: Complex distributed transaction
async function createOrder(userId: string, items: Item[]) {
const transaction = await distributedTx.begin();
try {
// Call to Inventory Service
const reserved = await inventoryService.reserve(items, transaction);
// Call to Payment Service
const payment = await paymentService.charge(userId, total, transaction);
// Call to Order Service
const order = await orderService.create(userId, items, transaction);
// Call to Notification Service
await notificationService.send(userId, order.id, transaction);
await transaction.commit();
return order;
} catch (error) {
await transaction.rollback();
throw error;
}
}
2. Development Friction
Developers spend more time on:
- Service discovery: “Which service owns this data?”
- Local development: Running 10+ services locally
- Debugging: Tracing errors across service boundaries
- Version management: Keeping service contracts in sync
A study we conducted across our client projects showed that teams spent 40% of their time managing microservices infrastructure rather than building features.
When to Choose a Modular Monolith
Consider consolidation when:
- Your team is < 50 engineers - You don’t have the operational expertise
- Services share a database - You’ve defeated the purpose of microservices
- Most calls are synchronous - You’re building a distributed monolith
- Deployment takes hours - Coordination overhead is killing velocity
- You can’t trace requests - Observability is insufficient
The Modular Monolith Architecture
A well-designed modular monolith gives you microservices benefits without the operational cost:
monolith/
├── modules/
│ ├── orders/
│ │ ├── api/ # Public interface
│ │ ├── domain/ # Business logic
│ │ ├── data/ # Data access
│ │ └── events/ # Domain events
│ ├── inventory/
│ ├── payments/
│ └── notifications/
├── shared/
│ ├── database/
│ ├── events/
│ └── utils/
└── infrastructure/
Key Principles
1. Strong Module Boundaries
// ❌ Bad: Direct dependencies between modules
import { InventoryRepository } from '@/modules/inventory/data/repository';
// ✅ Good: Depend on public interfaces only
import { InventoryService } from '@/modules/inventory/api';
2. Domain Events for Communication
// Order module publishes events
export class OrderService {
async createOrder(data: CreateOrderDTO) {
const order = await this.repository.save(data);
// Publish event instead of calling other modules directly
await this.eventBus.publish(new OrderCreatedEvent({
orderId: order.id,
userId: order.userId,
items: order.items,
total: order.total
}));
return order;
}
}
// Inventory module subscribes to events
export class InventoryEventHandler {
@Subscribe(OrderCreatedEvent)
async handleOrderCreated(event: OrderCreatedEvent) {
await this.inventoryService.reserveItems(event.items);
}
}
3. Vertical Slicing
Each module owns its complete stack:
// modules/orders/api/orders.controller.ts
@Controller('/orders')
export class OrdersController {
constructor(private orderService: OrderService) {}
@Post()
async create(@Body() dto: CreateOrderDTO) {
return this.orderService.createOrder(dto);
}
}
// modules/orders/domain/order.service.ts
export class OrderService {
constructor(
private repository: OrderRepository,
private eventBus: EventBus
) {}
async createOrder(data: CreateOrderDTO): Promise<Order> {
// Domain logic here
}
}
// modules/orders/data/order.repository.ts
export class OrderRepository {
async save(order: Order): Promise<Order> {
// Data access here
}
}
Migration Strategy
Phase 1: Audit Current Architecture
Analyze your microservices:
// Generate dependency graph
const serviceDependencies = await analyzeServices({
services: getAllServices(),
metrics: {
callFrequency: true,
latency: true,
errorRate: true,
deploymentFrequency: true
}
});
// Identify consolidation candidates
const candidates = serviceDependencies
.filter(s => s.callFrequency > 1000) // Chatty
.filter(s => s.avgLatency > 100) // Slow network calls
.filter(s => s.deploymentFrequency < 1); // Rarely deployed independently
Phase 2: Create Modules in Monolith
Start by extracting shared code:
// Phase 2a: Move shared code to monolith
monolith/
shared/
models/ # Domain models from microservices
clients/ # API clients for remaining microservices
database/ # Database connections
// Phase 2b: Create facade for microservices
export class OrdersServiceFacade {
private useMonolith = process.env.USE_MONOLITH_ORDERS === 'true';
async createOrder(data: CreateOrderDTO) {
if (this.useMonolith) {
return this.monolithOrderService.create(data);
}
return this.microserviceClient.createOrder(data);
}
}
Phase 3: Gradual Migration
Use feature flags to shift traffic:
export class FeatureToggleService {
async shouldUseMonolith(userId: string, feature: string): Promise<boolean> {
const rollout = await this.getRolloutPercentage(feature);
const userBucket = hashCode(userId) % 100;
return userBucket < rollout;
}
}
// In your controller
async createOrder(userId: string, data: CreateOrderDTO) {
const useMonolith = await this.features.shouldUseMonolith(
userId,
'orders-in-monolith'
);
return useMonolith
? this.monolithOrderService.create(data)
: this.microserviceClient.createOrder(data);
}
Phase 4: Decommission Services
Once 100% of traffic is on monolith:
- Archive microservice code for reference
- Migrate data if necessary
- Update documentation and runbooks
- Remove infrastructure (save costs!)
Real-World Results
We recently helped a client consolidate 12 microservices into a modular monolith:
Before:
- Deploy time: 45 minutes average
- P95 latency: 850ms
- Infrastructure cost: $8,000/month
- Incidents/month: 12
- Time to debug issues: 2-3 hours
After:
- Deploy time: 6 minutes
- P95 latency: 120ms (7x faster!)
- Infrastructure cost: $1,200/month (85% reduction)
- Incidents/month: 2
- Time to debug issues: 15 minutes
When NOT to Consolidate
Keep microservices when:
- Truly independent domains: E.g., billing vs analytics
- Different scaling needs: CPU-intensive vs memory-intensive
- Different tech stacks: Python ML service + Node.js API
- Regulatory requirements: PCI compliance isolation
- Large, distributed teams: 100+ engineers across continents
The Hybrid Approach
You don’t have to choose all-or-nothing:
Architecture:
├── Monolith (90% of business logic)
│ ├── Orders
│ ├── Inventory
│ ├── Payments
│ └── User Management
├── ML Service (separate microservice)
│ └── Recommendation Engine
├── Analytics Service (separate microservice)
│ └── Real-time Analytics
└── Legacy Service (temporary, being migrated)
└── Old Billing System
Practical Tips
1. Enforce Module Boundaries
Use architectural testing:
// tests/architecture/module-boundaries.test.ts
import { checkModuleBoundaries } from 'ts-arch';
describe('Module Boundaries', () => {
it('should not allow modules to import from other modules internals', () => {
const result = checkModuleBoundaries({
modules: ['orders', 'inventory', 'payments'],
rules: [
{
from: 'orders/**',
shouldNotImport: ['inventory/data/**', 'inventory/domain/**'],
message: 'Orders should only use Inventory public API'
}
]
});
expect(result.violations).toHaveLength(0);
});
});
2. Use a Monorepo
Manage your modular monolith with modern tools:
// package.json
{
"workspaces": [
"modules/orders",
"modules/inventory",
"modules/payments",
"shared/*"
]
}
3. Implement Circuit Breakers
Even in a monolith, protect against cascading failures:
export class CircuitBreaker {
private failures = 0;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
throw new Error('Circuit breaker is OPEN');
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onFailure() {
this.failures++;
if (this.failures >= 5) {
this.state = 'OPEN';
setTimeout(() => this.state = 'HALF_OPEN', 30000);
}
}
private onSuccess() {
this.failures = 0;
this.state = 'CLOSED';
}
}
Conclusion
Microservices are a powerful pattern, but they’re not a universal solution. For many teams, a well-architected modular monolith provides:
- Faster development cycles
- Simpler operations
- Better performance
- Lower costs
The key is strong module boundaries and disciplined architecture—the same skills that make microservices successful.
At BrilliMinds, we’ve helped numerous clients evaluate their architecture and make informed decisions about consolidation vs distribution. If you’re struggling with microservices complexity, reach out—we’d love to help you find the right architecture for your needs.
Further Reading
About the author
BrilliMinds Team
Software Engineering & Product Team
BrilliMinds Team shares practical insights on software architecture, AI integration, product delivery, and engineering best practices for startups and enterprises.