← Back to DevBytes

Migrating from SQL vs NoSQL to Monolith vs Microservices: Step-by-Step Guide

Understanding the Dual Migration Landscape

Migrating both your database paradigm and your application architecture simultaneously represents one of the most consequential transformations a development team can undertake. This guide addresses the four-dimensional matrix that emerges when you consider moving from SQL to NoSQL (or vice versa) while also transitioning from a monolithic codebase to a distributed microservices architecture. Each axis introduces its own complexity—schema design, data consistency, network boundaries, and service orchestration—and they compound when tackled together.

Why this matters: Organizations often discover that their original architecture choices—made years ago under different assumptions—no longer serve current scale, performance, or team structure requirements. A monolith built on a relational database might struggle with horizontal scaling and rigid schemas, while a NoSQL-backed monolith could suffer from lack of transactional guarantees as business logic grows more complex. The decision to migrate is rarely isolated; database and architecture evolution are deeply intertwined. Understanding how to sequence these changes, where to introduce boundaries, and how to maintain data integrity throughout the process is critical for avoiding costly rollbacks or data corruption.

Decision Framework: Choosing Your Target Architecture

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before writing migration code, you must map your current state to a desired target. The matrix below illustrates the four primary migration paths, each carrying distinct risks and benefits.

The Four Migration Quadrants

Consider your starting point and where you need to end up:

Decision Triggers

Ask these questions before committing to a dual migration:

Step-by-Step Migration Paths

Path A: SQL Monolith to SQL Microservices

This is the most common migration pattern. You decompose a single relational database into multiple databases, each owned by one microservice. The key challenge is breaking foreign key relationships that span service boundaries.

Step 1: Identify Domain Boundaries in the Schema

Analyze your existing schema to find natural aggregates. Foreign key dependencies that cross domain boundaries must be replaced with service-level contracts.

