Skip to content
Ayhan Sipahi Ayhan Sipahi

Migrating from Microservices Back to a Modular Monolith

Recognizing distributed monoliths, strategic service consolidation, and the honest reality of moving back to a modular monolith when complexity grows.

When a microservices architecture evolves into a distributed monolith, teams face the exact problems they were trying to avoid. Deployment coupling, data consistency struggles, and coordination overhead are the visible symptoms.

Consolidating those services into a modular monolith is a valid architectural pattern, not an admission of failure. For a team of roughly 50 engineers or fewer, the sensible default is one well-structured deployable per product team. Extract a service out of it only when a measured need justifies the operational cost.

When Adding a Cart Item Touches 47 Services

Consider an e-commerce platform that grew to 47 microservices along the path from product page to checkout. Each service has its own database, deployment pipeline, and on-call rotation. A single purchase requires coordination across 12 different teams.

The architecture diagram looks impressive in presentations. The reality: more time spent debugging service-to-service communication than building features. “Loosely coupled” services cannot deploy independently because changing one API means coordinating with five other teams. Classic distributed monolith syndrome.

Microservice Failure Patterns

Consistent warning signs emerge when microservices evolve into technical debt:

The Deployment Ordering Problem

# 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 services cannot deploy independently, what the diagram calls microservices is 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);
    }
  }
}

The usual next step is a distributed transaction coordinator. That coordinator becomes a component every service depends on, which puts the monolith back in the middle of the diagram under a different name.

The Consolidation Strategy

Reducing 47 services to 3 modular monoliths starts with a decision framework:

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 Consolidated Application

Instead of 47 services, 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, injected into every module
  private sharedDb = new DatabaseConnection();
  private cache = new RedisCache();
  
  async processOrder(request: OrderRequest) {
    // One ACID transaction instead of a distributed saga
    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 becomes a single blue-green rollout instead of an ordered sequence across dozens of pipelines. More importantly, the order flow gains a single transaction boundary: a failure halfway through rolls back cleanly rather than leaving half-written state spread across four databases.

Three Consolidation Patterns

The Small Team With a Big-Tech Diagram

The most common trigger is a service count that scales with features instead of with teams. Every new capability gets its own service, because that is what the published reference architectures look like. Those architectures come from companies staffing thousands of engineers; a team of thirty inherits the same operational surface without the people to run it.

The damage shows up in flows that cross many boundaries. A registration path that touches eight services fails as a set: when the payment call times out, the flow aborts and leaves a half-created user behind, because no transaction spans the services.

A workable consolidation target is a handful of domain-focused deployables:

  • Identity: users, auth, profiles, permissions
  • Transactions: payments, orders, invoicing, reconciliation
  • Product: catalog, pricing, inventory, recommendations
  • Communication: email, SMS, push notifications, webhooks

Each one owns a transaction boundary that matches how the business writes data. Partial failures become rollbacks, leaving no half-written records for someone to clean up by hand.

The Report That Reads From Every Service

Large decompositions hit a second failure mode: reporting. Analytical and compliance queries do not respect service boundaries, so one report fans out across dozens of services, and a timeout anywhere in that fan-out fails the whole run. The retry re-reads everything.

Consolidating storage before consolidating code addresses this without touching the services themselves. Domain schemas inside a single database restore the ability to join:

-- Domain schemas instead of separate databases
CREATE SCHEMA customer_domain;
CREATE SCHEMA product_domain;
CREATE SCHEMA order_domain;
CREATE SCHEMA compliance_domain;

-- Move 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;

-- Compliance reports become plain 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';
-- One query plan instead of a fan-out across services

Once the data lives in one engine, reporting stops being a distributed systems problem and becomes a query plan the database can optimize. Whether the services themselves get merged afterwards is a separate decision, and its measure is deployment coupling.

The Latency Budget That Network Hops Consume

The third pattern applies where the latency budget is tight. Splitting a request path into five services adds a network round trip per hop, and on short paths each hop costs more than the work it wraps. On a trade execution path whose budget is single-digit milliseconds, the hops consume all of it:

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

The in-process version does the same work. It simply stops paying for serialization and network transit between each step. Latency-critical paths are the clearest case for consolidation, because the cost of a hop can be measured before committing to the change.

Migration Strategies

The Strangler Fig Pattern (In Reverse)

Instead of strangling a monolith with microservices, this pattern strangles 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;
  }
}

