Skip to content
Ayhan Sipahi Ayhan Sipahi

Monolith to Microservices in Node.js: A Migration Guide

A practical guide to evolving Node.js monoliths into event-driven serverless functions, with migration strategies and proven architectural patterns.

Monolith Maintainability Limits

Node.js monoliths degrade predictably. A single shared deployment boundary lets one slow domain block the whole release cycle, and a peak-traffic failure in one feature takes the rest of the process down with it. The architectural fix is not refactoring the monolith; it is decomposing it into independently deployable, event-driven functions.

Boundaries decide the outcome. Pick them from how the code actually changes and fails, keep the system releasable while the split is in progress, and the migration stays reversible at every step.

How the Monolith Grew

An e-commerce platform of this shape usually begins as a straightforward Node.js Express application:

// Day one: one process, one deployment
app.use('/api/users', userController);
app.use('/api/products', productController);
app.use('/api/orders', orderController);
app.use('/api/inventory', inventoryController);
app.use('/api/payments', paymentController);
// ... and dozens more controllers

Over time, this application grows into a complex system:

  • Large codebase spanning thousands of files
  • Multiple business domains in a single repository
  • Extended deploy times including comprehensive test suites
  • Declining team velocity as complexity increases
  • High infrastructure costs for monolithic deployment
  • Significant debugging overhead consuming development time

Cognitive Load Across Domain Boundaries

Monolith discussions usually land on technical debt. The binding constraint is narrower: how much of the system one engineer has to hold in their head to ship a single change. A typical “simple” feature reaches into four domains at once:

// A single "product recommendation" endpoint touches four domains:

// 1. User service (authentication + preferences)
class UserService {
  async getUserPreferences(userId: string) {
    // preference resolution, consent flags, segment lookup
    // + several tables behind one method
    // + external profile integrations
  }
}

// 2. Product service (catalog + inventory + pricing)
class ProductService {
  async getRecommendations(userId: string, context: string) {
    // several ranking strategies behind one signature
    // + ML model integration
    // + A/B testing framework
    // + Cache invalidation logic (the hardest part)
  }
}

// 3. Order service (for purchase history analysis)
class OrderService {
  async getUserOrderHistory(userId: string, limit?: number) {
    // multi-table joins over historical orders
    // + Data privacy compliance logic
    // + Query paths tuned for high-volume accounts
  }
}

// 4. Analytics service (for tracking recommendations)
class AnalyticsService {
  async trackRecommendationEvent(event: RecommendationEvent) {
    // event enrichment and batching
    // + GDPR compliance
    // + Rate limiting
    // + Queue management
  }
}

None of these classes is unreasonable on its own. The cost appears when one endpoint depends on all four: the change is small, but the reading required to make it safely is not. Onboarding slows for the same reason, because there is no smaller unit to learn first.

Feature Development Bottlenecks

One request makes the cost concrete: show related products in the shopping cart. A task that looks like one afternoon expands into a sequence:

  1. Understanding existing architecture across multiple interconnected services
  2. Careful integration to avoid breaking existing workflows
  3. Comprehensive testing to prevent regression in complex test suites
  4. Iterative fixes as changes reach unexpected parts of the system
  5. Cascade debugging when one fix breaks another component
  6. Simplified compromise when the full implementation turns out too risky

None of these steps is technically hard. Each one exists because the change crosses a boundary that the deployment unit does not enforce, so no part of it can be verified in isolation.

Evolutionary Decomposition Over Rewrite

Three options usually reach the table: a full rewrite, a much larger team, or incremental decomposition. A rewrite discards every bug fix the monolith has absorbed and freezes feature work for as long as it runs. A larger team amplifies the coordination cost instead of removing it. Incremental decomposition is the default worth defending, because each step ships on its own and can be reverted on its own.

Pain-Driven Service Extraction

Domain models are a weak first source of boundaries: they describe the business, not the deployment. Deployment history describes the deployment. Three signals in that history are enough to pick the first candidates:

  1. Components that change together indicate tight coupling and should stay together
  2. Components that fail together share a risk boundary, not just a code path
  3. Components with different scaling or release needs are extraction candidates

Reading a few months of pull requests and incidents against those signals produces a short, boring list:

GroupingMembersSignal
Tightly coupleduser-auth, notification, profilealmost always ship in the same release
Tightly coupledproduct-catalog, inventory, pricingone schema change touches all three
Tightly coupledorder-processing, payment, shippinga failure in one rolls back the others
Extraction candidateanalyticswrite-heavy, scales on a different curve
Extraction candidateadmin-toolsseparate release cycle, separate audience
Extraction candidaterecommendationsfailures are already isolated and tolerable

Phase 1: Low-Risk Extractions (Months 1-3)

Start where a mistake is cheap. The first candidates share three traits:

  • Already isolated with minimal shared dependencies
  • High-pain, low-risk like analytics and admin tools
  • Different operational characteristics such as ML recommendation engines

Original Monolith Large Codebase

Auth Service Node.js + JWT

Analytics Service Node.js + ClickHouse

Admin Service Node.js + React

Core Monolith Reduced Size

