ep 2: Implement the Strangler Fig Proxy
Route traffic through a proxy that can direct requests to either the monolith or the new service. This allows incremental migration without changing client code.
TypeScript Implementation: Smart Router
import { Request, Response, NextFunction } from 'express';
import axios from 'axios';
import { createHash } from 'crypto';
// Configuration for routing logic
interface RouteConfig {
serviceUrl: string;
featureFlag?: string;
fallbackToMonolith: boolean;
}
class MigrationRouter {
private routes: Map<string, RouteConfig>;
private monolithBaseUrl: string;
constructor(monolithBaseUrl: string) {
this.monolithBaseUrl = monolithBaseUrl;
this.routes = new Map();
}
// Register a new service endpoint
registerRoute(pathPattern: string, config: RouteConfig): void {
this.routes.set(pathPattern, config);
}
// Middleware to intercept and route requests
middleware = async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const matchedRoute = this.findMatchingRoute(req.path);
if (!matchedRoute) {
// No migration for this path, let monolith handle it
return next();
}
// Check feature flags or routing rules
const shouldRouteToService = this.evaluateRoutingRule(matchedRoute, req);
if (shouldRouteToService) {
try {
const response = await this.proxyRequest(req, matchedRoute.serviceUrl);
res.status(response.status).json(response.data);
} catch (error) {
if (matchedRoute.fallbackToMonolith) {
console.warn(`Service ${matchedRoute.serviceUrl} failed, falling back to monolith`);
await this.proxyToMonolith(req, res);
} else {
res.status(502).json({ error: 'Service unavailable' });
}
}
} else {
// Shadow mode: Send to service but return monolith response for validation
this.shadowRequest(req, matchedRoute.serviceUrl);
await this.proxyToMonolith(req, res);
}
};
private findMatchingRoute(path: string): RouteConfig | undefined {
for (const [pattern, config] of this.routes.entries()) {
if (path.startsWith(pattern)) return config;
}
return undefined;
}
private evaluateRoutingRule(config: RouteConfig, req: Request): boolean {
// Implement logic based on headers, cookies, or tenant ID
const tenantId = req.headers['x-tenant-id'] as string;
if (config.featureFlag === 'beta-users' && tenantId?.includes('BETA')) {
return true;
}
// Default to percentage-based rollout
const hash = createHash('md5').update(tenantId || req.ip).digest('hex');
const rolloutPercent = parseInt(hash.slice(0, 2), 16) % 100;
return rolloutPercent < 5; // Start with 5% traffic
}
private async proxyRequest(req: Request, baseUrl: string): Promise<any> {
const url = `${baseUrl}${req.path}`;
return axios({
method: req.method,
url,
data: req.body,
headers: { ...req.headers, host: undefined },
timeout: 5000,
});
}
private async proxyToMonolith(req: Request, res: Response): Promise<void> {
const url = `${this.monolithBaseUrl}${req.path}`;
const response = await axios({
method: req.method,
url,
data: req.body,
headers: { ...req.headers, host: undefined },
});
res.status(response.status).json(response.data);
}
private shadowRequest(req: Request, serviceUrl: string): void {
// Fire-and-forget shadow request for validation
this.proxyRequest(req, serviceUrl).catch(err => {
console.error(`Shadow request failed: ${err.message}`);
// Emit metric for monitoring
});
}
}
export default MigrationRouter;
Step 3: Establish Communication Contracts
Synchronous REST calls between services increase latency and coupling. Prefer asynchronous event-driven communication for cross-service data propagation. Define strict contracts to prevent breaking changes.
Event Contract Definition:
// src/shared/events/user-events.ts
import { z } from 'zod';
export const UserCreatedEventSchema = z.object({
eventId: z.string().uuid(),
timestamp: z.string().datetime(),
eventType: z.literal('user.created'),
payload: z.object({
userId: z.string(),
email: z.string().email(),
tenantId: z.string(),
metadata: z.record(z.string()).optional(),
}),
});
export type UserCreatedEvent = z.infer<typeof UserCreatedEventSchema>;
// Validation middleware for producers/consumers
export const validateEvent = <T>(schema: z.ZodSchema<T>, event: unknown): T => {
const result = schema.safeParse(event);
if (!result.success) {
throw new Error(`Invalid event structure: ${result.error.message}`);
}
return result.data;
};
Step 4: Database Decomposition
The most critical architectural decision is database separation. Shared databases defeat the purpose of microservices by creating implicit coupling. Implement the "Database per Service" pattern.
- Migration Strategy: Use Change Data Capture (CDC) tools like Debezium to replicate data from the monolith database to the new service's database during the transition.
- Consistency: Accept eventual consistency. Use the SAGA pattern for distributed transactions. Avoid distributed two-phase commit (2PC) protocols.
SAGA Orchestration Example:
// src/services/order/saga.ts
import { EventEmitter } from 'events';
export class OrderSaga {
private emitter = new EventEmitter();
async executeOrder(order: Order): Promise<void> {
const steps = [
{ action: () => this.reserveInventory(order), compensate: () => this.releaseInventory(order) },
{ action: () => this.processPayment(order), compensate: () => this.refundPayment(order) },
{ action: () => this.shipOrder(order), compensate: () => this.cancelShipment(order) },
];
const completedSteps: typeof steps = [];
try {
for (const step of steps) {
await step.action();
completedSteps.push(step);
}
} catch (error) {
// Compensate in reverse order
console.error('Saga failed, initiating compensation', error);
for (let i = completedSteps.length - 1; i >= 0; i--) {
try {
await completedSteps[i].compensate();
} catch (compError) {
// Log and alert; manual intervention may be required
console.error(`Compensation failed for step ${i}`, compError);
}
}
throw error;
}
}
}
Architecture Rationale
- Hexagonal Architecture: Each microservice should implement ports and adapters to isolate domain logic from infrastructure. This ensures services remain testable and portable.
- API Gateway: Centralize cross-cutting concerns (auth, rate limiting, routing) at the gateway layer. Services should not duplicate these concerns.
- Observability: Implement OpenTelemetry for distributed tracing. Every request must carry a
traceId across service boundaries. Without this, debugging is impossible.
Pitfall Guide
-
Distributed Monolith: Extracting services but maintaining synchronous chains of calls (A calls B calls C) creates a system that is slower and more fragile than the monolith. Mitigation: Break chains using events or batch APIs. Design services to handle requests independently.
-
Shared Database Migration: Attempting to split the database by moving tables to different schemas while keeping a single connection pool. This creates locking contention and prevents independent scaling. Mitigation: Enforce strict database per service. Use CDC for data synchronization during migration.
-
Ignoring Network Latency: Monolith code assumes function calls are instantaneous. Microservice calls involve network overhead, serialization, and potential retries. Code that performs 100 small calls will suffer catastrophic latency. Mitigation: Implement BFF (Backend for Frontend) patterns, batch requests, and caching. Review N+1 query patterns in the context of RPC calls.
-
Lack of Idempotency: Network partitions cause retries. If services are not idempotent, retries result in duplicate orders, charges, or records. Mitigation: Every API endpoint processing state changes must accept an idempotency key. Implement deduplication logic based on request signatures.
-
Premature Service Count Optimization: Creating too many small services increases operational overhead and deployment complexity without value. Mitigation: Start with coarse-grained services aligned to bounded contexts. Split further only when independent scaling or deployment is proven necessary.
-
Copy-Paste Refactoring: Moving code verbatim from monolith to service without refactoring the domain model. This propagates technical debt and coupling. Mitigation: Use migration as an opportunity to refactor. Apply DDD principles to redefine aggregates and entities within the new service boundary.
-
Insufficient Observability: Deploying microservices without structured logging, metrics, and tracing. Mitigation: Mandate observability as a non-functional requirement. Implement correlation IDs, health checks, and readiness probes before any service is extracted.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| High Churn, Clear Domain | Strangler Fig Extraction | Immediate velocity gains; low risk isolation. | Medium (Infrastructure setup) |
| Legacy Stack, Low Budget | Modular Monolith First | Reduce coupling before splitting; avoids distributed complexity costs. | Low (Refactoring only) |
| Regulatory Data Isolation | Database-First Split | Compliance requires physical data separation; services follow data. | High (Security & Compliance) |
| Small Team, MVP Stage | Monolith with Modules | Microservices overhead outweighs benefits; focus on product-market fit. | Minimal |
Configuration Template
Ready-to-use docker-compose setup for local development with sidecar logging and service mesh simulation.
version: '3.8'
services:
api-gateway:
build: ./gateway
ports:
- "8080:8080"
environment:
- MONOLITH_URL=http://monolith:3000
- USER_SERVICE_URL=http://user-service:3001
depends_on:
- monolith
- user-service
user-service:
build: ./services/user
ports:
- "3001:3001"
environment:
- DB_HOST=user-db
- KAFKA_BROKER=kafka:9092
depends_on:
- user-db
- kafka
monolith:
build: ./monolith
ports:
- "3000:3000"
environment:
- DB_HOST=monolith-db
depends_on:
- monolith-db
# Shared Infrastructure
kafka:
image: confluentinc/cp-kafka:latest
ports:
- "9092:9092"
environment:
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
user-db:
image: postgres:15
volumes:
- user-data:/var/lib/postgresql/data
monolith-db:
image: postgres:15
volumes:
- monolith-data:/var/lib/postgresql/data
volumes:
user-data:
monolith-data:
Quick Start Guide
- Initialize Monorepo: Create a workspace structure with
packages/monolith, packages/services/user, and packages/shared.
- Run Infrastructure: Execute
docker-compose up -d kafka user-db monolith-db to provision dependencies.
- Start Services: Run
npm run dev in the monolith and user-service directories.
- Verify Routing: Send a request to the gateway at
http://localhost:8080/api/users. Verify the proxy routes to the monolith by default.
- Enable Migration: Update the gateway configuration to route
/api/users to the user-service. Validate the response matches the monolith output.
- Monitor: Check logs and traces to ensure the request flows through the new service boundary correctly.