Microservices Architecture
Prerequisite: Message Queues & Event-Driven Architecture
Microservices architecture decomposes an application into small, independently deployable services, each responsible for a specific business capability. The appeal is real: independent deployment, independent scaling, and team autonomy. But microservices trade one class of problems for another, and the trade is often unfavorable at small scale. Understanding both sides of the trade-off determines when the architecture actually helps.
Monolith vs Microservices
A monolith deploys as a single unit. All components share a process, a codebase, and a database. This simplicity is a feature. A developer can run the entire system on a laptop. Transactions cross domain boundaries trivially. Refactoring is an IDE operation, not a multi-team contract negotiation. Debugging follows a single call stack.
The monolith’s problems emerge at scale: a deployment of any component requires deploying all components; a memory leak in one feature affects the entire process; scaling the payment service means scaling the user service too even if it doesn’t need it; a large codebase becomes difficult for any team to fully own.
Microservices solve these problems by enforcing boundaries at the process level. Each service has its own repository, its own deployment pipeline, its own runtime. Teams can deploy independently without coordination. Services can be scaled individually. A crash in the notification service does not take down the checkout service.
The cost: each service boundary is now a network call. Network calls have latency, can fail, require serialization, and are dramatically harder to test than in-process function calls. What was a one-line function call is now an HTTP request with timeouts, retries, circuit breakers, and distributed tracing.
Service Boundaries and Domain-Driven Design
The hardest part of microservices is choosing where to draw the boundaries. The wrong decomposition creates a distributed monolith: services that are deployed separately but coupled tightly enough that they must be deployed in coordinated waves, share databases, or fail together.
Domain-driven design (DDD) provides vocabulary for finding natural boundaries. A bounded context is a part of the domain where a particular domain model applies consistently. The Order bounded context has a concept of “order” with specific meaning; the Inventory bounded context has a different model even for the same physical goods. Services map to bounded contexts: one service owns one bounded context.
Red flags for a bad boundary: two services that always deploy together, services that share a database table, services that call each other synchronously in a chain longer than two hops.
Communication Patterns
Synchronous communication (HTTP REST, gRPC) is straightforward: service A calls service B, waits for a response. Use when A needs B’s result to proceed. The coupling is temporal - if B is slow, A is slow. If B is unavailable, A must handle the error. gRPC is preferred for internal service-to-service calls: binary protocol, strongly typed contracts via protobuf, bidirectional streaming.
Asynchronous communication (events via Kafka or RabbitMQ) decouples services temporally. Service A publishes an event and continues; service B consumes it when ready. Use when A does not need B’s result immediately, or when multiple services should react to the same event. This introduces eventual consistency between services.
The general guideline: prefer async for workflows that span multiple services, prefer sync for queries that need fresh data.
Service Discovery
Microservices need to find each other at runtime. In a containerized environment, instances come and go; hardcoding IP addresses is not viable.
Consul is a service registry: services register on startup with their address and health check endpoint. Clients query Consul for the current list of healthy instances. Consul also provides distributed key-value storage and can integrate with DNS.
Kubernetes DNS is simpler: each Kubernetes Service gets a stable DNS name (payment-service.default.svc.cluster.local). Kubernetes' kube-proxy handles load balancing across pods. No separate registry needed inside Kubernetes clusters.
API Gateway
An API gateway is the single entry point for external clients. It handles:
- Routing: Map external paths to internal service addresses.
- Authentication: Validate JWTs or API keys before requests reach services, so individual services don’t need to implement auth.
- Rate limiting: Protect backends from overload.
- Request transformation: Adapt client-facing APIs without changing internal service contracts.
Kong, AWS API Gateway, and nginx with custom configuration are common choices. The gateway can become a bottleneck and a single point of failure - it needs its own redundancy.
Data Ownership
The principle is strict: each service owns its data; no service reads another’s database directly. All data access goes through the owning service’s API. This is the rule that distinguishes microservices from a distributed monolith.
Shared databases create hidden coupling: a schema change in the shared table breaks all consumers; there is no way to enforce what queries other services run or optimize the schema for a single service’s access patterns.
The consequence: operations that were a single SQL join become a service call followed by joining data in application code, or become events that maintain a denormalized view in the consuming service’s own database.
Distributed Tracing
In a monolith, a stack trace shows the complete execution path. In microservices, a request might touch 8 services before returning a response. Debugging without tooling is nearly impossible.
Distributed tracing assigns a trace ID at the entry point (the API gateway or the first service). Every service propagates the trace ID in its outbound calls (via HTTP headers: X-Trace-Id, or the W3C Trace Context standard). Each service emits spans - records of work done with start time, duration, and metadata - associated with the trace ID.
Tools like Jaeger and Zipkin collect spans and reconstruct the full call graph. A flame graph view shows exactly which service and which operation consumed the most time.
When Not to Use Microservices
Microservices require significant operational maturity before they reduce rather than increase complexity:
- Each service needs its own CI/CD pipeline, container registry, deployment manifests, monitoring dashboards, and alerting rules.
- Debugging requires distributed tracing and centralized log aggregation.
- Integration testing across services is harder than testing a monolith.
A small team (fewer than ~20 engineers) that is still discovering its domain model should strongly prefer a well-structured monolith. Conway’s Law is real: your system architecture tends to mirror your team communication structure. Microservices work well when teams are organized around services.
Strangler Fig Pattern
Migrating a monolith to microservices incrementally - rather than a full rewrite - uses the strangler fig pattern: route a subset of functionality (one domain) from the monolith to a new service. The monolith handles everything else. Gradually extract more domains. The monolith shrinks as the new services grow around it.
The routing layer (a reverse proxy or API gateway) makes this transparent to clients. New features are built in the new services; legacy behavior stays in the monolith until explicitly migrated.
Examples
Netflix decomposition: Netflix began as a monolith. Their migration to microservices (now 700+ services) was driven by the need to deploy hundreds of times per day across teams independently. Each team owns its service stack completely.
Trace ID propagation across services:
# Incoming request handler
trace_id = request.headers.get("X-Trace-Id", generate_trace_id())
span = tracer.start_span("checkout", trace_id=trace_id)
# Outbound call to payment service
response = http.post(
"http://payment-service/charge",
headers={"X-Trace-Id": trace_id},
json=payload,
)
span.finish()
Backwards compatibility via contract testing: When service A depends on service B’s response schema, teams use tools like Pact to define consumer-driven contracts. The contract specifies exactly which fields A uses. Service B’s CI pipeline verifies it does not break existing contracts before deployment - preventing breaking changes from reaching production.
Read Next: Load Balancing & Proxies