Skip to content

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

yaml
# Your deployment "orchestration" looks like thisdeploy-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

typescript
// 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

typescript
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:

typescript
// Single deployable, multiple modulesclass 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:

sql
-- Created domain schemas instead of separate databasesCREATE SCHEMA customer_domain;CREATE SCHEMA product_domain;CREATE SCHEMA order_domain;CREATE SCHEMA compliance_domain;
-- Moved related tables into domain schemasALTER 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 joinsSELECT   c.user_id,  c.registration_date,  o.total_orders,  o.total_revenue,  p.product_categoriesFROM customer_domain.users cJOIN order_domain.order_summary o ON c.user_id = o.user_idJOIN product_domain.user_products p ON c.user_id = p.user_idWHERE 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:

typescript
// Before: Microservices with network overheadclass 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 memoryclass 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:

typescript
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:

sql
-- Step 1: Create domain schemas in consolidated databaseCREATE SCHEMA user_domain;CREATE SCHEMA order_domain;CREATE SCHEMA inventory_domain;
-- Step 2: Set up logical replication from microservice DBsCREATE 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 60,500to60,500 to 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:

typescript
// Team structure drove architectureinterface 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:

typescript
// 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 timeclass 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

javascript
// 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

javascript
// 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:

typescript
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.

References

Related Posts