Microservices Are Not a Goal, They're a Trade-Off
The microservices hype peaked years ago. The honest reality: microservices solve specific organizational and scaling problems while introducing significant complexity. Running 50 services instead of one monolith means 50 deployment pipelines, 50 monitoring dashboards, and a distributed systems problem on every request.
Before adopting microservices, ask: is your team large enough that coordination on a monolith is genuinely painful? Are specific parts of your system scaling at dramatically different rates? If the answer is no, a well-structured monolith will serve you better.
This guide assumes you've decided microservices are appropriate and explains how to implement them well.
Service Decomposition: Where to Draw Boundaries
The single hardest problem in microservices. Get this wrong and you'll have a "distributed monolith" — all the complexity of microservices with none of the benefits.
Domain-Driven Design (DDD) Bounded Contexts provide the most reliable decomposition strategy:
E-commerce System Bounded Contexts:
Order Context User Context Inventory Context
├── Order ├── User ├── Product
├── OrderLine ├── Address ├── Stock
├── OrderStatus ├── Payment Method ├── Warehouse
└── Shipping └── Profile └── Reservation
Payment Context Notification Context
├── Transaction ├── Email
├── Refund ├── SMS
└── Invoice └── Push Notification
Each bounded context becomes a service. The key: services should be able to change independently. If changing the Order service always requires changing the User service, the boundary is wrong.
API Gateway Pattern
External clients shouldn't call individual services directly:
// Simple API Gateway with Express
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
// Authentication middleware (runs before all proxies)
app.use(async (req, res, next) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ error: 'No token' });
try {
req.user = await verifyJWT(token);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
});
// Route to appropriate microservice
app.use('/api/users', createProxyMiddleware({
target: process.env.USER_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/users': '' },
}));
app.use('/api/orders', createProxyMiddleware({
target: process.env.ORDER_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/orders': '' },
}));
// Aggregation: combine multiple services
app.get('/api/dashboard', async (req, res) => {
const [user, orders, notifications] = await Promise.all([
fetch(`${USER_SERVICE}/users/${req.user.id}`).then(r => r.json()),
fetch(`${ORDER_SERVICE}/users/${req.user.id}/recent`).then(r => r.json()),
fetch(`${NOTIF_SERVICE}/users/${req.user.id}/unread`).then(r => r.json()),
]);
res.json({ user, orders, notifications });
});
Service-to-Service Communication
Synchronous: REST / gRPC
// Typed service client (gRPC style)
import { createChannel, createClient } from 'nice-grpc';
import { OrderServiceDefinition } from './proto/order';
const channel = createChannel('order-service:50051');
const orderClient = createClient(OrderServiceDefinition, channel);
// Type-safe, efficient binary protocol
const order = await orderClient.getOrder({ orderId: '123' });
const { stream } = orderClient.watchOrderStatus({ orderId: '123' });
for await (const status of stream) {
console.log('Order status:', status);
}
Asynchronous: Message Queues
// RabbitMQ with amqplib
import amqp from 'amqplib';
const connection = await amqp.connect(process.env.RABBITMQ_URL);
const channel = await connection.createChannel();
// Publisher: Order Service
async function publishOrderCreated(order) {
await channel.assertExchange('orders', 'topic', { durable: true });
channel.publish(
'orders',
'order.created',
Buffer.from(JSON.stringify({
orderId: order.id,
userId: order.userId,
total: order.total,
timestamp: new Date().toISOString(),
})),
{ persistent: true }
);
}
// Subscriber: Inventory Service
await channel.assertExchange('orders', 'topic', { durable: true });
const { queue } = await channel.assertQueue('inventory.order-created');
await channel.bindQueue(queue, 'orders', 'order.created');
channel.consume(queue, async (msg) => {
const event = JSON.parse(msg.content.toString());
try {
await reserveInventory(event.orderId);
channel.ack(msg); // Acknowledge success
} catch (error) {
// Negative ack — requeue for retry
channel.nack(msg, false, true);
}
});
Circuit Breaker Pattern
Prevent cascade failures when a downstream service is struggling:
import CircuitBreaker from 'opossum';
// Wrap any async function in a circuit breaker
const paymentCircuitBreaker = new CircuitBreaker(
async (paymentData) => {
return fetch(`${PAYMENT_SERVICE}/charge`, {
method: 'POST',
body: JSON.stringify(paymentData),
signal: AbortSignal.timeout(3000), // 3 second timeout
}).then(r => r.json());
},
{
timeout: 3000, // How long before considering a request failed
errorThresholdPercentage: 50, // Open circuit if 50%+ fail
resetTimeout: 30000, // Try again after 30 seconds
volumeThreshold: 5, // Minimum requests before opening
}
);
paymentCircuitBreaker.on('open', () => {
console.error('Payment service circuit OPEN — using fallback');
});
paymentCircuitBreaker.on('halfOpen', () => {
console.log('Payment service circuit testing...');
});
paymentCircuitBreaker.on('close', () => {
console.log('Payment service circuit CLOSED — normal operation');
});
// Usage
async function processPayment(orderId, amount) {
try {
return await paymentCircuitBreaker.fire({ orderId, amount });
} catch (error) {
if (paymentCircuitBreaker.opened) {
// Fallback: queue for later processing
await queuePaymentForRetry({ orderId, amount });
return { status: 'queued', message: 'Payment queued for processing' };
}
throw error;
}
}
Saga Pattern: Distributed Transactions
When an operation spans multiple services, you need sagas instead of ACID transactions:
// Choreography-based Saga: services react to events
// (No central coordinator — services emit and react)
// Order Service creates order → publishes OrderCreated
async function createOrder(orderData) {
const order = await db.orders.create({ ...orderData, status: 'pending' });
await eventBus.publish('OrderCreated', { orderId: order.id, ...orderData });
return order;
}
// Payment Service hears OrderCreated → charges payment
eventBus.subscribe('OrderCreated', async (event) => {
try {
await chargePayment(event.userId, event.total);
await eventBus.publish('PaymentProcessed', { orderId: event.orderId });
} catch (error) {
// Compensating transaction
await eventBus.publish('PaymentFailed', { orderId: event.orderId });
}
});
// Order Service hears PaymentFailed → cancels order
eventBus.subscribe('PaymentFailed', async (event) => {
await db.orders.update(event.orderId, { status: 'cancelled' });
await eventBus.publish('OrderCancelled', { orderId: event.orderId });
});
// Inventory Service hears PaymentProcessed → reserves items
// Inventory Service hears OrderCancelled → releases items
// Orchestration-based Saga: central coordinator
class CreateOrderSaga {
async execute(orderData) {
const steps = [];
try {
// Step 1: Create order
const order = await this.orderService.create(orderData);
steps.push(() => this.orderService.cancel(order.id));
// Step 2: Reserve inventory
await this.inventoryService.reserve(order.items);
steps.push(() => this.inventoryService.release(order.items));
// Step 3: Process payment
await this.paymentService.charge(order.userId, order.total);
steps.push(() => this.paymentService.refund(order.userId, order.total));
// Step 4: Confirm
await this.orderService.confirm(order.id);
return order;
} catch (error) {
// Rollback: execute compensating transactions in reverse
for (const compensate of steps.reverse()) {
await compensate().catch(console.error);
}
throw error;
}
}
}
Service Discovery
// Using Consul for service discovery
import Consul from 'consul';
const consul = new Consul({ host: 'consul', port: 8500 });
// Register on startup
await consul.agent.service.register({
id: `order-service-${process.env.POD_IP}`,
name: 'order-service',
address: process.env.POD_IP,
port: 3000,
check: {
http: `http://${process.env.POD_IP}:3000/health`,
interval: '10s',
deregisterCriticalServiceAfter: '30s',
},
tags: ['api', 'v2'],
});
// Discover and call another service
async function callUserService(userId) {
// Get healthy instances of user-service
const services = await consul.health.service({
service: 'user-service',
passing: true, // Only healthy instances
});
if (services.length === 0) throw new Error('No user-service instances available');
// Simple round-robin (in practice, use a load-balancer or service mesh)
const instance = services[Math.floor(Math.random() * services.length)];
const url = `http://${instance.Service.Address}:${instance.Service.Port}`;
return fetch(`${url}/users/${userId}`).then(r => r.json());
}
Key Trade-offs Summary
| Concern | Monolith | Microservices |
|---|---|---|
| Complexity | Low | High (distributed systems) |
| Deployment | Simple | Complex (many pipelines) |
| Scaling | Whole app | Per-service |
| Team size | 1-15 | 50+ (Conway's Law) |
| Latency | In-process | Network hops |
| Transactions | ACID | Saga pattern needed |
| Debugging | Simple | Distributed tracing required |
| Data isolation | Shared DB | Per-service DB |
→ Convert service configs between YAML and JSON with the JSON to YAML Converter.