erences, or reporting.
Step 2: Deploy API Gateway with Routing Rules
Implement an API gateway to act as the entry point. The gateway routes requests to either the monolith or the new microservice based on path or header configuration.
Gateway Configuration (NestJS/Express Router Pattern):
import { Controller, Get, Post, Req, Res } from '@nestjs/common';
import { Request, Response } from 'express';
import axios from 'axios';
@Controller()
export class GatewayController {
private readonly monolithUrl = process.env.MONOLITH_URL;
private readonly userServiceUrl = process.env.USER_SERVICE_URL;
@Get('/api/users/:id')
async getUser(@Req() req: Request, @Res() res: Response) {
const userId = req.params.id;
// Strategy: Read from new service, fallback to monolith if service is down
try {
const response = await axios.get(`${this.userServiceUrl}/users/${userId}`, {
timeout: 500,
validateStatus: () => true
});
if (response.status === 200) {
return res.json(response.data);
}
} catch (error) {
// Fallback logic for resilience
console.warn('User service unavailable, falling back to monolith');
}
const monolithResponse = await axios.get(`${this.monolithUrl}/api/users/${userId}`);
res.json(monolithResponse.data);
}
@Post('/api/users')
async createUser(@Req() req: Request, @Res() res: Response) {
// Write strategy: Dual-write to ensure consistency during migration
await Promise.all([
axios.post(`${this.userServiceUrl}/users`, req.body),
axios.post(`${this.monolithUrl}/api/users`, req.body)
]);
res.status(201).send();
}
}
Step 3: Implement Dual-Write and Data Migration
Data migration is the highest risk component. Use a dual-write strategy to maintain consistency between the monolith database and the new service database.
- Dual-Write: Update both databases on write operations.
- Backfill: Run a background job to migrate historical data from the monolith DB to the new service DB.
- Verification: Implement checksum validation to ensure data parity.
- Cutover: Switch read traffic to the new service, then disable dual-writes once confidence is established.
Dual-Write Repository Abstraction:
export class MigrationUserRepository {
constructor(
private readonly monolithRepo: MonolithUserRepo,
private readonly microserviceRepo: MicroserviceUserRepo,
private readonly migrationState: MigrationStateService
) {}
async save(user: User): Promise<void> {
const state = await this.migrationState.getCurrent();
if (state === 'DUAL_WRITE' || state === 'CUTOVER') {
await this.microserviceRepo.save(user);
}
if (state === 'DUAL_WRITE' || state === 'MONOLITH') {
await this.monolithRepo.save(user);
}
}
async findById(id: string): Promise<User | null> {
const state = await this.migrationState.getCurrent();
if (state === 'CUTOVER') {
return this.microserviceRepo.findById(id);
}
// During migration, verify consistency and prefer source of truth
const monoUser = await this.monolithRepo.findById(id);
const microUser = await this.microserviceRepo.findById(id);
if (microUser && !this.isConsistent(monoUser, microUser)) {
await this.reconcile(monoUser, microUser);
}
return monoUser;
}
}
Step 4: Inter-Service Communication and Consistency
Replace synchronous monolith method calls with inter-service communication patterns. Use REST or gRPC for query operations and asynchronous messaging (Kafka/RabbitMQ) for state-changing events. Implement the Saga pattern for distributed transactions to maintain data consistency without distributed locks.
Saga Orchestration Example:
// OrderService Saga Orchestration
export class OrderSaga {
constructor(private readonly eventBus: EventBus) {}
async executeOrderCreation(order: Order) {
const sagaId = generateId();
try {
await this.eventBus.publish('OrderCreated', { sagaId, order });
// Reserve Inventory (Async)
await this.eventBus.publish('ReserveInventory', { sagaId, order });
// Process Payment (Async)
await this.eventBus.publish('ProcessPayment', { sagaId, order });
// If all steps succeed, confirm order
await this.eventBus.publish('ConfirmOrder', { sagaId, order });
} catch (error) {
// Compensating transactions
await this.eventBus.publish('CancelOrder', { sagaId, order });
throw error;
}
}
}
Step 5: Observability and CI/CD Adaptation
Microservices require distributed tracing. Integrate OpenTelemetry to propagate context across service boundaries. Update CI/CD pipelines to support independent deployments. Each service must have isolated build, test, and deploy stages. Implement contract testing (Pact) to prevent breaking changes between services.
Pitfall Guide
1. The Distributed Monolith
Extracting code into separate services but maintaining tight coupling via synchronous RPC calls for every operation. This creates a system with the complexity of microservices and the performance characteristics of a monolith, plus added network latency.
- Best Practice: Enforce loose coupling. Services should only communicate via well-defined APIs and asynchronous events. Avoid cross-service joins or transactions.
2. Shared Database Schema
Multiple services accessing the same database tables directly. This recreates the monolith's data coupling, making schema changes difficult and risking data corruption.
- Best Practice: Database per service. Each service owns its data store. Share data via APIs or event streams, never direct database access.
3. Ignoring Network Partitions
Assuming the network is reliable. In distributed systems, timeouts, retries, and partial failures are inevitable. Lack of resilience patterns leads to cascading failures.
- Best Practice: Implement circuit breakers, retries with exponential backoff, and bulkheads. Design for failure; ensure services degrade gracefully when dependencies are unavailable.
4. Chatty Services
Designing fine-grained services that require excessive inter-service calls to fulfill a single user request. This degrades latency and increases load.
- Best Practice: Co-locate data access patterns. Use the BFF (Backend for Frontend) pattern to aggregate data. Group operations that frequently occur together within the same service boundary.
5. Inconsistent Data Models
Duplicating data across services without synchronization mechanisms. When the monolith updates a shared entity, the microservice remains stale, causing business logic errors.
- Best Practice: Define clear ownership for each data entity. Use event sourcing or CDC (Change Data Capture) to propagate changes. Implement reconciliation jobs during migration.
6. Premature Microservices
Migrating to microservices before the domain is stable or the team size justifies the overhead. Small teams managing dozens of services spend more time on operations than feature development.
- Best Practice: Start with a modular monolith. Migrate only when deployment frequency is hindered by the monolith structure or specific domains require independent scaling.
7. Missing Observability
Deploying microservices without centralized logging, metrics, and tracing. Debugging issues across services becomes impossible, increasing MTTR (Mean Time to Recovery).
- Best Practice: Implement OpenTelemetry from day one. Ensure every request carries a trace ID. Centralize logs and set up dashboards for service health and business metrics.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP (<10 devs) | Modular Monolith | Low ops overhead, fast iteration, single deployment unit | Low |
| High Traffic E-commerce | Strangler Fig + Event Sourcing | Independent scaling, resilience, domain isolation | High |
| Legacy Enterprise | Domain-Driven Decomposition | Risk mitigation, gradual change, preserves stability | Medium |
| Regulated Finance | Modular Monolith + Strict Isolation | Auditability, transaction integrity, compliance ease | Medium |
| Legacy Monolith with Stable Domain | Keep Monolith, Improve Modularity | Migration cost outweighs benefits if velocity is acceptable | Low |
Configuration Template
Kubernetes Deployment with Sidecar Tracing:
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
labels:
app: user-service
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
spec:
containers:
- name: user-service
image: registry/user-service:latest
ports:
- containerPort: 8080
env:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector:4317"
- name: DB_CONNECTION_STRING
valueFrom:
secretKeyRef:
name: db-creds
key: connection-string
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
- name: otel-collector
image: otel/opentelemetry-collector-contrib:latest
ports:
- containerPort: 4317
- containerPort: 8888
args: ["--config=/etc/otel/config.yaml"]
Quick Start Guide
- Initialize Gateway: Deploy an API gateway (e.g., Kong, NGINX, or custom Express router) and configure a route for the target domain path (e.g.,
/api/users).
- Create Service Skeleton: Scaffold a new service repository with health checks, metrics endpoints, and OpenTelemetry instrumentation. Deploy to the staging environment.
- Route Traffic: Update gateway configuration to route 100% of traffic for
/api/users to the new service. Verify response codes and latency.
- Migrate Data: Run the dual-write configuration and backfill historical data. Execute consistency checks to validate data parity.
- Validate and Isolate: Monitor error rates and performance. Once stable, remove the monolith dependency for this domain and update the monolith codebase to remove the extracted logic.