What the first phase buys:

  • Faster deployments for the extracted services, with no change to the core release train
  • Isolated analytics that no longer competes with request traffic
  • Independent admin development with its own workflow and release cycle
  • A rehearsal for the deployment, monitoring, and on-call changes the later phases depend on

Phase 2: Core Business Logic (Months 4-8)

The second phase addresses revenue-critical components: product management, order processing, and payment handling.

Core Monolith Reduced Size

Product Service Node.js + PostgreSQL

Order Service Node.js + PostgreSQL

Payment Service Node.js + Stripe

User Service Node.js + PostgreSQL

Remaining Monolith Minimal Size

Event Bus AWS EventBridge

Event-Driven Communication:

Service-to-service HTTP preserves the coupling the split was supposed to remove; keeping it moves the monolith onto the network. Amazon EventBridge replaces the synchronous chain with published facts that downstream services subscribe to:

import { EventBridgeClient, PutEventsCommand } from '@aws-sdk/client-eventbridge';

const events = new EventBridgeClient({});

// Before: Synchronous coupling
async function processOrder(orderData) {
  // Synchronous dependencies create cascade failure risk
  const user = await userService.validateUser(orderData.userId);
  const inventory = await inventoryService.reserveItems(orderData.items);
  const payment = await paymentService.processPayment(orderData.payment);
  const shipping = await shippingService.calculateShipping(orderData.address);

  // Multiple failure points in a single transaction
  return await orderService.createOrder({ user, inventory, payment, shipping });
}

// After: Event-driven resilience
async function processOrder(orderData) {
  // Create order record immediately
  const order = await orderService.createOrder(orderData);

  // Publish the fact; downstream services subscribe to it
  const published = await events.send(new PutEventsCommand({
    Entries: [{
      Source: 'order-service',
      DetailType: 'Order Created',
      Detail: JSON.stringify({
        orderId: order.id,
        userId: orderData.userId,
        items: orderData.items
      })
    }]
  }));

  // A rejected entry still resolves: the error arrives in the response
  // body, not as an exception. Unchecked, the order is stored and no
  // subscriber ever hears about it.
  if (published.FailedEntryCount) {
    const [entry] = published.Entries ?? [];
    throw new Error(
      `Order ${order.id} not published: ${entry?.ErrorCode} ${entry?.ErrorMessage}`
    );
  }

  // Reduced coupling and cascade failure risk
  return order;
}

That check is not defensive padding. PutEvents reports per-entry rejections, throttling included, in the response instead of throwing, so an unchecked call can persist an order that no subscriber ever sees. Reading FailedEntryCount exposes the gap between the database write and the publish. Closing it takes an outbox: write the event in the same transaction as the order, then let a separate publisher drain the table and retry.

Phase 3: Serverless Functions (Months 9-12)

Once service boundaries hold, the deployment unit can shrink again, from services to functions:

Microservices EC2 + Load Balancer

Lambda Functions Event-Driven

Product Service

Product Functions get-product, create-product, update-inventory

Order Service

Order Functions create-order, process-payment, send-confirmation

User Service

User Functions authenticate, get-profile, update-preferences

EventBridge

Function-Based Architecture:

Each service becomes a collection of focused, single-purpose functions:

// product-service/lib/clients.ts
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb';
import { EventBridgeClient } from '@aws-sdk/client-eventbridge';

export const docClient = DynamoDBDocumentClient.from(new DynamoDBClient({}));
export const events = new EventBridgeClient({});

// product-service/functions/get-product.ts
import type { APIGatewayProxyEvent } from 'aws-lambda';
import { GetCommand } from '@aws-sdk/lib-dynamodb';
import { docClient } from '../lib/clients';

export const handler = async (event: APIGatewayProxyEvent) => {
  const productId = event.pathParameters?.productId;

  // Single responsibility: Retrieve product data
  const product = await docClient.send(new GetCommand({
    TableName: 'Products',
    Key: { id: productId }
  }));

  return {
    statusCode: 200,
    body: JSON.stringify(product.Item)
  };
};

// product-service/functions/inventory-updated.ts
import type { EventBridgeEvent } from 'aws-lambda';
import { UpdateCommand } from '@aws-sdk/lib-dynamodb';
import { PutEventsCommand } from '@aws-sdk/client-eventbridge';
import { docClient, events } from '../lib/clients';

type InventoryDetail = { productId: string; newQuantity: number };

export const handler = async (event: EventBridgeEvent<'Inventory Updated', InventoryDetail>) => {
  const { productId, newQuantity } = event.detail;

  // Single responsibility: React to inventory changes
  await docClient.send(new UpdateCommand({
    TableName: 'Products',
    Key: { id: productId },
    UpdateExpression: 'SET #inv = :qty',
    ExpressionAttributeNames: { '#inv': 'inventory' },
    ExpressionAttributeValues: { ':qty': newQuantity }
  }));

  // Publish downstream event if needed
  if (newQuantity === 0) {
    const published = await events.send(new PutEventsCommand({
      Entries: [{
        Source: 'product-service',
        DetailType: 'Product Out of Stock',
        Detail: JSON.stringify({ productId })
      }]
    }));

    // Same rule as above: fail loudly so the invocation retries
    if (published.FailedEntryCount) {
      throw new Error(`Out-of-stock event dropped for ${productId}`);
    }
  }
};

