Scaling with Microservices
When and how to transition from monolith to microservices. Learn the practical strategies, patterns, and pitfalls to avoid when scaling your architecture.
Scaling with Microservices: The Complete Guide
Microservices have become the go-to architecture for scaling modern applications. But when should you make the switch, and how do you do it right? Let's dive deep into the practical aspects of microservices architecture.
The Monolith vs Microservices Debate
When to Keep Your Monolith
Don't rush to microservices if:
- ๐ Your startup is in early stages (< 10 engineers)
- ๐ You're still finding product-market fit
- ๐ฏ Your application has clear boundaries and low complexity
- ๐ฅ Your team lacks distributed systems experience
- ๐ฐ You have limited DevOps resources
The monolith is powerful when:
// Simple, cohesive application
class ECommerceApp {
products: ProductService;
orders: OrderService;
users: UserService;
payments: PaymentService;
// All in one codebase, deployed together
// Easy to develop, test, and deploy
}
When to Consider Microservices
Signs you're ready:
- โ Team size > 20-30 engineers
- โ Deployment bottlenecks (waiting on other teams)
- โ Different services have different scaling needs
- โ Parts of the system need different tech stacks
- โ You want independent service deployments
- โ You have complex domain boundaries
The Migration Strategy
Phase 1: Prepare the Monolith
Before splitting, clean up your monolith:
// Bad: Tightly coupled code
class UserController {
async createUser(data: UserData) {
const user = await db.users.create(data);
await db.orders.update({ userId: user.id }); // Direct DB access
await emailService.send(user.email); // Tight coupling
return user;
}
}
// Good: Loosely coupled with events
class UserController {
async createUser(data: UserData) {
const user = await db.users.create(data);
// Publish event instead of direct coupling
await eventBus.publish('user.created', { userId: user.id });
return user;
}
}
// Other services listen to events
eventBus.subscribe('user.created', async (event) => {
await emailService.sendWelcomeEmail(event.userId);
});
Phase 2: Identify Service Boundaries
Use Domain-Driven Design (DDD):
# Bad Decomposition (by technical layers)
- database-service
- api-service
- ui-service
# Good Decomposition (by business domains)
- user-service (authentication, profiles)
- product-service (catalog, inventory)
- order-service (cart, checkout, fulfillment)
- payment-service (billing, transactions)
- notification-service (email, SMS, push)
Phase 3: Extract Services Gradually
Strangler Fig Pattern:
// 1. Start with API Gateway
const apiGateway = express();
// 2. Route new features to microservices
apiGateway.use('/api/notifications', proxy('http://notification-service:3001'));
// 3. Keep old routes on monolith
apiGateway.use('/api/users', proxy('http://monolith:3000'));
// 4. Gradually migrate routes
apiGateway.use('/api/products', proxy('http://product-service:3002'));
Microservices Patterns
1. API Gateway Pattern
// api-gateway/src/index.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
// Service registry
const services = {
users: 'http://user-service:3000',
products: 'http://product-service:3001',
orders: 'http://order-service:3002',
};
// Route to services
Object.entries(services).forEach(([name, target]) => {
app.use(`/api/${name}`, createProxyMiddleware({
target,
changeOrigin: true,
pathRewrite: { [`^/api/${name}`]: '' },
}));
});
// Cross-cutting concerns
app.use(authMiddleware);
app.use(rateLimiter);
app.use(logger);
app.listen(8080);
2. Service Discovery
// Using Consul for service discovery
import Consul from 'consul';
const consul = new Consul();
// Register service
await consul.agent.service.register({
name: 'product-service',
address: 'localhost',
port: 3001,
check: {
http: 'http://localhost:3001/health',
interval: '10s',
},
});
// Discover service
const getServiceUrl = async (serviceName: string) => {
const services = await consul.health.service({
service: serviceName,
passing: true,
});
const service = services[0]?.Service;
return `http://${service.Address}:${service.Port}`;
};
// Use in client
const productServiceUrl = await getServiceUrl('product-service');
const response = await fetch(`${productServiceUrl}/products`);
3. Event-Driven Communication
// Using RabbitMQ for async communication
import amqp from 'amqplib';
class EventBus {
private connection: amqp.Connection;
private channel: amqp.Channel;
async publish(event: string, data: any) {
await this.channel.assertExchange('events', 'topic');
this.channel.publish(
'events',
event,
Buffer.from(JSON.stringify(data))
);
}
async subscribe(pattern: string, handler: (data: any) => Promise<void>) {
const queue = await this.channel.assertQueue('', { exclusive: true });
await this.channel.bindQueue(queue.queue, 'events', pattern);
this.channel.consume(queue.queue, async (msg) => {
if (msg) {
const data = JSON.parse(msg.content.toString());
await handler(data);
this.channel.ack(msg);
}
});
}
}
// Order Service publishes event
await eventBus.publish('order.created', {
orderId: '123',
userId: 'user-456',
items: [...],
});
// Inventory Service subscribes
await eventBus.subscribe('order.created', async (order) => {
await decrementInventory(order.items);
});
// Notification Service subscribes
await eventBus.subscribe('order.created', async (order) => {
await sendOrderConfirmation(order.userId, order.orderId);
});
4. Circuit Breaker Pattern
// Prevent cascading failures
import CircuitBreaker from 'opossum';
const options = {
timeout: 3000, // If request takes longer than 3s, trigger failure
errorThresholdPercentage: 50, // Open circuit if 50% fail
resetTimeout: 30000, // Try again after 30s
};
const breaker = new CircuitBreaker(callExternalService, options);
// Fallback when circuit is open
breaker.fallback(() => ({
status: 'unavailable',
message: 'Service temporarily unavailable',
}));
// Use the circuit breaker
app.get('/api/products', async (req, res) => {
try {
const products = await breaker.fire();
res.json(products);
} catch (error) {
res.status(503).json({ error: 'Service unavailable' });
}
});
5. Saga Pattern (Distributed Transactions)
// Orchestration-based saga
class OrderSaga {
async createOrder(orderData: OrderData) {
const sagaId = generateId();
const compensations = [];
try {
// Step 1: Reserve inventory
const inventory = await inventoryService.reserve(orderData.items);
compensations.push(() => inventoryService.release(inventory.id));
// Step 2: Process payment
const payment = await paymentService.charge(orderData.payment);
compensations.push(() => paymentService.refund(payment.id));
// Step 3: Create order
const order = await orderService.create(orderData);
// Success - commit saga
await sagaStore.complete(sagaId);
return order;
} catch (error) {
// Failure - run compensations in reverse
for (const compensate of compensations.reverse()) {
try {
await compensate();
} catch (compError) {
console.error('Compensation failed:', compError);
}
}
await sagaStore.fail(sagaId, error);
throw error;
}
}
}
Data Management
Database per Service
// โ Don't share databases
class OrderService {
async getOrderWithUser(orderId: string) {
// Bad: Accessing user database directly
const order = await orderDb.orders.findOne(orderId);
const user = await userDb.users.findOne(order.userId); // Cross-DB query
return { order, user };
}
}
// โ
Do communicate via APIs/events
class OrderService {
async getOrderWithUser(orderId: string) {
const order = await orderDb.orders.findOne(orderId);
// Good: Call user service API
const user = await fetch(`http://user-service/users/${order.userId}`)
.then(r => r.json());
return { order, user };
}
}
CQRS (Command Query Responsibility Segregation)
// Write Model - optimized for commands
class OrderWriteService {
async createOrder(data: CreateOrderDTO) {
const order = await orderDb.orders.create(data);
await eventBus.publish('order.created', order);
return order;
}
}
// Read Model - optimized for queries
class OrderReadService {
async getOrderDetails(orderId: string) {
// Denormalized data for fast reads
return await orderReadDb.orderDetails.findOne(orderId);
}
}
// Event handler updates read model
eventBus.subscribe('order.created', async (order) => {
const user = await userService.getUser(order.userId);
const products = await productService.getProducts(order.productIds);
await orderReadDb.orderDetails.create({
...order,
userName: user.name,
productNames: products.map(p => p.name),
});
});
Observability
Distributed Tracing
// Using OpenTelemetry
import { trace } from '@opentelemetry/api';
const tracer = trace.getTracer('order-service');
app.post('/orders', async (req, res) => {
const span = tracer.startSpan('create-order');
try {
// Trace database call
const dbSpan = tracer.startSpan('db-insert', { parent: span });
const order = await db.orders.create(req.body);
dbSpan.end();
// Trace external service call
const paymentSpan = tracer.startSpan('payment-charge', { parent: span });
await paymentService.charge(order.id);
paymentSpan.end();
span.setStatus({ code: 1 }); // OK
res.json(order);
} catch (error) {
span.setStatus({ code: 2, message: error.message }); // ERROR
throw error;
} finally {
span.end();
}
});
Centralized Logging
// Structured logging with correlation IDs
import winston from 'winston';
const logger = winston.createLogger({
format: winston.format.json(),
defaultMeta: { service: 'order-service' },
transports: [
new winston.transports.File({ filename: 'error.log', level: 'error' }),
new winston.transports.File({ filename: 'combined.log' }),
],
});
// Add correlation ID to all logs
app.use((req, res, next) => {
req.correlationId = req.headers['x-correlation-id'] || generateId();
res.setHeader('x-correlation-id', req.correlationId);
next();
});
// Log with context
app.post('/orders', async (req, res) => {
logger.info('Creating order', {
correlationId: req.correlationId,
userId: req.body.userId,
items: req.body.items.length,
});
try {
const order = await orderService.create(req.body);
logger.info('Order created successfully', {
correlationId: req.correlationId,
orderId: order.id,
});
res.json(order);
} catch (error) {
logger.error('Order creation failed', {
correlationId: req.correlationId,
error: error.message,
stack: error.stack,
});
res.status(500).json({ error: 'Internal server error' });
}
});
Common Pitfalls to Avoid
1. Premature Decomposition
// โ Don't: Creating too many services too early
- auth-service
- user-profile-service
- user-preferences-service
- user-settings-service
// โ
Do: Start with coarse-grained services
- user-service (handles all user-related functionality)
2. Distributed Monolith
// โ Don't: Services that all depend on each other
order-service โ payment-service โ user-service โ order-service // Circular!
// โ
Do: Minimize direct dependencies
order-service โ events โ payment-service
โ notification-service
โ inventory-service
3. Ignoring Network Failures
// โ Don't: Assume network calls always succeed
const user = await fetch(`${userService}/users/${id}`).then(r => r.json());
// โ
Do: Handle failures gracefully
const user = await withRetry(
() => fetch(`${userService}/users/${id}`).then(r => r.json()),
{ attempts: 3, timeout: 5000 }
).catch(error => {
logger.error('User service unavailable', { error });
return { id, name: 'Unknown User' }; // Fallback
});
Deployment Strategy
Docker Compose for Development
version: '3.8'
services:
api-gateway:
build: ./api-gateway
ports:
- "8080:8080"
environment:
- NODE_ENV=development
user-service:
build: ./user-service
environment:
- DATABASE_URL=postgresql://user:pass@user-db:5432/users
depends_on:
- user-db
order-service:
build: ./order-service
environment:
- DATABASE_URL=postgresql://user:pass@order-db:5432/orders
- RABBITMQ_URL=amqp://rabbitmq:5672
depends_on:
- order-db
- rabbitmq
rabbitmq:
image: rabbitmq:3-management
ports:
- "15672:15672" # Management UI
user-db:
image: postgres:14
environment:
POSTGRES_DB: users
order-db:
image: postgres:14
environment:
POSTGRES_DB: orders
Kubernetes for Production
# order-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: myregistry/order-service:v1.0.0
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: order-service-secrets
key: database-url
resources:
limits:
memory: "512Mi"
cpu: "500m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 80
targetPort: 3000
type: ClusterIP
Success Metrics
Track these KPIs:
- Deployment Frequency: How often each service deploys
- Lead Time: Time from commit to production
- Mean Time to Recovery (MTTR): How fast you recover from failures
- Service Availability: 99.9% uptime per service
- Request Latency: p50, p95, p99 response times
- Error Rate: 4xx and 5xx responses
Conclusion
Microservices are powerful but complex. Success requires:
- Start simple: Begin with a modular monolith
- Extract gradually: Use strangler fig pattern
- Automate everything: CI/CD, testing, monitoring
- Design for failure: Circuit breakers, retries, fallbacks
- Observe deeply: Tracing, logging, metrics
- Team alignment: Conway's Law is real
The goal isn't microservicesโit's scalability, velocity, and reliability. Choose the architecture that serves your business goals.
Need help scaling your architecture? Contact us for an architecture review and migration strategy.
Tags: Microservices, Architecture, Scalability, DevOps, Distributed Systems, Docker, Kubernetes
Share this article
Related Articles
The Future of API Design
Exploring GraphQL, REST, and what comes next in API architecture. Discover emerging patterns and technologies shaping the future of API development.
Building Scalable APIs with Next.js 14
Learn how to build production-ready APIs using Next.js 14's App Router and Route Handlers with best practices for authentication, validation, and error handling.