Essential patterns and practices for designing scalable microservices architecture.
Microservices Architecture: Design Patterns for Scale
Microservices architecture enables independent scaling of development teams and systems. This guide covers essential patterns and practices for building successful microservices-based applications.
Table of Contents
- Service Design Principles
- Communication Patterns
- Data Management
- Service Discovery
- Deployment Strategies
- Monitoring & Observability
Service Design Principles
Domain-Driven Design
Design services around business domains, not technical layers:
// Order Service - handles order lifecycle
class OrderService {
constructor() {
this.domain = "order-management";
}
async createOrder(orderData) {
const order = new Order(orderData);
await order.validate();
const savedOrder = await this.orderRepository.save(order);
// Publish domain event
await this.eventBus.publish("order.created", {
orderId: savedOrder.id,
customerId: savedOrder.customerId,
totalAmount: savedOrder.totalAmount,
});
return savedOrder;
}
}
// Inventory Service - manages stock
class InventoryService {
constructor() {
this.domain = "inventory-management";
}
async reserveItems(items) {
for (const item of items) {
const available = await this.checkAvailability(
item.productId,
item.quantity,
);
if (!available) {
throw new Error(`Insufficient stock for product ${item.productId}`);
}
await this.reserveStock(item.productId, item.quantity);
}
}
}
Service Boundaries
Define clear interfaces and responsibilities:
class ServiceInterface {
constructor(serviceName, version) {
this.serviceName = serviceName;
this.version = version;
this.endpoints = new Map();
this.events = new Map();
}
defineEndpoint(path, method, handler, schema) {
this.endpoints.set(`${method}:${path}`, {
handler,
schema,
rateLimit: { windowMs: 15 * 60 * 1000, max: 100 },
authentication: true,
});
}
defineEvent(eventName, schema) {
this.events.set(eventName, { schema, version: this.version });
}
}
// Usage
const userService = new ServiceInterface("user-service", "v1");
userService.defineEndpoint("/users", "POST", createUserHandler, {
body: {
type: "object",
required: ["email", "password"],
properties: {
email: { type: "string", format: "email" },
password: { type: "string", minLength: 8 },
},
},
});
Communication Patterns
API Gateway Pattern
Centralize cross-cutting concerns:
class APIGateway {
constructor() {
this.services = new Map();
this.circuitBreakers = new Map();
}
registerService(serviceName, instances) {
this.services.set(serviceName, instances);
this.circuitBreakers.set(
serviceName,
new CircuitBreaker({
failureThreshold: 5,
resetTimeout: 30000,
}),
);
}
async routeRequest(serviceName, path, options) {
const circuitBreaker = this.circuitBreakers.get(serviceName);
return await circuitBreaker.execute(async () => {
const serviceInstance = this.getHealthyInstance(serviceName);
const url = `${serviceInstance.baseUrl}${path}`;
const response = await fetch(url, {
...options,
timeout: 5000,
headers: {
...options.headers,
"X-Request-ID": this.generateRequestId(),
},
});
if (!response.ok) {
throw new Error(`Service ${serviceName} returned ${response.status}`);
}
return response.json();
});
}
}
Event-Driven Communication
Use events for loose coupling:
class EventBus {
constructor() {
this.subscribers = new Map();
}
subscribe(eventType, handler) {
if (!this.subscribers.has(eventType)) {
this.subscribers.set(eventType, []);
}
this.subscribers.get(eventType).push(handler);
}
async publish(eventType, data) {
const event = {
id: this.generateEventId(),
type: eventType,
data,
timestamp: new Date().toISOString(),
version: "1.0",
};
const handlers = this.subscribers.get(eventType) || [];
await Promise.all(handlers.map((handler) => handler(event)));
}
}
// Usage
const eventBus = new EventBus();
// Order service publishes events
eventBus.subscribe("order.created", async (event) => {
await inventoryService.reserveItems(event.data.items);
});
eventBus.subscribe("order.created", async (event) => {
await emailService.sendOrderConfirmation(event.data.customerId);
});
Saga Pattern for Distributed Transactions
class OrderSaga {
constructor() {
this.steps = [];
this.compensations = [];
}
async execute(orderData) {
try {
// Step 1: Create order
const order = await this.orderService.createOrder(orderData);
this.steps.push({ service: "order", action: "create", data: order });
// Step 2: Reserve inventory
await this.inventoryService.reserveItems(orderData.items);
this.steps.push({
service: "inventory",
action: "reserve",
data: orderData.items,
});
// Step 3: Process payment
const payment = await this.paymentService.processPayment(
orderData.payment,
);
this.steps.push({ service: "payment", action: "process", data: payment });
return { success: true, orderId: order.id };
} catch (error) {
await this.compensate();
throw error;
}
}
async compensate() {
for (const step of this.steps.reverse()) {
try {
await this.executeCompensation(step);
} catch (compensationError) {
console.error("Compensation failed:", compensationError);
}
}
}
}
Data Management
Database per Service
Each service owns its data:
class UserService {
constructor() {
this.database = new Database("user_db");
}
async createUser(userData) {
return await this.database.users.create(userData);
}
async getUserById(id) {
return await this.database.users.findById(id);
}
}
class OrderService {
constructor() {
this.database = new Database("order_db");
}
async createOrder(orderData) {
// Only store user reference, not user data
const order = {
...orderData,
userId: orderData.userId, // Reference only
};
return await this.database.orders.create(order);
}
}
CQRS Pattern
Separate read and write models:
// Command side - handles writes
class OrderCommandService {
async createOrder(command) {
const order = new Order(command);
await this.orderRepository.save(order);
await this.eventStore.append("order.created", {
orderId: order.id,
customerId: order.customerId,
items: order.items,
});
}
}
// Query side - handles reads
class OrderQueryService {
async getOrderById(id) {
return await this.orderReadModel.findById(id);
}
async getOrdersByCustomer(customerId) {
return await this.orderReadModel.findByCustomerId(customerId);
}
}
// Event handler updates read model
class OrderProjectionHandler {
async handle(event) {
switch (event.type) {
case "order.created":
await this.updateOrderProjection(event.data);
break;
case "order.updated":
await this.updateOrderProjection(event.data);
break;
}
}
}
Service Discovery
Service Registry Pattern
class ServiceRegistry {
constructor() {
this.services = new Map();
this.healthChecks = new Map();
}
register(serviceName, instance) {
if (!this.services.has(serviceName)) {
this.services.set(serviceName, []);
}
this.services.get(serviceName).push({
...instance,
registeredAt: new Date(),
status: "healthy",
});
this.startHealthCheck(serviceName, instance);
}
discover(serviceName) {
const instances = this.services.get(serviceName) || [];
return instances.filter((instance) => instance.status === "healthy");
}
async startHealthCheck(serviceName, instance) {
setInterval(async () => {
try {
const response = await fetch(`${instance.baseUrl}/health`);
instance.status = response.ok ? "healthy" : "unhealthy";
} catch (error) {
instance.status = "unhealthy";
}
}, 30000); // Check every 30 seconds
}
}
Load Balancing
class LoadBalancer {
constructor(strategy = "round-robin") {
this.strategy = strategy;
this.counters = new Map();
}
selectInstance(serviceName, instances) {
switch (this.strategy) {
case "round-robin":
return this.roundRobin(serviceName, instances);
case "random":
return this.random(instances);
case "least-connections":
return this.leastConnections(instances);
default:
return instances[0];
}
}
roundRobin(serviceName, instances) {
if (!this.counters.has(serviceName)) {
this.counters.set(serviceName, 0);
}
const counter = this.counters.get(serviceName);
const instance = instances[counter % instances.length];
this.counters.set(serviceName, counter + 1);
return instance;
}
}
Deployment Strategies
Containerization with Docker
# Dockerfile for Node.js microservice
FROM node:18-alpine
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci --only=production
# Copy source code
COPY src/ ./src/
# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
USER nodejs
EXPOSE 3000
CMD ["node", "src/index.js"]
Kubernetes Deployment
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: user-service
spec:
replicas: 3
selector:
matchLabels:
app: user-service
template:
metadata:
labels:
app: user-service
spec:
containers:
- name: user-service
image: user-service:latest
ports:
- containerPort: 3000
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: db-secret
key: url
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "256Mi"
cpu: "200m"
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 30
periodSeconds: 10
Blue-Green Deployment
class DeploymentManager {
constructor() {
this.environments = {
blue: { active: true, version: "v1.0" },
green: { active: false, version: "v1.1" },
};
}
async deployToGreen(newVersion) {
// Deploy to green environment
await this.deployService("green", newVersion);
// Run health checks
const healthCheck = await this.runHealthChecks("green");
if (!healthCheck.passed) {
throw new Error("Health checks failed");
}
// Run smoke tests
const smokeTests = await this.runSmokeTests("green");
if (!smokeTests.passed) {
throw new Error("Smoke tests failed");
}
return { ready: true, environment: "green" };
}
async switchTraffic() {
// Switch traffic from blue to green
this.environments.blue.active = false;
this.environments.green.active = true;
await this.updateLoadBalancer();
}
}
Monitoring & Observability
Distributed Tracing
class TracingMiddleware {
constructor() {
this.tracer = new Tracer();
}
middleware() {
return (req, res, next) => {
const traceId = req.headers["x-trace-id"] || this.generateTraceId();
const spanId = this.generateSpanId();
req.tracing = { traceId, spanId };
const span = this.tracer.startSpan({
traceId,
spanId,
operationName: `${req.method} ${req.path}`,
startTime: Date.now(),
});
res.on("finish", () => {
span.finish({
statusCode: res.statusCode,
duration: Date.now() - span.startTime,
});
});
next();
};
}
}
Health Checks
class HealthCheckService {
constructor() {
this.checks = new Map();
}
addCheck(name, checkFunction) {
this.checks.set(name, checkFunction);
}
async runChecks() {
const results = {};
let overallStatus = "healthy";
for (const [name, checkFunction] of this.checks) {
try {
const result = await checkFunction();
results[name] = { status: "healthy", ...result };
} catch (error) {
results[name] = { status: "unhealthy", error: error.message };
overallStatus = "unhealthy";
}
}
return { status: overallStatus, checks: results };
}
}
// Usage
const healthCheck = new HealthCheckService();
healthCheck.addCheck("database", async () => {
await database.ping();
return { responseTime: "5ms" };
});
healthCheck.addCheck("external-api", async () => {
const response = await fetch("https://api.external.com/health");
return { status: response.status };
});
Metrics Collection
class MetricsCollector {
constructor() {
this.metrics = new Map();
}
increment(name, tags = {}) {
const key = this.createKey(name, tags);
const current = this.metrics.get(key) || 0;
this.metrics.set(key, current + 1);
}
gauge(name, value, tags = {}) {
const key = this.createKey(name, tags);
this.metrics.set(key, value);
}
histogram(name, value, tags = {}) {
const key = this.createKey(name, tags);
if (!this.metrics.has(key)) {
this.metrics.set(key, []);
}
this.metrics.get(key).push(value);
}
export() {
const exported = {};
for (const [key, value] of this.metrics) {
exported[key] = value;
}
return exported;
}
}
// Usage
const metrics = new MetricsCollector();
// In request handler
metrics.increment("http.requests", { method: "GET", endpoint: "/users" });
metrics.histogram("http.response_time", responseTime, { endpoint: "/users" });
Best Practices
Service Design
- Keep services small and focused on a single business capability
- Design for failure - implement circuit breakers and retries
- Use asynchronous communication where possible
- Implement proper error handling and logging
Data Management
- Each service should own its data
- Use eventual consistency where strong consistency isn't required
- Implement proper backup and disaster recovery strategies
- Consider data privacy and compliance requirements
Security
class SecurityMiddleware {
constructor() {
this.jwtSecret = process.env.JWT_SECRET;
}
authenticate() {
return (req, res, next) => {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
return res.status(401).json({ error: "No token provided" });
}
try {
const decoded = jwt.verify(token, this.jwtSecret);
req.user = decoded;
next();
} catch (error) {
return res.status(401).json({ error: "Invalid token" });
}
};
}
authorize(permissions) {
return (req, res, next) => {
const userPermissions = req.user.permissions || [];
const hasPermission = permissions.every((p) =>
userPermissions.includes(p),
);
if (!hasPermission) {
return res.status(403).json({ error: "Insufficient permissions" });
}
next();
};
}
}
Conclusion
Microservices architecture offers significant benefits for scalable systems but comes with complexity. Focus on proper service boundaries, robust communication patterns, and comprehensive monitoring. Start with a modular monolith and extract services as your understanding of the domain grows.
Key success factors:
- Clear service boundaries based on business domains
- Robust inter-service communication
- Comprehensive monitoring and observability
- Automated testing and deployment
- Strong DevOps culture and practices
Remember: microservices are a means to an end, not the end itself. Choose this architecture when the benefits outweigh the operational complexity.