What Decomposition Changes

Migration write-ups tend to bundle two very different kinds of improvement. They are worth separating before anyone commits to the work.

Guaranteed by the split: deploy scope. A change to one domain stops waiting on unrelated test suites, so that domain releases at its own pace. Blast radius shrinks with it, because a peripheral service can be down while checkout keeps serving.

Earned, not guaranteed: onboarding time, debugging speed, and incident count. These improve only when each service owns its data and communicates through events. Services that still share a database and still call each other synchronously make all three worse than the monolith was, because the coupling now runs over the network, where no stack trace follows it.

Cost Profile

DimensionMonolith on always-on instancesEvent-driven serverless
Compute billingProvisioned for peak, paid at idlePer invocation, scales to zero
MonitoringCustom stack to install and runCloudWatch built in, X-Ray tracing behind a flag
DeploymentShared build and deploy infrastructurePer-function deploy, no hosts to patch
ScalingVertical, planned aheadHorizontal and automatic, bounded by account quotas
Failure modeThe whole application degradesOne handler degrades

The billing model flips rather than simply dropping. Spiky, event-shaped workloads (analytics, notifications, admin tooling) usually get cheaper. Steady high-throughput traffic can cost more per request on Lambda than on a saturated instance, so compute savings alone are a weak reason to migrate. The reduction in deploy scope is the reason.

Design Rules That Survive the Migration

1. Operational Reality Guides Architecture

Boundaries drawn from deployment and incident history hold up better than boundaries drawn from a domain diagram. The diagram describes what the business does; the history describes what actually changes together.

2. Event-Driven Communication Improves Resilience

Asynchronous communication buys isolation, not just loose coupling. A consumer that is down delays work instead of failing the producer, provided the event has somewhere durable to wait. That last clause is the whole guarantee: without a dead-letter queue and a retry policy, an event bus is a fire-and-forget message loss channel.

3. Functions Match Most Business Logic Patterns

For many workloads the operational surface of a full service exceeds the requirement. A focused function has a narrower contract, a narrower failure mode, and a stack trace that fits on one screen.

4. Observability Must Be Built-In

Once the logic is distributed, tracing is the only way to answer “where did this request spend its time”:

// Essential observability for distributed functions
import { captureAWSv3Client } from 'aws-xray-sdk-core';
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';

// Wrap each SDK client so its calls become trace subsegments
const dynamo = captureAWSv3Client(new DynamoDBClient({}));

export const handler = async (event, context) => {
  // Calls made through `dynamo` are correlated with the caller's trace,
  // provided active tracing is enabled on the function.
};

Migration Strategy Guide

The sequence in practice:

1. Assessment Phase

  • Identify pain points: Map deployment failures, debugging time, and development bottlenecks
  • Analyze dependencies: Document service interactions and shared resources
  • Measure baseline: Establish current performance and cost metrics

2. Extraction Strategy

  • Start with periphery: Begin with isolated, non-critical components
  • Follow operational patterns: Use deployment correlation to guide service boundaries
  • Maintain data consistency: Plan database decomposition carefully

3. Event-Driven Transition

  • Introduce event bus: Start with simple pub/sub patterns
  • Gradual decoupling: Replace synchronous calls incrementally
  • Design for failure: Build resilience into event handling

4. Function Evolution

  • Single responsibility: Keep functions focused and stateless
  • Event triggers: Design functions to respond to specific events
  • Observability first: Implement comprehensive monitoring from the start

Common Pitfalls

The Distributed Monolith

Services split along code layout while still sharing one database and one synchronous call chain. Every original coupling survives, and network timeouts, partial failures, and retries are added on top. The test is simple: can one service deploy while another is being rolled back? If not, the split is cosmetic.

Extracting the Core First

Checkout and payments are the components everyone wants out of the monolith, and they are the worst place to learn a new deployment model. Extract the periphery until the pipeline, the dashboards, and the on-call rotation are boring, then approach the core.

Events Without a Failure Path

A successful publish says nothing about the consumer. Every rule needs a dead-letter queue, a retry policy, and an alarm on queue depth. Consumers also have to tolerate duplicates, because EventBridge and most brokers deliver at least once.

Shared Database as a Transition Shortcut

Letting two services read one table is the fastest way through phase 2 and the hardest decision to undo later, because the coupling is invisible in the code. Copy the data behind an event, keep the old table read-only for the new owner, and remove the shared access before the phase closes.

When to Decompose and When to Stay

Decompose when deploy contention is the binding constraint: several teams share one release train, one domain’s test suite gates everyone, and a failure in a peripheral feature can take checkout down with it. Extract the periphery first, move communication to events, then let the remaining core shrink on its own.

Stay monolithic when one team owns the whole codebase, deploys take minutes rather than hours, and the pain is code organization rather than deployment coupling. Module boundaries inside a single deployable solve that at a fraction of the operational cost, and they keep the extraction option open. Splitting first and discovering the boundaries afterwards produces the same coupling with a network in the middle.

References

Related posts