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:
- SQL Monolith → SQL Microservices: Keep relational integrity but decompose schema ownership across services. Common in enterprises moving from monoliths to domain-driven service boundaries while retaining ACID guarantees.
- SQL Monolith → NoSQL Microservices: Complete paradigm shift. Requires denormalization planning, event-driven patterns, and careful handling of formerly relational data. Suited for high-write throughput systems or flexible document models.
- NoSQL Monolith → SQL Microservices: Introducing relational structure where previously there was none. Often driven by regulatory requirements, reporting needs, or complex transactional workflows that NoSQL handles poorly.
- NoSQL Monolith → NoSQL Microservices: Decomposing a single NoSQL database (e.g., a massive MongoDB collection set or a single Cassandra keyspace) into per-service data stores. Focuses on data ownership and query isolation.
Decision Triggers
Ask these questions before committing to a dual migration:
- Is your current database the bottleneck? If query performance, schema rigidity, or scaling costs are your primary pain point, prioritize database migration first and defer microservice extraction.
- Is team velocity suffering due to monolith coupling? If deployment coordination, merge conflicts, and tight coupling slow delivery, extract microservices first while maintaining the existing database, then migrate data stores incrementally.
- Are both dimensions equally problematic? Then a phased, service-by-service approach is mandatory. Never attempt a "big bang" dual migration.
- Do you have strong transactional requirements? If ACID is non-negotiable across service boundaries, consider staying with SQL or implementing the Saga pattern early.
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