Migrate one business capability at a time and watch error rates and latency at each step. The rollout percentage is the safety valve: if the consolidated handler regresses, the traffic share drops back to zero without a deploy.

Database Consolidation Without Tears

The scariest part of consolidation is often merging databases. The pattern that works:

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

Treat it like any other data migration. Logical replication, then a gradual read cutover, then a flagged write cutover is the same playbook used for any storage move.

The Cost Side of the Decision

Most consolidation business cases are built on the compute bill, which is usually the smallest line item. Four categories belong in the comparison:

  • Infrastructure: per-service load balancers, mesh sidecars, and the headroom reserved separately for every service
  • Observability: log volume, span ingestion, and per-host or per-container agent pricing, all of which scale with service count rather than with traffic
  • On-call: how many rotations the current headcount has to staff, and the pager load carried by each one
  • Coordination: the engineering hours spent sequencing releases across teams

The first two shrink roughly in proportion to the service count, which makes them the easy part of the argument. The last two are usually the larger numbers, and they are the ones missing from the spreadsheet.

Team Structure and Conway’s Law

In practice, team structure determines architecture far more reliably than architecture determines team structure.

When Team Boundaries Move

When an organization restructures from 12 small teams to 4 larger product teams, maintaining 47 microservices becomes impossible. Each team would own 10-12 services. Instead of fighting Conway’s Law, the right move is to embrace 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
  };
}

With one modular monolith per team, on-call maps onto ownership: the team that gets paged is the team that can fix the code. Reviews improve for the same reason, because reviewers already hold the context the change sits in.

Module Boundaries That Stand the Test of Time

The secret to successful modular monoliths is getting the module boundaries right:

// 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
  ) {}
}

Make wrong dependencies fail the build. A boundary that lives only in review comments erodes the first time a deadline gets tight.

Monitoring and Observability

Consolidation also changes what monitoring is able to tell you.

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"
}

The second view comes from one log stream and one deployment, so the first question after an alert is simply which module was slow, with no hunt for the service that owns the trace. Distributed tracing is still worth running between the remaining deployables; it just stops being the only way to answer a basic latency question.

The Decision Framework

The signals worth weighing before committing to a consolidation:

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

Three rules generalize across consolidations:

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 Alongside Performance

Response time and throughput are the metrics every team already collects. The ones that predict an architecture going wrong are harder to instrument:

  • 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 on the assumption that some of them will be merged later:

  • Use compatible technology stacks
  • Maintain consistent data models
  • Standardize API patterns
  • Keep good documentation of service boundaries and why they exist

Where This Default Stops Applying

Consolidation is the right default when services deploy together, share a transaction boundary, or belong to one team. It is the wrong move in three cases: when a component genuinely scales on a different curve from the rest of the system, when it needs a different runtime or hardware profile, and when regulatory or tenancy isolation requires a separate blast radius. Each of those is verifiable before the split, which is the point. The expensive mistake is splitting on a diagram rather than on a measurement.

Team size is the other boundary. Past roughly one deployable per product team, the coordination cost inside a shared codebase starts to outweigh the coordination cost of a network boundary, and extraction earns its keep. Below that line, a modular monolith with build-enforced module boundaries carries most of what gets attributed to microservices, without the distributed transaction problems.

If the service graph is already large and the starting point is unclear, measure deployment coupling first. The pairs that always ship together are one unit in practice, whatever the diagram says.

References

Related posts