2025-09-08
The Monolith's Revenge: When Microservices Become Technical Debt
A perspective on recognizing distributed monoliths, strategic service consolidation, and the honest reality of moving back to modular monoliths after microservices complexity becomes unsustainable.
When microservices architecture evolves into a distributed monolith, teams face the exact problems they were trying to avoid. This post explores patterns for recognizing when microservices become technical debt and strategies for consolidating back to more maintainable architectures.
The 47-Service Shopping Cart Wake-Up Call
Here’s a story that might sound familiar. We had a simple e-commerce platform that somehow evolved into 47 microservices just to add an item to a cart. Each service had its own database, deployment pipeline, and on-call rotation. A single purchase required coordination across 12 different teams.
The architecture diagram looked impressive in presentations. The reality? We spent more time debugging service-to-service communication than building features. Our “loosely coupled” services couldn’t deploy independently because changing one API meant coordinating with five other teams. Classic distributed monolith syndrome.
When Microservices Attack: Recognition Patterns
Consistent warning signs emerge when microservices evolve into technical debt:
The Deployment Dance of Death
# Your deployment "orchestration" looks like this
deploy-order:
- auth-service # Must deploy first
- user-service # Depends on auth changes
- profile-service # Needs new user fields
- order-service # Requires profile updates
- inventory-service # Needs order changes
- payment-service # Depends on everything above
# ... 41 more services in specific order
If you can’t deploy services independently, you don’t have microservices - you have a distributed monolith with extra steps.
The Data Consistency Problem
// What started as clean service boundaries...
class OrderService {
async createOrder(orderData: OrderRequest) {
// Turned into distributed transaction hell
const user = await this.userService.getUser(orderData.userId);
const inventory = await this.inventoryService.checkStock(orderData.items);
const pricing = await this.pricingService.calculateTotal(orderData);
const payment = await this.paymentService.authorize(pricing.total);
// Now pray nothing fails in the middle
try {
const order = await this.saveOrder(orderData);
await this.inventoryService.reserve(orderData.items);
await this.paymentService.capture(payment.id);
// What happens if this fails?
await this.emailService.sendConfirmation(order);
return order;
} catch (error) {
// Good luck rolling all this back consistently
await this.attemptDistributedRollback(error);
}
}
}
We eventually built a distributed transaction coordinator. Which was, ironically, a monolith coordinating our microservices. The universe has a sense of humor.
The Great Consolidation Strategy
One consolidation effort reduced 23 services to 3 modular monoliths. Here’s the framework that guided the process:
Service Consolidation Decision Matrix
interface ConsolidationCandidate {
services: string[];
criteria: {
sharedDataModel: boolean; // Same conceptual data?
teamOwnership: string; // Same team owns them?
deploymentCoupling: number; // How often deployed together?
communicationVolume: number; // Calls per minute between them
transactionBoundary: boolean; // Need ACID guarantees?
};
consolidationScore(): number {
// If score > 0.7, strong consolidation candidate
return (
(this.criteria.sharedDataModel ? 0.3 : 0) +
(this.criteria.teamOwnership ? 0.2 : 0) +
(this.criteria.deploymentCoupling > 0.8 ? 0.2 : 0) +
(this.criteria.communicationVolume > 100 ? 0.2 : 0) +
(this.criteria.transactionBoundary ? 0.3 : 0)
);
}
}
The Modular Monolith Pattern That Actually Works
Instead of 47 services, we built 3 modular monoliths with clear internal boundaries:
// Single deployable, multiple modules
class ECommerceApplication {
// Modules with clear boundaries
private modules = {
user: new UserModule(this.sharedDb),
order: new OrderModule(this.sharedDb),
inventory: new InventoryModule(this.sharedDb),
payment: new PaymentModule(this.sharedDb)
};
// Shared infrastructure - the magic sauce
private sharedDb = new DatabaseConnection();
private cache = new RedisCache();
async processOrder(request: OrderRequest) {
// Beautiful ACID transactions instead of distributed sagas
return await this.sharedDb.transaction(async (tx) => {
const user = await this.modules.user.validateUser(request.userId, tx);
const items = await this.modules.inventory.reserveItems(request.items, tx);
const payment = await this.modules.payment.processPayment(request.payment, tx);
const order = await this.modules.order.createOrder(user, items, payment, tx);
// Everything commits or rolls back together
return order;
});
}
}
Deployment time went from 45 minutes of orchestrated chaos to 4 minutes of simple blue-green deployment. Our incident rate dropped by 60%. Team velocity increased by 40%.
Real Consolidation Stories: The Good, The Bad, The Expensive
The High-Growth Startup Reckoning
One rapidly scaling fintech startup reached 47 microservices within 18 months. Each new feature meant a new service because “that’s how Netflix does it.” But with 30 engineers instead of 3,000, the complexity quickly became unsustainable.
A business demonstration revealed the extent of the problem. A simple user registration triggered calls across 8 services. When the payment service timed out, the entire flow failed, leaving a half-created user in the system.
The next quarter focused on consolidating into 4 domain-focused services:
- Identity Service: User, auth, profiles, permissions
- Transaction Service: Payments, orders, invoicing, reconciliation
- Product Service: Catalog, pricing, inventory, recommendations
- Communication Service: Email, SMS, push notifications, webhooks
The result? Feature delivery improved by 3x, and we could actually trace bugs without distributed systems archaeology.
The Enterprise Migration That Went Full Circle
One Fortune 500 company migrated from a legacy monolith to 200+ microservices over 3 years. The architecture became so complex they needed a dedicated “Service Cartography Team” just to maintain documentation of service dependencies.
During a critical audit, they discovered that generating a single compliance report required data from 73 different services. The report took 6 hours to generate and failed 30% of the time due to timeout cascades.
The consolidation strategy was surgical:
-- Created domain schemas instead of separate databases
CREATE SCHEMA customer_domain;
CREATE SCHEMA product_domain;
CREATE SCHEMA order_domain;
CREATE SCHEMA compliance_domain;
-- Moved related tables into domain schemas
ALTER TABLE users SET SCHEMA customer_domain;
ALTER TABLE profiles SET SCHEMA customer_domain;
ALTER TABLE preferences SET SCHEMA customer_domain;
-- Now compliance reports are simple joins
SELECT
c.user_id,
c.registration_date,
o.total_orders,
o.total_revenue,
p.product_categories
FROM customer_domain.users c
JOIN order_domain.order_summary o ON c.user_id = o.user_id
JOIN product_domain.user_products p ON c.user_id = p.user_id
WHERE c.registration_date >= '2024-01-01';
-- Executes in 3 seconds, not 6 hours
They went from 200+ services to 12 modular monoliths organized by business domain. Compliance report generation became a 3-second query instead of a 6-hour distributed systems adventure.
The Performance-Critical System That Couldn’t Scale Out
One real-time trading platform had decomposed into microservices for “infinite scalability.” The problem? Network latency between services added 50-100ms to each trade execution. In high-frequency trading, that’s an eternity.
The solution was counterintuitive - consolidate everything into a single, highly optimized process:
// Before: Microservices with network overhead
class TradingSystemDistributed {
async executeTrade(order: Order) {
// Each call adds 10-20ms latency
const validation = await this.validationService.validate(order); // +15ms
const pricing = await this.pricingService.getPrice(order); // +12ms
const risk = await this.riskService.checkLimits(order); // +18ms
const execution = await this.executionService.execute(order); // +14ms
const settlement = await this.settlementService.settle(order); // +16ms
// Total: 75ms average latency
}
}
// After: Monolithic with shared memory
class TradingSystemMonolithic {
async executeTrade(order: Order) {
// Everything in-process with shared memory
const validation = this.validateOrder(order); // <1ms
const pricing = this.calculatePrice(order); // <1ms
const risk = this.checkRiskLimits(order); // <1ms
const execution = this.executeOrder(order); // <1ms
const settlement = this.settleOrder(order); // <1ms
// Total: <5ms latency
}
}
Trade execution improved by 15x. Sometimes, the old ways are the best ways.
Migration Strategies That Actually Work
The Strangler Fig Pattern (In Reverse)
Instead of strangling a monolith with microservices, we strangled our microservices with a monolith:
class ConsolidationProxy {
private legacyServices = new Map<string, MicroserviceClient>();
private consolidatedHandlers = new Map<string, Handler>();
async handleRequest(request: Request): Promise<Response> {
const feature = this.extractFeature(request);
// Gradually move traffic to consolidated version
if (this.shouldUseConsolidated(feature)) {
return await this.consolidatedHandlers.get(feature)!(request);
}
// Fall back to legacy microservice
return await this.legacyServices.get(feature)!.call(request);
}
private shouldUseConsolidated(feature: string): boolean {
// Start with 10% traffic, increase gradually
const rolloutPercentage = this.getRolloutPercentage(feature);
return Math.random() < rolloutPercentage;
}
}
We migrated one business capability at a time, monitoring error rates and performance at each step. The entire consolidation took 6 months, but we never had a major incident.
Database Consolidation Without Tears
The scariest part of consolidation is often merging databases. Here’s the pattern that worked for us:
-- Step 1: Create domain schemas in consolidated database
CREATE SCHEMA user_domain;
CREATE SCHEMA order_domain;
CREATE SCHEMA inventory_domain;
-- Step 2: Set up logical replication from microservice DBs
CREATE PUBLICATION user_pub FOR ALL TABLES;
CREATE SUBSCRIPTION user_sub
CONNECTION 'host=user-service-db dbname=users'
PUBLICATION user_pub;
-- Step 3: Gradually migrate reads to consolidated DB
-- Step 4: Switch writes with feature flags
-- Step 5: Decommission old databases
The key insight: treat it like any other data migration, not some special microservices magic.
The Cost Analysis Nobody Talks About
Consolidation reveals significant cost savings beyond infrastructure:
Moving from 47 microservices to 3 modular monoliths reduced monthly costs from 19,900 - a 67% reduction. This included infrastructure, monitoring, on-call overhead, and developer coordination time.
Beyond direct cost savings, developer experience improved dramatically. Employee retention increased when engineers could understand the entire system without navigating complex service meshes.
Team Dynamics and Conway’s Law Revenge
Here’s something they don’t teach in architecture courses: your team structure will ultimately determine your architecture, not the other way around.
The Team Reorganization That Forced Consolidation
When our company restructured from 12 small teams to 4 larger product teams, maintaining 47 microservices became impossible. Each team would have owned 10-12 services. Instead of fighting Conway’s Law, we embraced it:
// Team structure drove architecture
interface TeamArchitectureAlignment {
teamStructure: {
identityTeam: 8, // 8 engineers
commerceTeam: 10, // 10 engineers
fulfillmentTeam: 6, // 6 engineers
platformTeam: 6 // 6 engineers
};
serviceStructure: {
identityService: 'identityTeam', // 1 service per team
commerceService: 'commerceTeam', // Clear ownership
fulfillmentService: 'fulfillmentTeam',// No coordination needed
platformService: 'platformTeam' // Shared infrastructure
};
}
Each team owned one modular monolith. On-call became manageable. Knowledge sharing improved. Code reviews actually made sense because reviewers understood the context.
Module Boundaries That Stand the Test of Time
The secret to successful modular monoliths is getting the module boundaries right. Here’s what worked for us:
// Clear module interfaces with dependency injection
@Module({
imports: [], // No circular dependencies!
providers: [
OrderService,
OrderRepository,
OrderValidator,
OrderEventPublisher
],
exports: [OrderService] // Only expose the service
})
export class OrderModule {
// Internal classes are module-private
private repository: OrderRepository;
private validator: OrderValidator;
private events: OrderEventPublisher;
// Public interface is minimal and stable
public service: OrderService;
}
// Enforce boundaries at build time
class OrderService {
constructor(
// Can only inject from allowed modules
@Inject(UserModule) private users: UserService,
@Inject(InventoryModule) private inventory: InventoryService,
// @Inject(RandomModule) <- This would fail at build time
) {}
}
The key: make wrong dependencies impossible at compile time, not just discouraged in code reviews.
Monitoring and Observability Simplified
One unexpected benefit of consolidation: monitoring became actually useful again.
Before: Distributed Tracing Problem
// Tracing a single user request across 12 services
{
traceId: "abc-123",
spans: [
{ service: "api-gateway", duration: 5 },
{ service: "auth-service", duration: 45 },
{ service: "user-service", duration: 23 },
{ service: "profile-service", duration: 67 },
{ service: "preference-service", duration: 12 },
{ service: "recommendation-service", duration: 234 },
{ service: "content-service", duration: 56 },
{ service: "cache-service", duration: 3 },
{ service: "analytics-service", duration: 89 },
{ service: "notification-service", duration: 34 },
{ service: "email-service", duration: 156 },
{ service: "audit-service", duration: 45 }
],
totalDuration: 769,
status: "failed",
error: "Timeout in recommendation-service after 234ms"
}
Finding the root cause required correlating logs from 12 different services, each with their own log format and timestamp precision.
After: Application-Level Observability
// Same request in modular monolith
{
requestId: "xyz-789",
module_timings: {
"auth.validateToken": 8,
"user.loadProfile": 15,
"recommendations.generate": 45,
"content.fetch": 12
},
totalDuration: 80,
databaseQueries: 4,
cacheHits: 12,
status: "success"
}
One log stream. One deployment. One place to look when things go wrong. Revolutionary.
The Decision Framework
After going through this multiple times, here’s my framework for deciding when to consolidate:
class ConsolidationDecisionFramework {
shouldConsolidate(): boolean {
const factors = {
// Technical factors
deploymentCoupling: this.measureDeploymentCoupling(), // > 0.7 = consolidate
sharedDataRequirements: this.assessDataSharing(), // > 0.6 = consolidate
networkChattiness: this.measureServiceCommunication(), // > 100 calls/min = consolidate
transactionRequirements: this.needsAcidTransactions(), // true = strongly consider
// Organizational factors
teamSize: this.getEngineeringHeadcount(), // < 50 = lean toward monolith
teamStructure: this.assessTeamBoundaries(), // misaligned = consolidate
onCallBurden: this.measureOnCallLoad(), // > 40hrs/month = consolidate
// Business factors
developmentVelocity: this.measureFeatureDelivery(), // decreasing = warning sign
operationalCost: this.calculateMonthlyBurn(), // unsustainable = consolidate
timeToMarket: this.measureFeatureLeadTime(), // increasing = problem
};
// If more than half the factors suggest consolidation, do it
return this.calculateConsolidationScore(factors) > 0.5;
}
}
Lessons Learned
Reflecting on multiple microservices consolidation efforts reveals key patterns:
Start With a Modular Monolith
Starting with a well-structured modular monolith makes sense in most cases. Extract services only when:
- A module needs to scale independently (proven with metrics, not speculation)
- A module requires different technology (legitimate technical requirement)
- A module needs independent deployment (due to different release cycles)
- A separate team will own it completely (Conway’s Law compliance)
Measure Complexity, Not Just Performance
We always measured response times and throughput. What we should have measured:
- Time to debug an issue (from alert to resolution)
- Number of people needed to understand a feature
- Cognitive load per developer (context switches per day)
- Time spent on coordination vs. creation
Design for Consolidation From Day One
Build services with the assumption you might merge them later:
- Use compatible technology stacks
- Maintain consistent data models
- Standardize API patterns
- Keep good documentation of service boundaries and why they exist
The Honest Truth About Architectural Evolution
There’s no perfect architecture, only architectures that fit current context. Microservices aren’t bad. Monoliths aren’t bad. Distributed monoliths pretending to be microservices - those are bad.
The pendulum swing from monolith to microservices to modular monolith isn’t failure - it’s learning. Every architecture decision is a bet on future requirements, team structure, and business needs. Sometimes that bet doesn’t pan out. The key is recognizing when to change course.
Modular monoliths can serve millions of users successfully. Splitting into microservices should happen only when there’s a compelling reason that justifies the complexity cost.
Key Takeaways
For Technical Leaders:
- Service consolidation is a valid architectural pattern, not admission of failure
- Monitor team cognitive load as closely as system metrics
- Let team boundaries drive service boundaries, not vice versa
- Operational complexity has real costs - factor them into architectural decisions
For Development Teams:
- Modular monoliths can provide microservices benefits without the complexity
- Shared databases and ACID transactions are often simpler than eventual consistency
- Focus on module boundaries within your monolith - they matter more than service boundaries
- Your happiness and productivity are valid architectural requirements
For Architects:
- Design for change, including the possibility of consolidation
- Measure the total cost of your architecture, not just infrastructure
- Transaction boundaries often matter more than service boundaries
- Sometimes the best move is backward - and that’s okay
Remember: the goal isn’t architectural purity - it’s building systems that let your team deliver value efficiently. Sometimes that means admitting your microservices have become technical debt and having the courage to consolidate back to something simpler.
Your monolith is waiting for its revenge. Maybe it’s time to let it have it.
Related posts
Learn multi-account AWS architecture patterns for building resilient event-driven systems. Explore account structure, EventBridge routing, cross-service communication, and operational challenges in distributed systems.
A vendor-neutral evaluation of external authorization platforms including AWS Verified Permissions, SpiceDB, OpenFGA, Cerbos, and OPA. Covers architecture patterns, cost analysis, and a decision framework for engineering teams.
A deep technical comparison of SpiceDB and Auth0 FGA (OpenFGA) -- two Zanzibar-inspired authorization systems with different trade-offs in schema design, consistency models, deployment, and scalability.
A comprehensive guide to implementing caching strategies across multiple tiers, from in-memory application caches to distributed Redis clusters and CDN edge caching. Learn when to use cache-aside vs write-through patterns, how to choose between ElastiCache and MemoryDB, and how to prevent cache stampede in production.
Learn how the Transactional Outbox Pattern solves the dual-write problem in distributed systems, with practical implementations using PostgreSQL, DynamoDB, and CDC tools.