Microservices
Learn how microservices decompose monolithic applications into independently deployable services, when the operational overhead is worth it, and how to manage the distributed-systems failure modes that follow.
Introduction
Microservices are an organizational and deployment choice: split a system along stable business boundaries so services can be changed, scaled, and operated independently. Mental model: each service owns a capability and its data contract; network calls, asynchronous handoffs, and operational tooling replace in-process calls and shared transactions. The benefit is selective independence, not a guaranteed performance or reliability upgrade.
TL;DR
- Microservices split a large application into small, independently deployable services β each owning a runtime and bounded business capability, and often owning the data it writes.
- The payoff is independent deployment and scaling: the checkout service handles Black Friday load without touching the notification service. Teams ship features without coordinating across seven squads.
- The cost is operational complexity: the distributed form often requires tracing, service discovery, ingress policy, and explicit consistency across service boundaries β capabilities a monolith can keep within one process.
- A common mistake is moving to microservices before identifying a concrete coupling or scaling problem. The platform work can be substantial, so budget for deployment, observability, security, testing, and on-call ownership.
- Team size and traffic thresholds are not universal gates. A smaller team may still need a separate service for isolation or a distinct scaling profile, while a larger team may benefit from a modular monolith if boundaries and ownership are still evolving.
The Problem It Solves
It's 11 a.m. on Black Friday. Your e-commerce platform is handling 200,000 concurrent users. A bug in the payment module throws an uncaught exception, exhausts the JVM heap, and starts cascading OOM errors across the process. Within 90 seconds, the entire application is down β including the homepage, product search, and user profiles that have nothing to do with payment.
Your on-call engineer opens the deployment pipeline to hotfix it. Estimated build time: 22 minutes. Because the entire 300,000-line codebase compiles, tests, and ships as a single deployable unit. The payment team has a fix ready in 8 minutes. The other 11 teams sit and wait.
By the time the fix ships, you've been down for four hours. Cart abandonment is in the millions. The post-mortem has two findings: the payment bug, and the fact that a payment bug had no business taking down the homepage.
The architectural issue is deployment and failure coupling: when everything runs in one process, a process-level failure can affect everything in that process. A monolith can still be a sound choice when its boundaries, testing, and operational profile fit the workload.
The monolith's hidden failure mode
It's not that monoliths are slow or broken β many start out fast. The failure mode is coupling. One bad deployment window, one shared database schema migration, or one memory leak in a module can affect the whole application. That coupling becomes expensive when traffic, change rate, or ownership boundaries make it hard to manage.
One possible fix is a different architectural boundary: isolate the payment domain so it can fail, scale, and deploy with less coupling. Refactoring, resource limits, and safer deployment can also address parts of the problem; microservices are justified when the boundary buys enough independence to repay their cost.
What Is It?
A microservice is a small, independently deployable application that owns a single bounded domain of business logic and typically owns the data it writes. Microservices communicate over the network β typically via REST, gRPC, or a message broker β and can often be deployed, scaled, and updated with less coordination than a monolith.
Analogy: Think of a city with specialized districts β a financial district, a restaurant quarter, a hospital complex. Each district has its own staff, its own operating hours, and its own supply chain. The financial district going dark doesn't close the restaurants. The hospital scaling for a flu season doesn't affect the banks.
A monolith is a city run out of one single building: when one department floods, everyone evacuates. Microservices restructure that into separate districts that communicate via well-defined interfaces β "I need payment authorization" β while keeping internal workings and data behind service boundaries. Shared gateways, brokers, clusters, and identity systems can still create common failure domains.
Each service aims to be an independently operated boundary. Order Service does not need to know how Notification Service works when it publishes an event, but the broker, identity layer, and shared infrastructure can still be common dependencies. That selective decoupling is the core architectural property microservices optimize for.
Name service boundaries in terms of owned business capabilities, then explain how clients enter through a gateway or another stable interface. The boundary is only useful if ownership, data access, and failure handling are clear.
How It Works
Here's exactly what happens when a user places an order on a microservices platform:
- Client sends request β
POST /ordershits the API Gateway. The gateway validates the JWT, checks the rate limit, and routes the request to the Order Service. - Order Service handles the write β creates the order record in its own Postgres database. It does NOT call the Notification Service directly.
- Event published to broker β the Order Service publishes an
Order.placedevent to Kafka with the order ID and user ID, waiting for the required broker acknowledgement. The latency is workload- and configuration-dependent. Order Service returns201withstatus: pendingto the client. - Notification Service consumes the event β reads from the Kafka topic, fetches the user's email preference from its own store, and sends the confirmation email. This happens asynchronously, subject to consumer lag and downstream availability.
- Payment Service processes β also consumes the
Order.placedevent, but checks idempotency first: "Have I already processedorder_id: abc123?" If yes, it discards the duplicate. If no, it charges the card and publishesPayment.completed. If payment fails, a compensating event fires and the Order Service updates the order status topayment_failed, triggering a user notification.
The critical insight: the client gets a 201 with status: pending in step 3, not a confirmed order. Order Service owns the order record and updates its status as downstream events arrive. The decoupling means order creation and payment processing scale and fail independently β but the 201 is a promise to process, not a confirmation of completion.
// Order Service β handles POST /orders
export async function createOrder(
userId: string,
items: OrderItem[],
): Promise<Order> {
// 1. Write to Order Service's own database (isolated schema)
const order = await orderRepo.create({
userId,
items,
status: 'pending',
createdAt: new Date(),
});
// 2. Publish event β wait for broker ack (acks=all), NOT for consumers to finish
// ~5ms is illustrative; measure with your broker, replication, and network
await eventBus.publish('order.placed', {
orderId: order.id,
userId: order.userId,
totalAmount: order.totalAmount,
timestamp: order.createdAt.toISOString(),
});
// 3. Return immediately with status: pending β not a payment confirmation
return { ...order, status: 'pending' };
}
// Payment Service β idempotent Kafka consumer
kafkaConsumer.subscribe('order.placed', async (event: OrderPlacedEvent) => {
// Kafka is commonly configured for at-least-once delivery β duplicates can
// happen on consumer restart, partition rebalance, or broker hiccup.
// Check idempotency before any non-repeatable side effect.
const alreadyProcessed = await paymentRepo.existsByOrderId(event.orderId);
if (alreadyProcessed) return; // Discard duplicate β idempotency key = orderId
const charge = await paymentGateway.charge(event.userId, event.totalAmount);
await paymentRepo.save({ orderId: event.orderId, chargeId: charge.id });
await eventBus.publish('payment.completed', { orderId: event.orderId, chargeId: charge.id });
});
// Notification Service β idempotent consumer (email deduplication)
kafkaConsumer.subscribe('order.placed', async (event: OrderPlacedEvent) => {
const sent = await notifRepo.existsByOrderId(event.orderId);
if (sent) return; // Do not send duplicate confirmation email
const user = await userClient.getById(event.userId); // gRPC, cached in Redis
await emailService.sendOrderConfirmation(user.email, event.orderId);
await notifRepo.markSent(event.orderId);
});
Make asynchronous completion explicit
The 201 carries status: pending β it is a promise to process, not a payment receipt. Every Kafka consumer should be idempotent because delivery may repeat; use the order ID or another stable operation key for deduplication.
For each interaction, state whether the caller needs a synchronous result or whether an asynchronous handoff is sufficient, and name the consistency and recovery behavior that follows.
Key Vocabulary and Components
| Component | Role |
|---|---|
| API Gateway | Common entry point for client traffic. Handles auth, rate limiting, request routing, protocol translation (REST β gRPC), and SSL termination. Without one, clients need another discovery and policy mechanism for service addresses and auth. |
| Service Registry | A live directory of running service instances and their health. Consul, Kubernetes DNS, or AWS Cloud Map. Services register on startup, deregister on shutdown. The gateway uses it for routing decisions. Stale registrations are evicted by health-check TTL. |
| Message Broker | Decouples producers from consumers for async flows. Kafka fits high-throughput durable event streams; RabbitMQ and similar brokers often fit task queues. Delivery and durability depend on broker configuration and the consumer's acknowledgement strategy. |
| Circuit Breaker | Wraps outbound service calls and fails fast when a downstream service is consistently unavailable, limiting thread-pool exhaustion and cascading failure. Resilience4j, Hystrix, or a proxy can implement it; thresholds and fallbacks need to match the dependency. |
| Distributed Tracing | Propagates trace IDs (for example, traceparent via W3C TraceContext) across service boundaries so a user request can be reconstructed across services. Jaeger, Zipkin, or OpenTelemetry. Without it, cross-service latency and partial failures are much harder to diagnose. |
| Container Orchestrator | Kubernetes (or ECS) manages service deployment, scaling, health checks, and inter-service networking. Each microservice runs as a container with its own resource envelope. Rolling deployments and auto-scaling operate per service independently. |
| Service Mesh | Optional sidecar or proxy layer (Istio, Linkerd) that can handle mTLS, retries, circuit breaking, and traffic observability at the infrastructure level. It adds resource, configuration, and debugging overhead, so adopt it when those controls justify the cost. |
| Service Identity / mTLS | Each service has a cryptographic identity (SPIFFE/SPIRE or your mesh's built-in CA). Services prove identity via mutual TLS on every internal call β no hard-coded secrets, no IP allow-lists. Without this, a compromised service can freely call Billing, Auth, or Payment. The API Gateway handles TLS from external clients; your mesh handles mTLS internally. These are different trust boundaries. |
Communication Patterns
So when does a service call another service synchronously versus publish an event? Getting this wrong is a common source of microservices production incidents.
Synchronous (REST / gRPC)
Use synchronous calls when the caller needs the response to continue. The canonical cases:
- Auth check β verify the JWT before serving any request
- Inventory check β "Is this item in stock?" must be answered before showing Add to Cart
- Read path β fetching user profile data to render a page
The danger is latency chaining: if Service A calls B and B calls C, the total latency is A + B + C. If a 200ms downstream call is sequential, a 50ms endpoint becomes roughly 250ms before queueing and other work. Under load, queueing and retries can make the tail worse.
// Synchronous gRPC call from Order Service to Inventory Service
// If Inventory takes 200ms, order creation blocks for 200ms
const inventoryStatus = await inventoryClient.checkAvailability({
productId: item.productId,
quantity: item.quantity,
});
if (!inventoryStatus.available) {
throw new OutOfStockError(item.productId);
}
// Only continue if inventory confirmed β synchronous is correct here
Asynchronous (Events / Message Broker)
Use async when the caller does not need the response to proceed. The canonical cases:
- Notifications β confirmation email after an order is placed
- Analytics β log the page view without blocking the page response
- Side effects β update search index, invalidate downstream caches, trigger fulfillment
The payoff is fault isolation: if the Notification Service is down, the Order Service need not block on it. A durably acknowledged event can remain in Kafka until Notification recovers, subject to retention, consumer, and downstream limits.
The client response time in this diagram is illustrative. Payment and Notification run on their own timelines with their own retry logic and failure modes, decoupled from the order response path.
The rule: if the failure of the downstream operation would change your response to the client, use sync. If not, it is usually a candidate for async. Then add timeouts, bounded retries, idempotency, and a fallback for either path.
Failure Modes and Operations
- Latency chaining and fan-out. Synchronous hops add network, serialization, queueing, and downstream latency. Bound the call graph, batch or parallelize reads, and use a local projection when a remote read is on a hot path.
- Cascading failure. Slow dependencies consume threads and connections. Use deadlines, bounded retries with jitter, circuit breakers, bulkheads, and graceful degradation.
- Distributed data inconsistency. There is usually no single ACID boundary across service databases. Use local transactions, durable events, idempotency, and Sagas or reconciliation for cross-service workflows.
- Contract breakage. Version APIs and events, use consumer-driven contract tests, and deprecate fields before removal.
- Unobservable partial work. Propagate trace and correlation IDs, record workflow state, and monitor per-service latency, errors, saturation, dependency health, queue lag, and retry rates.
- Unsafe releases. Roll out independently but stage changes, support rollback, and keep database migrations backward-compatible with both old and new service versions.
Trade-offs
| Benefit | Cost |
|---|---|
| Independent deployment β ship one service without touching others | Distributed systems complexity β network partitions, partial failures, retry storms |
| Independent scaling β scale only the bottleneck service during a sale | Data consistency challenges β cross-service invariants need explicit coordination and are often eventually consistent |
| Fault isolation β one service crash doesn't cascade through the system | Operational overhead β each service needs its own CI/CD pipeline, alerting, log aggregation |
| Technology freedom β Postgres for Orders, MongoDB for Products, Redis for Sessions | Latency cost per hop β every inter-service call adds network, serialization, and queueing overhead |
| Team autonomy β the Orders team owns their full stack end-to-end | Distributed tracing required β debugging request failures across many services without trace IDs is difficult |
| Smaller, understandable codebases β engineers master their bounded context | Testing complexity β integration tests require live dependent services or complex mocks |
The fundamental tension here is developer velocity versus operational complexity.
A well-run microservices organization can ship features faster when teams are autonomous and deployments are independent. Getting to that operating model requires platform investment β deployment, tracing, contract testing, security, and on-call ownership. Before those capabilities exist, developer velocity can be worse than in a monolith.
When to Use It / When to Avoid It
The decision should follow the coupling, scaling, ownership, compliance, and operational constraints rather than a universal team or traffic threshold.
Use microservices when:
- Multiple teams own separate domains β 3+ teams stepping on each other's deployment windows. The coordination tax exceeds the migration cost.
- Services have materially different scaling needs β for example, video transcoding may need GPU nodes while auth is CPU-light. Independent scaling can be a cleaner option when the measurements justify it.
- Failure isolation is a hard requirement β a payment outage must not affect product browsing. Financial or healthcare systems often mandate this.
- Compliance demands separation β PCI-DSS for payment, HIPAA for health data. Each domain needs isolated access control, encryption, and audit logs.
- You've already felt the monolith's pain β you can point to specific incidents: blocked deployments, 30-minute CI runs, cascading failures. If those incidents are real, microservices are worth the migration cost.
Alternatives
- A modular monolith keeps one deployable and one transaction boundary while enforcing domain ownership inside the codebase.
- A separate worker or batch service can isolate a CPU-heavy or asynchronous workload without splitting the whole application.
- Read replicas, caches, or a local projection can address read scale and latency without creating a new service boundary.
- A service-oriented monolith can expose stable internal APIs first, leaving extraction as a later option when the boundary is proven.
Avoid microservices when:
- A modular monolith already meets the requirements β separate deployables would add cost without a concrete isolation or scaling benefit.
- You lack operational ownership β each service needs deployment, observability, security, incident response, and a clear data owner.
- Your team is new to distributed systems β microservices surface network partitions, partial failures, and retry interactions that monoliths hide. Learn and instrument those failure modes before expanding the topology.
- Your domain boundaries aren't clear β if you can't articulate exactly where one service's responsibility ends and another's begins, your service cut will be wrong. You'll end up with a distributed monolith: all the complexity, none of the benefits.
If you're unsure whether you need microservices, start with a modular monolith or one well-justified extraction. Identify the actual coupling pain point, prove the boundary, and extract only the service whose independence pays back its operating cost.
Real-World Examples
Amazon's two-pizza-team story is a useful organizational example: small teams with clear ownership make service boundaries and independent deployment more workable. Splitting deployables without assigning real ownership creates more coordination, not less.
Netflix's decomposition is a useful operational example: once services are separated, capabilities such as circuit breaking, service discovery, gateways, tracing, and failure testing become important supporting systems. The lesson is not a particular service count; it is that topology and platform discipline must evolve together.
Uber's service-graph experience illustrates the observability risk of a large topology: as call paths multiply, ownership, dependency discovery, latency attribution, and policy enforcement need dedicated tooling. Microservices can reduce deployment coupling while increasing the need for governance and visibility.
Explain It in 30 Seconds and 5 Minutes
30-second explanation
Microservices split a system along business boundaries so each service can own a capability, data contract, deployment, and scaling policy. The benefit is selective independence and fault isolation. The cost is network failure, distributed consistency, more deployment and observability work, and the need to operate every service.
5-minute explanation
Start with the boundary: name the business capability, its owner, its data, and the interface it exposes. Use synchronous calls when the caller needs the result; use events or queues for independent side effects. For cross-service workflows, use local transactions plus an outbox and a Saga when compensation is required. Make async handlers idempotent and version APIs and events so consumers can migrate safely.
Then describe the operating envelope: timeouts, bounded retries and circuit breakers for sync calls; lag, DLQs, and backpressure for async calls; trace context across hops; health checks and staged deployments; mTLS or another service-identity model where the threat model requires it. If a modular monolith can meet the requirements with less coordination, it may be the better architecture.
Common Mistakes and Misconceptions
- Splitting by nouns or code size alone. A service boundary should follow ownership, invariants, change patterns, and access patterns, not an arbitrary number of classes or tables.
- Sharing a database across services. Direct reads and writes recreate deployment coupling; use an API, an event projection, or a deliberate data export.
- Making every call synchronous. Side effects such as notifications and analytics often belong off the request path, with explicit delivery and retry semantics.
- Making every call asynchronous. Authentication, inventory reservation, and other decisions needed for the response still require a synchronous or otherwise coordinated path.
- Retrying without budgets or idempotency. Retries can amplify an outage and duplicate writes; set deadlines, cap attempts, add jitter, and make the operation safe to repeat.
- Assuming a service boundary guarantees isolation. Shared gateways, brokers, databases, clusters, or identity systems can still be common failure domains.
- Extracting too early. A modular monolith can prove boundaries and keep transactions simple until an actual scaling, ownership, or isolation constraint justifies extraction.
Test Your Understanding
Quick Recap
- Microservices split a system into independently deployable services, each owning a bounded domain and usually its own data store β failure and deployment boundaries are explicit, not automatically complete isolation.
- An API Gateway is a common entry point: it can handle auth, rate limiting, and routing before requests reach services, while other ingress and discovery patterns are also possible.
- Synchronous calls (REST/gRPC) chain latency β use them only when the downstream response is required to continue. Every other inter-service interaction is a candidate for async events.
- Asynchronous events via Kafka or RabbitMQ decouple producers from consumers: Order Service need not wait for Notification Service when it publishes
order.placed, subject to the broker handoff policy. - Data consistency across service boundaries requires the Saga pattern β a sequenced set of local transactions with compensating events on failure. ACID transactions do not exist across microservice boundaries.
- Distributed tracing with propagated trace context becomes increasingly important as the topology grows; without it, diagnosing cross-service latency spikes or partial failures is much harder.
- If you're debating whether you need microservices, find the specific coupling, scaling, or ownership pain first, then extract the smallest boundary that repays its operating cost.
Related Concepts
- API Gateway β A common ingress pattern for microservices. Covers auth, routing, rate limiting, and the failure modes of gateway centralization.
- Message Queues β The async backbone for decoupled microservices. Covers Kafka vs. RabbitMQ, consumer group semantics, and delivery/idempotency semantics.
- Service Mesh β The infrastructure layer for large-scale microservices: mTLS, retries, circuit breaking, and observability via Istio or Linkerd without code changes.
- Circuit Breaker β The pattern that prevents cascading failures across synchronously-coupled services. Essential reading after this article.
- Monolith vs. Microservices β The full trade-off breakdown: when the architectural overhead pays off and when it actively hurts you.
Related Articles
Learn what an API Gateway is, how it works, its trade-offs, and how to explain it in a system design interview.
Learn how message queues decouple services, which delivery guarantee fits your workload, and how to build a queue layer that survives consumer failures.
Learn how a service mesh eliminates duplicated networking code across microservices, enforces zero-trust mTLS by default, and gives you end-to-end observability without touching your application code.