-- Original monolithic schema (PostgreSQL example)
CREATE TABLE users (
    id UUID PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    full_name TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE orders (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL REFERENCES users(id),
    total_amount DECIMAL(10,2),
    status TEXT DEFAULT 'pending',
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE order_items (
    id UUID PRIMARY KEY,
    order_id UUID NOT NULL REFERENCES orders(id),
    product_id UUID NOT NULL REFERENCES products(id),
    quantity INT NOT NULL,
    unit_price DECIMAL(10,2)
);

CREATE TABLE products (
    id UUID PRIMARY KEY,
    sku TEXT UNIQUE NOT NULL,
    name TEXT NOT NULL,
    inventory_count INT NOT NULL DEFAULT 0
);

After analysis, you identify two bounded contexts: User Management (users) and Order Processing (orders, order_items). The products table forms a third context: Product Catalog. The foreign key orders.user_id → users.id crosses from Orders to Users; order_items.product_id → products.id crosses from Orders to Catalog.

Step 2: Extract Schema Ownership

Create separate databases and remove cross-service foreign keys. Replace them with identifier references and application-level joins or data replication.

-- User Service database (user_db)
CREATE TABLE users (
    id UUID PRIMARY KEY,
    email TEXT UNIQUE NOT NULL,
    full_name TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

-- Order Service database (order_db) — note: user_id is now just a UUID reference
CREATE TABLE orders (
    id UUID PRIMARY KEY,
    user_id UUID NOT NULL,  -- No FK constraint; validated via API call
    total_amount DECIMAL(10,2),
    status TEXT DEFAULT 'pending',
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE order_items (
    id UUID PRIMARY KEY,
    order_id UUID NOT NULL REFERENCES orders(id),
    product_id UUID NOT NULL,  -- No FK constraint; Catalog service owns product data
    quantity INT NOT NULL,
    unit_price DECIMAL(10,2)
);

-- Product Catalog Service database (catalog_db)
CREATE TABLE products (
    id UUID PRIMARY KEY,
    sku TEXT UNIQUE NOT NULL,
    name TEXT NOT NULL,
    inventory_count INT NOT NULL DEFAULT 0
);

Step 3: Implement Cross-Service Data Access

When the Order service needs user information (e.g., to send an email), it calls the User service via HTTP or consumes a user-created event. Here is a practical REST-based approach in a Node.js/Express order service:

// Order Service — fetching user data from User Service
const axios = require('axios');

async function enrichOrderWithUser(order) {
  try {
    const response = await axios.get(
      `http://user-service/api/users/${order.user_id}`,
      { timeout: 3000 }
    );
    return {
      ...order,
      user_email: response.data.email,
      user_name: response.data.full_name
    };
  } catch (error) {
    // Graceful degradation — cache or fallback
    console.warn(`User ${order.user_id} not reachable, using cached data`);
    return { ...order, user_email: 'unavailable', user_name: 'unavailable' };
  }
}

app.get('/api/orders/:id', async (req, res) => {
  const order = await orderRepository.findById(req.params.id);
  const enriched = await enrichOrderWithUser(order);
  res.json(enriched);
});

Step 4: Handle Transactions Across Services

When creating an order requires decrementing inventory (Catalog service), implement a Saga orchestration pattern:

// Order Service — Saga orchestrator for order creation
async function createOrderSaga(userId, items) {
  const sagaId = uuidv4();
  const steps = [];

  try {
    // Step 1: Reserve inventory
    for (const item of items) {
      const reserveResult = await axios.post(
        `http://catalog-service/api/products/${item.product_id}/reserve`,
        { quantity: item.quantity, saga_id: sagaId }
      );
      steps.push({ action: 'reserve', productId: item.product_id, quantity: item.quantity });
    }

    // Step 2: Create order
    const order = await orderRepository.create({
      user_id: userId,
      items,
      status: 'confirmed',
      saga_id: sagaId
    });
    steps.push({ action: 'create_order', orderId: order.id });

    return { success: true, order };

  } catch (error) {
    // Compensation: undo all previous steps
    console.error(`Saga ${sagaId} failed, running compensation`);
    for (const step of steps.reverse()) {
      if (step.action === 'reserve') {
        await axios.post(
          `http://catalog-service/api/products/${step.productId}/release`,
          { quantity: step.quantity, saga_id: sagaId }
        );
      }
      if (step.action === 'create_order') {
        await orderRepository.updateStatus(step.orderId, 'cancelled');
      }
    }
    return { success: false, error: error.message };
  }
}

Path B: SQL Monolith to NoSQL Microservices

This path requires the most fundamental rethinking. You move from normalized tables with joins to denormalized documents or wide-column stores, while simultaneously splitting ownership across services.

Step 1: Analyze Access Patterns

Unlike SQL where you normalize for write efficiency and join at read time, NoSQL design starts with query patterns. For each service, identify every read and write operation, its frequency, and its latency requirements. This drives your document or partition key design.

Step 2: Design Document Models Per Service

Using MongoDB as the target NoSQL store, here is how you might model the Order service data from the previous PostgreSQL schema:

// MongoDB collection: orders (Order Service owns this entirely)
// Document design embeds order items — no separate collection needed

db.createCollection("orders");

// Example document
{
  "_id": ObjectId("..."),
  "user_id": "uuid-of-user-from-user-service",  // Reference, not FK
  "status": "confirmed",
  "total_amount": 129.99,
  "created_at": ISODate("2025-01-15T10:30:00Z"),
  "items": [
    {
      "product_id": "uuid-from-catalog-service",
      "sku": "WH-1000XM5",
      "name": "Sony Headphones",  // Denormalized snapshot at order time
      "quantity": 1,
      "unit_price": 129.99
    }
  ],
  "shipping_address": {
    "street": "123 Main St",
    "city": "Austin",
    "state": "TX",
    "zip": "78701"
  }
}

// Index for querying orders by user (frequent access pattern)
db.orders.createIndex({ "user_id": 1, "created_at": -1 });

Notice that product name and SKU are denormalized into the order document. This is an immutable snapshot—the order reflects what the product was called at purchase time, not what it is called now. The Catalog service remains the source of truth for current product data.

Step 3: Migrate Data with Transformation Scripts

Write a dual-write migration script that reads from the SQL monolith and writes to the new NoSQL per-service collections. Use a change-data-capture (CDC) approach for ongoing sync during the cutover window.

#!/usr/bin/env python3
"""
Migration script: PostgreSQL monolith → MongoDB microservice stores
Phase: Initial bulk load + CDC tailing via logical replication
"""
import psycopg2
from pymongo import MongoClient
from datetime import datetime
import uuid

# Source: Monolithic PostgreSQL
pg_conn = psycopg2.connect(
    host="pg-monolith.example.com",
    database="monolith_db",
    user="migration_user",
    password="secure_password"
)

# Target: Per-service MongoDB instances
mongo_clients = {
    "orders": MongoClient("mongodb://order-svc-mongo:27017/"),
    "catalog": MongoClient("mongodb://catalog-svc-mongo:27017/"),
    "users": MongoClient("mongodb://user-svc-mongo:27017/")
}

def migrate_users():
    """Extract users from PG and load into User Service MongoDB"""
    with pg_conn.cursor() as cur:
        cur.execute("SELECT id, email, full_name, created_at FROM users")
        users_collection = mongo_clients["users"].user_service.users
        
        for row in cur.fetchall():
            user_doc = {
                "_id": str(row[0]),
                "email": row[1],
                "full_name": row[2],
                "created_at": row[3].isoformat(),
                "migrated_at": datetime.utcnow().isoformat(),
                "source": "pg_monolith_migration_v1"
            }
            users_collection.update_one(
                {"_id": str(row[0])},
                {"$set": user_doc},
                upsert=True
            )
        print(f"Migrated {cur.rowcount} users")

def migrate_orders_batch(batch_size=500):
    """Migrate orders with embedded items — batch for memory efficiency"""
    with pg_conn.cursor() as cur:
        cur.execute("""
            SELECT o.id, o.user_id, o.total_amount, o.status, o.created_at,
                   oi.product_id, oi.quantity, oi.unit_price,
                   p.sku, p.name as product_name
            FROM orders o
            LEFT JOIN order_items oi ON o.id = oi.order_id
            LEFT JOIN products p ON oi.product_id = p.id
            ORDER BY o.id
        """)
        
        orders_collection = mongo_clients["orders"].order_service.orders
        current_order = None
        order_doc = None
        
        for row in cur:
            order_id = str(row[0])
            if current_order != order_id:
                # Save previous order document
                if order_doc:
                    orders_collection.insert_one(order_doc)
                # Start new order document
                order_doc = {
                    "_id": order_id,
                    "user_id": str(row[1]),
                    "total_amount": float(row[2]),
                    "status": row[3],
                    "created_at": row[4].isoformat(),
                    "items": [],
                    "migrated_at": datetime.utcnow().isoformat()
                }
                current_order = order_id
            
            # Add item to order
            if row[5]:  # product_id exists (order has items)
                order_doc["items"].append({
                    "product_id": str(row[5]),
                    "sku": row[8],
                    "name": row[9],
                    "quantity": row[6],
                    "unit_price": float(row[7])
                })
        
        # Don't forget the last order
        if order_doc:
            orders_collection.insert_one(order_doc)

if __name__ == "__main__":
    migrate_users()
    migrate_orders_batch()
    print("Bulk migration complete. Starting CDC listener...")
    # CDC listener would follow here using pg_recvlogical or Debezium

Step 4: Implement Event-Driven Sync for the Cutover Period

During migration, both systems run in parallel. Use an outbox pattern in the monolith to emit events that NoSQL services consume:

-- Outbox table in the monolith (PostgreSQL)
CREATE TABLE outbox_events (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    aggregate_type TEXT NOT NULL,
    aggregate_id UUID NOT NULL,
    event_type TEXT NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now(),
    processed BOOLEAN DEFAULT false
);

-- Example: Trigger on orders table populates outbox
CREATE OR REPLACE FUNCTION order_outbox_trigger()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
    VALUES (
        'order',
        NEW.id,
        TG_OP,  -- 'INSERT', 'UPDATE', or 'DELETE'
        row_to_json(NEW)::jsonb
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER order_outbox_trigger
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION order_outbox_trigger();
// NoSQL Microservice: Debezium-style CDC consumer (Kafka-based)
// Consumes outbox events and updates MongoDB

const { Kafka } = require('kafkajs');

const kafka = new Kafka({
  clientId: 'order-service-cdc',
  brokers: ['kafka-broker:9092']
});

const consumer = kafka.consumer({ groupId: 'order-service-group' });

async function consumeOutboxEvents() {
  await consumer.connect();
  await consumer.subscribe({ topic: 'pg_outbox_orders', fromBeginning: false });

  await consumer.run({
    eachMessage: async ({ message }) => {
      const event = JSON.parse(message.value.toString());
      
      const ordersCollection = db.collection('orders');
      
      switch (event.event_type) {
        case 'INSERT':
          await ordersCollection.insertOne({
            _id: event.payload.id,
            user_id: event.payload.user_id,
            status: event.payload.status,
            total_amount: event.payload.total_amount,
            created_at: new Date(event.payload.created_at),
            items: []  // Items populated via separate materialization
          });
          break;
          
        case 'UPDATE':
          await ordersCollection.updateOne(
            { _id: event.payload.id },
            { 
              $set: { 
                status: event.payload.status,
                total_amount: event.payload.total_amount 
              }
            }
          );
          break;
          
        case 'DELETE':
          await ordersCollection.deleteOne({ _id: event.payload.id });
          break;
      }
    }
  });
}

consumeOutboxEvents().catch(console.error);

Path C: NoSQL Monolith to SQL Microservices

This path introduces relational structure where document flexibility previously dominated. It is often driven by the need for strict consistency, complex reporting joins, or compliance requirements (e.g., financial auditing).

Step 1: Schema Discovery from Documents

Analyze your NoSQL documents to infer a normalized schema. MongoDB documents with nested arrays become separate tables with foreign keys.

// Original MongoDB monolithic document in "app_data" collection
{
  "_id": "org_abc123",
  "type": "organization",
  "name": "Acme Corp",
  "departments": [
    {
      "dept_id": "dept_1",
      "name": "Engineering",
      "employees": [
        { "emp_id": "emp_100", "name": "Alice", "salary": 120000 },
        { "emp_id": "emp_101", "name": "Bob", "salary": 95000 }
      ]
    },
    {
      "dept_id": "dept_2",
      "name": "Marketing",
      "employees": [
        { "emp_id": "emp_200", "name": "Charlie", "salary": 85000 }
      ]
    }
  ]
}

This single document normalizes into four tables across two microservice boundaries:

-- Organization Service database (org_db)
CREATE TABLE organizations (
    id VARCHAR(50) PRIMARY KEY,
    name TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE departments (
    id VARCHAR(50) PRIMARY KEY,
    organization_id VARCHAR(50) NOT NULL REFERENCES organizations(id),
    name TEXT NOT NULL
);

-- Employee Service database (employee_db)
CREATE TABLE employees (
    id VARCHAR(50) PRIMARY KEY,
    department_id VARCHAR(50) NOT NULL,  -- Reference, FK if in same DB or validated via API
    full_name TEXT NOT NULL,
    salary NUMERIC(10,2) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT now()
);

Step 2: Write the Normalization Migration Pipeline

#!/usr/bin/env python3
"""
Migration: MongoDB monolithic documents → PostgreSQL microservice databases
"""
from pymongo import MongoClient
import psycopg2
from psycopg2.extras import execute_values

mongo = MongoClient("mongodb://mongo-monolith:27017/")
pg_org = psycopg2.connect(host="org-service-db", database="org_db")
pg_emp = psycopg2.connect(host="employee-service-db", database="employee_db")

def normalize_and_migrate():
    app_data = mongo.monolith_db.app_data
    
    # Find all organization documents
    org_docs = app_data.find({"type": "organization"})
    
    org_inserts = []
    dept_inserts = []
    emp_inserts = []
    
    for doc in org_docs:
        org_id = doc["_id"]
        org_inserts.append((org_id, doc["name"]))
        
        for dept in doc.get("departments", []):
            dept_id = dept["dept_id"]
            dept_inserts.append((dept_id, org_id, dept["name"]))
            
            for emp in dept.get("employees", []):
                emp_id = emp["emp_id"]
                emp_inserts.append((emp_id, dept_id, emp["name"], emp["salary"]))
    
    # Bulk insert into Organization Service DB
    with pg_org.cursor() as cur:
        execute_values(cur, """
            INSERT INTO organizations (id, name) VALUES %s
            ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name
        """, org_inserts)
        
        execute_values(cur, """
            INSERT INTO departments (id, organization_id, name) VALUES %s
            ON CONFLICT (id) DO NOTHING
        """, dept_inserts)
    pg_org.commit()
    
    # Bulk insert into Employee Service DB
    with pg_emp.cursor() as cur:
        execute_values(cur, """
            INSERT INTO employees (id, department_id, full_name, salary) VALUES %s
            ON CONFLICT (id) DO UPDATE SET 
                full_name = EXCLUDED.full_name,
                salary = EXCLUDED.salary
        """, emp_inserts)
    pg_emp.commit()
    
    print(f"Migrated {len(org_inserts)} organizations, "
          f"{len(dept_inserts)} departments, "
          f"{len(emp_inserts)} employees")

normalize_and_migrate()

Path D: NoSQL Monolith to NoSQL Microservices

This path keeps NoSQL but splits a single database into per-service data stores. The primary challenge is data ownership isolation when documents reference each other across collections.

Step 1: Collection-to-Service Mapping

Audit every MongoDB collection (or Cassandra table, DynamoDB table, etc.) and assign ownership to exactly one microservice. Collections that are queried together frequently should stay together under one service to avoid chatty cross-service calls.

Step 2: Clone Collections to New Service Databases

// mongosh script: Clone collections from monolith to per-service instances
// Run against source monolith MongoDB

// 1. Clone users collection to User Service
db.adminCommand({
  cloneCollectionAsCapped: "monolith.users",
  toDatabase: "user_service_db",
  cappedSize: 104857600  // 100MB if needed
});

// Or use mongodump/mongorestore per collection:
// mongodump --db=monolith --collection=users --out=/dump/users
// mongorestore --uri="mongodb://user-service-mongo:27017" --db=user_service_db /dump/users

// 2. For large collections, use a selective copy with transformation
db.monolith.orders.aggregate([
  {
    $match: { created_at: { $gte: ISODate("2024-01-01") } }
  },
  {
    $out: { db: "order_service_db", coll: "orders" }
  }
]);

Step 3: Establish Cross-Service References

Documents that previously used DBRefs or manual references across collections now need API-based resolution. Implement a lightweight data bridge service or use a shared event bus.

// Order Service: Resolving product references via Catalog Service API
// Uses an in-process cache to reduce cross-service calls

const NodeCache = require('node-cache');
const productCache = new NodeCache({ stdTTL: 300, checkperiod: 60 });

async function resolveProductInfo(productId) {
  // Check cache first
  const cached = productCache.get(productId);
  if (cached) return cached;
  
  // Fetch from Catalog Service
  try {
    const response = await axios.get(
      `http://catalog-service/api/products/${productId}`,
      { timeout: 2000 }
    );
    productCache.set(productId, response.data);
    return response.data;
  } catch (error) {
    // Return stale cache if available, otherwise fail gracefully
    const stale = productCache.get(productId);
    if (stale) {
      console.warn(`Returning stale cache for product ${productId}`);
      return stale;
    }
    throw new Error(`Product ${productId} unavailable`);
  }
}

async function enrichOrderItems(order) {
  const enrichedItems = await Promise.all(
    order.items.map(async (item) => {
      const product = await resolveProductInfo(item.product_id);
      return {
        ...item,
        current_name: product.name,
        current_price: product.price,
        in_stock: product.inventory_count > 0
      };
    })
  );
  return { ...order, items: enrichedItems };
}

Practical Implementation: The Strangler Fig Pattern

Regardless of your migration quadrant, the Strangler Fig Pattern is the safest approach. You incrementally extract functionality from the monolith into microservices, routing traffic to the new services as they mature, while the old system continues to operate. This pattern works for both application logic and database ownership.

Phase 1: Build the Proxy Layer

Deploy a reverse proxy or API gateway that can route requests to either the monolith or new microservices based on path, header, or feature flag.

# docker-compose.yml — Proxy layer with Nginx for strangler routing
version: '3.8'

services:
  nginx-proxy:
    image: nginx:1.25
    volumes:
      - ./nginx-strangler.conf:/etc/nginx/nginx.conf:ro
    ports:
      - "8080:8080"
    depends_on:
      - monolith-app
      - user-service
      - order-service

  monolith-app:
    image: myorg/legacy-monolith:latest
    environment:
      DATABASE_URL: postgresql://pg-monolith:5432/monolith_db
    ports:
      - "9000:9000"  # Internal only

  user-service:
    image: myorg/user-service:1.0
    environment:
      MONGO_URI: mongodb://user-svc-mongo:27017/user_service_db
    ports:
      - "3001:3001"

  order-service:
    image: myorg/order-service:1.0
    environment:
      MONGO_URI: mongodb://order-svc-mongo:27017/order_service_db
    ports:
      - "3002:3002"

  pg-monolith:
    image: postgres:16
    volumes:
      - pg_monolith_data:/var/lib/postgresql/data

  user-svc-mongo:
    image: mongo:7

  order-svc-mongo:
    image: mongo:7

volumes:
  pg_monolith_data:
# nginx-strangler.conf — Routing rules for incremental migration
events {
    worker_connections 1024;
}

http {
    upstream monolith {
        server monolith-app:9000;
    }

    upstream user_service {
        server user-service:3001;
    }

    upstream order_service {
        server order-service:3002;
    }

    # Map for feature-flag-based routing (header-driven)
    map $http_x_migrated_to_microservice $user_route {
        default "monolith";
        "true"   "user_service";
    }

    server {
        listen 8080;

        # Users endpoint — progressively migrated
        location /api/users {
            # Check feature flag header; default routes to monolith
            proxy_pass http://$user_route;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }

        # Orders endpoint — fully migrated, always routes to new service
        location /api/orders {
            proxy_pass http://order_service;
            proxy_set_header Host $host;
        }

        # Everything else still goes to monolith
        location / {
            proxy_pass http://monolith;
            proxy_set_header Host $host;
        }
    }
}

Phase 2: Extract Database Operations Alongside API Routes

When you move an API endpoint to a microservice, you must also move the corresponding data access. Use the shared-nothing principle: the new service owns its data exclusively.

// User Service: Complete ownership of user data operations
// This replaces the monolith's user CRUD that previously hit PostgreSQL directly

const express = require('express');
const { MongoClient } = require('mongodb');

const app = express();
app.use(express.json());

const mongoClient = new MongoClient(process.env.MONGO_URI);
let usersCollection;

async function initializeDB() {
  await mongoClient.connect();
  usersCollection = mongoClient.db().collection('users');
  // Ensure email uniqueness index
  await usersCollection.createIndex({ email: 1 }, { unique: true });
}
initializeDB();

// CREATE — replaces monolith INSERT INTO users
app.post('/api/users', async (req, res) => {
  try {
    const result = await usersCollection.insertOne({
      _id: require('uuid').v4(),
      email: req.body.email,
      full_name: req.body.full_name,
      created_at: new Date().toISOString()
    });
    res.status(201).json({ id: result.insertedId, email: req.body.email });
  } catch (error) {
    if (error.code === 11000) {
      res.status(409).json({ error: 'Email already exists' });
    } else {
      res.status(500).json({ error: error.message });
    }
  }
});

// READ — replaces monolith SELECT FROM users
app.get('/api/users/:id', async (req, res) => {
  const user = await usersCollection.findOne(
    { _id: req.params.id },
    { projection: { email: 1, full_name: 1, created_at: 1 } }
  );
  if (!user) return res.status(404).json({ error: 'User not found' });
  res.json(user);
});

// LIST — replaces monolith SELECT with pagination
app.get('/api/users', async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = Math.min(parseInt(req.query.limit) || 20, 100);
  const skip = (page - 1) * limit;
  
  const users = await usersCollection
    .find({}, { projection: { email: 1, full_name: 1, created_at: 1 } })
    .sort({ created_at: -1 })
    .skip(skip)
    .limit(limit)
    .toArray();
  
  const total = await usersCollection.countDocuments();
  
  res.json({
    data: users,
    pagination: {
      page,
      limit,
      total,
      pages: Math.ceil(total / limit)
    }
  });
});

app.listen(3001, () => console.log('User Service running on 3001'));

Phase 3: Dual-Write Period and Verification

During the strangler transition, write to both old and new data stores for critical data. Use a verification pipeline to compare results and detect discrepancies.

// Dual-write middleware for the monolith during transition
// Writes to both legacy PostgreSQL and new MongoDB microservice store

async function dualWriteUser(userData) {
  const results = { legacy: null, microservice: null, errors: [] };
  
  // Write to legacy PostgreSQL (still source of truth during transition)
  try {
    const pgResult = await pgPool.query

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles