Microservices Architecture: When and How
A pragmatic guide to microservices architecture, covering when it makes sense, how to design service boundaries, and how to avoid common pitfalls.
The Microservices Question
Every growing engineering team eventually asks: should we move to microservices? The honest answer is more nuanced than most articles suggest. Microservices solve real problems, but they introduce real complexity. The right architecture depends on your team size, your domain, and your operational maturity.
This guide helps you decide whether microservices are appropriate for your situation, and if they are, how to implement them well.
When Microservices Make Sense
Good Reasons to Adopt
Independent deployment: Different teams need to deploy their components on different schedules without coordinating with every other team.
Technology diversity: Some parts of your system benefit from different technology choices. A machine learning pipeline in Python, a real-time API in Go, and a web application in Node.js can each use the best tool for the job.
Scaling independence: Your order processing service needs 20 instances during a flash sale, but your product catalog service needs only 2. Microservices let you scale each component independently.
Team autonomy: Teams of 5 to 8 people own entire services end-to-end: development, testing, deployment, and monitoring. This ownership model reduces coordination overhead.
Fault isolation: When the recommendation engine crashes, it should not take down the checkout flow.
When to Stay Monolithic
Small team: If your entire engineering team is under 10 people, a well-structured monolith is almost always the better choice. The coordination overhead of microservices outweighs the benefits.
Early-stage product: When you are still discovering what your product should do, microservices lock you into service boundaries prematurely. Build a monolith first, then extract services as stable boundaries emerge.
Limited operational capability: Microservices require sophisticated monitoring, logging, deployment pipelines, and incident response. If you do not have these in place, microservices will create chaos.
Designing Service Boundaries
Domain-Driven Design
The most reliable method for identifying service boundaries comes from Domain-Driven Design (DDD). Each service aligns with a bounded context: a part of the business domain with its own internal consistency rules.
For an e-commerce platform:
Bounded Contexts:
├── Catalog Service
│ Owns: products, categories, pricing
│ Data: product descriptions, images, prices
│
├── Order Service
│ Owns: orders, order lines, order status
│ Data: order details, fulfillment state
│
├── Inventory Service
│ Owns: stock levels, warehouse locations
│ Data: quantities, reservations, movements
│
├── Customer Service
│ Owns: customer profiles, addresses
│ Data: contact info, preferences
│
├── Payment Service
│ Owns: payment processing, refunds
│ Data: payment methods, transaction records
│
└── Notification Service
Owns: email, SMS, push notifications
Data: templates, delivery status
The Boundary Test
A good service boundary passes these checks:
- The service can be developed by one team (5 to 8 people)
- Changes within the service rarely require changes in other services
- The service has its own data store that other services cannot access directly
- The service’s API is stable and does not change frequently
- The service can be deployed independently without coordination
If a change frequently requires updating multiple services simultaneously, those services are probably too tightly coupled and should either be merged or their boundaries redrawn.
Communication Patterns
Synchronous: REST and gRPC
For request-response interactions where the caller needs an immediate answer:
// REST: Simple, human-readable, widely supported
GET /api/products/12345
Response: { "id": "12345", "name": "Widget", "price": 25000 }
// gRPC: Binary protocol, faster, strongly typed
service ProductService {
rpc GetProduct(ProductRequest) returns (Product);
rpc ListProducts(ListRequest) returns (stream Product);
}
Use REST for public-facing APIs and gRPC for internal service-to-service communication where performance matters.
Asynchronous: Event-Driven
For interactions where the caller does not need an immediate response:
// Order Service publishes an event
{
"event": "order.created",
"timestamp": "2026-01-15T10:30:00Z",
"data": {
"orderId": "ORD-2026-001",
"customerId": "CUST-456",
"items": [
{ "productId": "PROD-789", "quantity": 2 }
],
"totalAmount": 500000
}
}
// Inventory Service subscribes and reserves stock
// Payment Service subscribes and initiates payment
// Notification Service subscribes and sends confirmation email
Event-driven architecture decouples services temporally. The Order Service does not wait for inventory, payment, or notification to complete. Each service processes the event at its own pace.
Choosing the Right Pattern
| Scenario | Pattern | Reason |
|---|---|---|
| User requests product details | Synchronous REST | Immediate response needed |
| Service needs to validate inventory | Synchronous gRPC | Fast internal call |
| Order placed, need to notify systems | Asynchronous event | Multiple consumers, no blocking |
| Data synchronization between services | Asynchronous event | Eventually consistent is acceptable |
| Complex workflow spanning services | Saga pattern | Distributed transaction coordination |
Data Management
Database Per Service
Each microservice owns its data and exposes it only through its API. No service directly queries another service’s database:
Order Service → orders_db (PostgreSQL)
Catalog Service → catalog_db (PostgreSQL)
Search Service → search_index (Elasticsearch)
Session Service → sessions (Redis)
Analytics Service → analytics_db (ClickHouse)
This isolation means services can choose the database technology that best fits their access patterns. It also means no service can break another by modifying shared data.
Handling Data Consistency
In a monolith, a single database transaction ensures consistency. In microservices, you need different strategies:
Saga pattern: A sequence of local transactions coordinated through events. If step 3 fails, compensating transactions undo steps 1 and 2.
Order Saga:
1. Order Service: Create order (status: pending)
2. Payment Service: Process payment
→ Success: Continue
→ Failure: Order Service cancels order (compensating)
3. Inventory Service: Reserve stock
→ Success: Order Service confirms order
→ Failure: Payment Service refunds (compensating),
Order Service cancels order (compensating)
Event sourcing: Store the sequence of events rather than the current state. Any service can rebuild its view of the data by replaying events.
Observability
Distributed Tracing
When a request flows through five services, you need to trace it end-to-end:
Request: GET /api/orders/ORD-2026-001
Trace ID: abc-123-def-456
├── API Gateway (12ms)
├── Order Service (45ms)
│ ├── Database query (8ms)
│ └── Customer Service call (25ms)
│ └── Database query (5ms)
├── Inventory Service call (18ms)
│ └── Database query (6ms)
└── Total: 75ms
Distributed tracing tools (Jaeger, Zipkin, AWS X-Ray) instrument your services to propagate trace context across service boundaries.
Centralized Logging
Logs from all services must flow to a centralized system. Include the trace ID in every log message so you can correlate logs across services for a single request:
{
"timestamp": "2026-01-15T10:30:00.123Z",
"service": "order-service",
"traceId": "abc-123-def-456",
"level": "info",
"message": "Order created",
"orderId": "ORD-2026-001",
"customerId": "CUST-456"
}
Health Checks and Alerting
Every service needs:
- A health check endpoint (
/health) that reports service status - Metrics endpoints or push-based metric collection (Prometheus)
- Alerts on error rates, latency, and resource utilization
- Runbooks for common failure scenarios
Common Pitfalls
The Distributed Monolith
If every change requires coordinated deployments across multiple services, you have a distributed monolith. This is worse than a regular monolith because you have all the complexity of distributed systems with none of the benefits.
Signs of a distributed monolith:
- Services share a database
- Services must be deployed in a specific order
- Changes frequently span multiple services
- Services call each other synchronously in long chains
Too Many Services Too Soon
Starting with 30 microservices when you have 15 engineers creates operational chaos. The overhead of managing that many services, databases, deployment pipelines, and monitoring dashboards overwhelms the team.
Start with a modular monolith. Extract services one at a time as clear boundaries emerge and operational tooling matures.
Ignoring Network Reality
Networks fail. Latency spikes. Messages get duplicated or lost. Microservices that do not account for network unreliability will exhibit bizarre failures in production.
Every inter-service call needs:
- Timeouts (never wait forever)
- Retries with exponential backoff
- Circuit breakers to prevent cascade failures
- Idempotency for safe retries
A Pragmatic Migration Path
If you are moving from a monolith to microservices:
- Modularize the monolith first: Establish clear internal boundaries, separate data access layers, define internal APIs between modules
- Build operational infrastructure: Monitoring, logging, CI/CD, container orchestration
- Extract the first service: Choose something with a clear boundary and low coupling to the rest. Notifications or search are common starting points.
- Learn and iterate: The first extraction will reveal organizational and technical challenges. Address them before extracting the next service.
- Continue extracting as justified: Not every module needs to become a service. Stop when the remaining monolith is manageable and the extracted services are delivering value.
The goal is not zero monolith. The goal is the right architecture for your team, your product, and your stage of growth.