Microservices are not a complexity goal. They are a scaling strategy. The reason to split a monolith isn't because microservices are modern — it's because your monolith has specific, proven pain points that a service boundary would solve.
That said, when the time comes, doing it well makes all the difference. Here's what I've learned building and running distributed systems.
When to Actually Split
Don't start with microservices. A well-structured monolith ships faster, is easier to debug, and handles early-stage uncertainty better.
Split when you have:
- Independent scaling needs — the image processing service needs 10x the resources of the user service
- Different deployment cadences — one team deploys 10 times a day, another deploys monthly
- Genuine team autonomy requirements — two teams need to own, deploy, and scale their slice independently
- Technology heterogeneity — one part of your system is genuinely better served by a different language or runtime
Don't split because a service feels conceptually separate. Split when you have a concrete operational reason.
Service Boundaries (Bounded Contexts)
The hardest problem in microservices isn't Kubernetes — it's drawing the right service boundaries. Domain-Driven Design's concept of a Bounded Context is the right mental model.
A Bounded Context is a portion of the domain where a particular domain model applies. The Order entity in your shipping service is not the same object as the Order in your billing service. They share an ID, but they have different attributes, different lifecycle, different invariants.
Shipping Service Billing Service
┌─────────────────┐ ┌─────────────────┐
│ Order │ │ Order │
│ - id │ │ - id │
│ - warehouseId │ │ - amount │
│ - trackingCode │ │ - taxRate │
│ - status │ │ - invoiceRef │
└─────────────────┘ └─────────────────┘
Each service owns its own data. No shared databases. This is non-negotiable.
API Gateway
All external traffic enters through an API Gateway. The gateway handles:
- Routing —
/users/*→ User Service,/orders/*→ Order Service - Authentication — validate JWT, pass identity downstream
- Rate limiting — per-client throttling
- Aggregation — combine responses from multiple services for a single client request
Client
│
▼
API Gateway (Kong / AWS API Gateway / custom Next.js middleware)
├── /api/users → User Service :3001
├── /api/orders → Order Service :3002
└── /api/products → Product Service :3003
For a Next.js frontend, the App Router's route.ts files can act as a lightweight gateway for BFF (Backend for Frontend) patterns, aggregating multiple service calls server-side before returning a single response to the client.
Inter-Service Communication
Services communicate two ways: synchronous (request/response) and asynchronous (events).
Synchronous — REST or gRPC
Use for: queries that need an immediate answer, user-facing reads.
// Order service calling User service synchronously
async function getOrderWithUser(orderId: string) {
const [order, user] = await Promise.all([
orderRepo.findById(orderId),
fetch(`http://user-service/users/${order.userId}`).then(r => r.json()),
]);
return { ...order, user };
}
gRPC is faster (binary protocol, HTTP/2 multiplexing) and better for high-throughput internal calls. REST is simpler for human debugging and external APIs.
Asynchronous — Event Bus
Use for: state changes that other services need to react to, without coupling them.
// Order service publishes an event
await eventBus.publish('order.placed', {
orderId: order.id,
userId: order.userId,
total: order.total,
placedAt: new Date().toISOString(),
});
// Inventory service subscribes independently
eventBus.subscribe('order.placed', async (event) => {
await inventoryService.reserveItems(event.orderId);
});
// Email service subscribes independently
eventBus.subscribe('order.placed', async (event) => {
await emailService.sendConfirmation(event.userId);
});
RabbitMQ and Apache Kafka are the standard choices. Kafka is better for high throughput and replay (consumers can re-read events). RabbitMQ is simpler for traditional pub/sub.
The Saga Pattern for Distributed Transactions
Databases give you ACID transactions. Microservices do not. When an operation spans multiple services, you need a Saga — a sequence of local transactions, each publishing an event or message, with compensating transactions to undo completed steps if a later step fails.
Choreography Saga — each service reacts to events and publishes its own events. No central coordinator.
PlaceOrder → (order.placed) → ReserveInventory → (inventory.reserved) → ChargeBilling
│
billing fails ▼
(billing.failed) → ReleaseInventory
│
(inventory.released) → CancelOrder
Orchestration Saga — a central saga coordinator tells each service what to do and handles failures.
class PlaceOrderSaga {
async execute(command: PlaceOrderCommand) {
const order = await this.orderService.create(command);
try {
await this.inventoryService.reserve(order.id, order.items);
await this.billingService.charge(order.id, order.total);
await this.orderService.confirm(order.id);
} catch (error) {
// Compensate in reverse order
await this.inventoryService.release(order.id);
await this.orderService.cancel(order.id);
throw error;
}
}
}
Orchestration is easier to follow and debug. Choreography scales better but is harder to reason about as the system grows.
Observability: Logs, Metrics, Traces
In a monolith, you console.log and check the server output. In microservices, a single user request might touch 8 services. You need three pillars:
Structured Logs — JSON with a correlation ID so you can trace a request across services.
logger.info({
correlationId: req.headers['x-correlation-id'],
service: 'order-service',
event: 'order_placed',
orderId: order.id,
durationMs: Date.now() - startTime,
});
Metrics — counters, gauges, histograms per service. Expose a /metrics endpoint (Prometheus format) and visualise in Grafana.
Distributed Tracing — OpenTelemetry + Jaeger. Each service propagates trace context (W3C Trace Context headers), and you can see the full call tree for any request across all services.
Key Principles
- Each service owns its data — no shared databases, ever.
- Design for failure — every network call can fail; use retries, circuit breakers, timeouts.
- Prefer async (events) for state propagation; use sync only for queries.
- Invest in observability before you need it, not after.
- Start with a monolith. Split when you have a concrete operational reason.
Microservices trade deployment simplicity for operational flexibility. Make sure the trade is worth it before you make it.