Microservices Architecture: Principles, Patterns & Pitfalls

Microservices are one of the most talked-about — and most misunderstood — architectural patterns in modern software development. Netflix, Amazon, Uber, and Spotify all run on microservices. So it must be the right architecture for everyone, right?
Wrong. Microservices solve specific problems at specific scales. Applied prematurely, they create more complexity than the problems they solve. Applied correctly, they unlock independent deployability, team autonomy, and fine-grained scaling.
In this post, we'll cover:
✅ What microservices actually are (and what they're not)
✅ Core principles: single responsibility, autonomous deployment, decentralized data
✅ Service boundaries and bounded contexts
✅ Inter-service communication: sync vs async
✅ API gateway pattern
✅ Data management: database per service, sagas, eventual consistency
✅ Testing microservices: contract testing, integration testing
✅ Organizational alignment: Conway's Law, team topologies
✅ When NOT to use microservices
✅ Migration strategies from monolith to microservices
What Are Microservices?
A microservices architecture is a system composed of small, independently deployable services, where each service runs its own process, owns its own data, and communicates with other services through well-defined APIs.
Compare this to a monolith where all functionality lives in a single deployable unit. In microservices, each service is:
- Independently deployable — deploy the payment service without touching the user service
- Independently scalable — scale the order service during peak hours without scaling everything
- Independently developable — different teams work on different services
- Technology agnostic — the user service can use Java, the notification service can use Go
What Microservices Are NOT
Let's clear up common misconceptions:
- ❌ "Just small services" — Size is irrelevant. It's about boundaries and ownership, not lines of code.
- ❌ "SOA rebranded" — SOA (Service-Oriented Architecture) relies on centralized orchestration (ESBs). Microservices favor decentralized choreography and smart endpoints with dumb pipes.
- ❌ "Always better than monoliths" — For small teams and early-stage products, a monolith is almost always the better choice. (See Monolithic Architecture Guide)
- ❌ "One service per REST endpoint" — Services represent business capabilities, not individual API endpoints.
Core Principles
1. Single Responsibility (Business Capability)
Each service should represent a single business capability — something the business does, not just a technical abstraction.
Good service boundaries (business capabilities):
- Order Service — manages order lifecycle (create, update, cancel, fulfill)
- Payment Service — processes payments, refunds, invoicing
- Inventory Service — tracks stock levels, reservations, restocking
Bad service boundaries (technical splits):
- ❌ Database Service — one service just for DB access
- ❌ Validation Service — one service just for input validation
- ❌ Logging Service — one service just for writing logs
The right question isn't "how small should a service be?" — it's "what business capability does this service own?"
2. Autonomous Deployment
Each service can be built, tested, and deployed independently without coordinating with other services.
This means:
- Each service has its own CI/CD pipeline
- Deploying service A doesn't require redeploying service B
- Services communicate through versioned APIs — no shared libraries that force lockstep deployments
If deploying one service requires deploying others at the same time, you don't have microservices — you have a distributed monolith.
3. Decentralized Data Management
Each service owns its own data and is the only service that can read or write to its database. Other services must request data through the service's API.
Why not share a database?
- Coupling — if two services share a table, changing the schema requires coordinating both services
- Ownership — who decides the table structure? Who manages migrations?
- Scalability — you can't independently scale data storage per service
- Technology lock-in — every service must use the same database technology
The trade-off: querying across services becomes harder. You can't just JOIN between user data and order data anymore. This is the fundamental challenge of microservices data management, and we'll address it in the Data Management section.
4. Design for Failure
In a distributed system, network failures are not exceptions — they're the norm. Services go down, networks partition, responses time out. Microservices must be designed to handle partial failures gracefully.
Key patterns:
- Circuit breaker — stop calling a failing service; fall back to a default response
- Timeout — don't wait forever for a response
- Retry with backoff — retry failed calls with exponential delays
- Bulkhead — isolate failures so one failing service doesn't cascade to others
// Circuit breaker with Resilience4j (Spring Boot)
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
@Retry(name = "paymentService", fallbackMethod = "paymentFallback")
@TimeLimiter(name = "paymentService")
public CompletableFuture<PaymentResult> processPayment(PaymentRequest request) {
return CompletableFuture.supplyAsync(() ->
paymentClient.charge(request)
);
}
// Fallback when payment service is down
public CompletableFuture<PaymentResult> paymentFallback(
PaymentRequest request, Exception ex) {
log.warn("Payment service unavailable, queuing for retry: {}", ex.getMessage());
paymentQueue.enqueue(request); // Queue for later processing
return CompletableFuture.completedFuture(
PaymentResult.pending("Payment queued for processing")
);
}Service Boundaries and Bounded Contexts
The hardest part of microservices isn't the technology — it's deciding where to draw the service boundaries. Get it wrong, and you'll spend all your time managing cross-service communication for things that should have been a single function call.
Domain-Driven Design (DDD) and Bounded Contexts
The best technique for finding service boundaries comes from Domain-Driven Design (DDD). A bounded context is a boundary within which a particular domain model is consistent and meaningful.
Consider an e-commerce system. The word "Product" means different things in different contexts:
In the Catalog context, a product has descriptions and images. In the Inventory context, a product has stock levels and warehouse locations. In the Pricing context, a product has prices and discount rules.
Each bounded context becomes a candidate for a microservice. They share a product ID, but each owns its own view of the product.
Heuristics for Finding Service Boundaries
When deciding service boundaries, ask these questions:
- Can this be deployed independently? — If changes to this service always require changes to another, the boundary is wrong.
- Does one team own this? — A good service boundary aligns with team ownership.
- Is the data cohesive? — Data that's always read/written together should stay together.
- How chatty is the communication? — If two services constantly call each other, they might be one service.
Signs your boundaries are wrong:
- ❌ Two services that always deploy together
- ❌ A service that calls another service for every request
- ❌ Data duplication without a clear synchronization strategy
- ❌ Circular dependencies between services
- ❌ One "God service" that coordinates everything
Inter-Service Communication
Services need to talk to each other. There are two fundamental approaches: synchronous (request/response) and asynchronous (event-driven).
Synchronous Communication: REST & gRPC
REST (HTTP/JSON) is the most common approach. Service A makes an HTTP request to Service B and waits for the response.
// Order Service calling User Service (REST)
class OrderService {
async createOrder(userId: string, items: OrderItem[]): Promise<Order> {
// Synchronous call to User Service
const user = await fetch(`http://user-service/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error(`User not found: ${userId}`);
return res.json();
});
// Synchronous call to Payment Service
const payment = await fetch('http://payment-service/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userId: user.id,
amount: calculateTotal(items),
}),
}).then(res => res.json());
return this.orderRepository.save({
userId,
items,
paymentId: payment.id,
status: 'CONFIRMED',
});
}
}gRPC uses HTTP/2, Protocol Buffers (binary serialization), and is significantly faster than REST/JSON. It's ideal for internal service-to-service communication.
// user_service.proto
syntax = "proto3";
service UserService {
rpc GetUser(GetUserRequest) returns (UserResponse);
rpc ValidateUser(ValidateUserRequest) returns (ValidateUserResponse);
}
message GetUserRequest {
string user_id = 1;
}
message UserResponse {
string id = 1;
string name = 2;
string email = 3;
bool is_active = 4;
}REST vs gRPC comparison:
| Feature | REST (HTTP/JSON) | gRPC (HTTP/2 + Protobuf) |
|---|---|---|
| Format | Text-based (JSON) | Binary (Protobuf) |
| Speed | Slower (serialization overhead) | Faster (compact binary) |
| Schema | Optional (OpenAPI) | Required (.proto files) |
| Streaming | Limited | Bidirectional streaming |
| Browser support | Native | Requires gRPC-Web proxy |
| Best for | Public APIs, external clients | Internal service-to-service |
Synchronous Pitfall: Temporal Coupling
The biggest problem with synchronous communication is temporal coupling — Service A can only work if Service B is available right now.
If the Notification Service is down, the Payment Service fails, which means the Order Service fails too. This is called a cascading failure — a single point of failure that brings down the entire chain.
Asynchronous Communication: Events & Messages
Asynchronous communication solves temporal coupling. Services communicate by publishing events to a message broker. Other services subscribe to those events and react independently.
// Order Service — publishes event (Spring Boot + Kafka)
@Service
public class OrderService {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public Order createOrder(CreateOrderCommand command) {
// 1. Save order locally
Order order = orderRepository.save(
Order.create(command.getUserId(), command.getItems())
);
// 2. Publish event — other services react asynchronously
kafkaTemplate.send("order-events", new OrderCreatedEvent(
order.getId(),
order.getUserId(),
order.getItems(),
order.getTotalAmount(),
Instant.now()
));
return order;
}
}
// Payment Service — subscribes to event
@Component
public class PaymentEventHandler {
@KafkaListener(topics = "order-events", groupId = "payment-service")
public void handleOrderCreated(OrderCreatedEvent event) {
// Process payment asynchronously
paymentService.processPayment(
event.getUserId(),
event.getTotalAmount(),
event.getOrderId()
);
}
}Key advantages of async communication:
- No temporal coupling — the Order Service doesn't care if the Payment Service is down right now; the event will be processed when it comes back up
- Better scalability — consumers can process events at their own pace
- Loose coupling — the publisher doesn't know (or care) who's listening
- Event replay — events can be replayed to rebuild state or add new consumers
Trade-off: Asynchronous communication introduces eventual consistency. The order is created before the payment is confirmed. This requires careful handling (see Saga Pattern).
When to Use Sync vs Async
| Use Case | Sync (REST/gRPC) | Async (Events/Messages) |
|---|---|---|
| User expects immediate response | ✅ | ❌ |
| Query another service's data | ✅ | ❌ |
| Fire-and-forget notifications | ❌ | ✅ |
| Long-running operations | ❌ | ✅ |
| Multiple consumers need the same data | ❌ | ✅ |
| Order creation → payment → inventory | Possible | ✅ Preferred |
Practical guideline: Use synchronous for reads (queries), use asynchronous for writes (commands that trigger side effects in other services).
API Gateway Pattern
When clients (web apps, mobile apps) need to call multiple microservices, you don't want them making 5 separate HTTP calls to render a single page. An API gateway sits between clients and services, providing a single entry point.
What the API Gateway Does
- Request routing — routes requests to the appropriate backend service
- Composition/Aggregation — combines data from multiple services into a single response
- Authentication & Authorization — validates tokens, enforces access control
- Rate limiting — protects backend services from traffic spikes
- Protocol translation — translates between HTTP/REST (external) and gRPC (internal)
- SSL termination — handles HTTPS at the edge
- Caching — caches frequently requested data
// API Gateway — aggregates data from multiple services
// (simplified example with Express.js)
app.get('/api/dashboard/:userId', async (req, res) => {
const userId = req.params.userId;
// Parallel calls to multiple services
const [user, orders, notifications] = await Promise.all([
fetch(`http://user-service/users/${userId}`).then(r => r.json()),
fetch(`http://order-service/users/${userId}/orders?limit=5`).then(r => r.json()),
fetch(`http://notification-service/users/${userId}/unread`).then(r => r.json()),
]);
// Aggregate into a single response
res.json({
user: { name: user.name, email: user.email },
recentOrders: orders,
unreadNotifications: notifications.count,
});
});Backend for Frontend (BFF)
A variation is the Backend for Frontend pattern — a separate gateway for each client type, optimized for that client's needs:
The Web BFF returns rich HTML-oriented responses. The Mobile BFF returns compact JSON optimized for bandwidth-constrained devices.
API Gateway Anti-Patterns
- ❌ Business logic in the gateway — the gateway should route and aggregate, not compute
- ❌ Single massive gateway — becomes a bottleneck and single point of failure; consider multiple gateways per domain
- ❌ Gateway as a transformation layer — don't map between domain models in the gateway
Service Discovery
In a microservices environment, services are deployed across multiple hosts, containers, or pods. Their network locations (IP addresses, ports) change dynamically as instances are created, destroyed, or scaled. Service discovery solves the question: "Where is Service B right now?"
Client-Side Discovery
The client queries a service registry to find available instances, then load-balances across them.
Tools: Netflix Eureka, HashiCorp Consul, Apache ZooKeeper
Server-Side Discovery
The client sends requests to a load balancer, which queries the registry and routes the request.
Tools: Kubernetes Services (built-in), AWS Elastic Load Balancer, Nginx
In Kubernetes, service discovery is essentially free — every service gets a DNS name (payment-service.default.svc.cluster.local) that resolves to healthy pods automatically.
Data Management Patterns
Data management is where microservices get really hard. In a monolith, you can JOIN across any table. In microservices, each service owns its own database. How do you handle transactions that span multiple services?
Database per Service
Each service has its own database, and no other service can access it directly.
Notice that different services can use different databases. The Order Service uses PostgreSQL for transactional guarantees. The Product Service uses MongoDB for flexible document schemas. This is called polyglot persistence.
The Cross-Service Query Problem
How do you display an order with the customer's name and product details when that data lives in three different databases?
Approach 1: API Composition
The service that needs the data calls the other services' APIs:
// Order Service — API Composition
async getOrderDetails(orderId: string): Promise<OrderDetails> {
const order = await this.orderRepository.findById(orderId);
// Call User Service for customer info
const user = await this.userClient.getUser(order.userId);
// Call Product Service for product details
const products = await Promise.all(
order.items.map(item =>
this.productClient.getProduct(item.productId)
)
);
return {
orderId: order.id,
customerName: user.name,
items: order.items.map((item, i) => ({
productName: products[i].name,
quantity: item.quantity,
price: item.price,
})),
total: order.totalAmount,
status: order.status,
};
}Approach 2: Data Duplication (CQRS-lite)
Each service stores a read-only copy of data it frequently needs. The data is kept in sync via events:
// Order Service — stores denormalized user data
@KafkaListener(topics = "user-events", groupId = "order-service")
public void handleUserUpdated(UserUpdatedEvent event) {
// Update local cache of user names
orderRepository.updateCustomerName(event.getUserId(), event.getName());
}This is faster (no cross-service calls for reads) but introduces eventual consistency — the cached user name might be stale for a few seconds after an update.
Saga Pattern
In a monolith, creating an order might look like this:
BEGIN TRANSACTION;
INSERT INTO orders (...);
UPDATE inventory SET quantity = quantity - 1 WHERE product_id = ?;
INSERT INTO payments (...);
COMMIT;In microservices, you can't do a distributed transaction across databases (well, you shouldn't — two-phase commit doesn't scale). Instead, you use a saga — a sequence of local transactions coordinated by events.
Choreography-Based Saga (each service reacts to events):
What if the payment fails? The saga runs compensating transactions in reverse:
Orchestration-Based Saga (a central orchestrator coordinates the steps):
// Saga Orchestrator
@Component
public class CreateOrderSaga {
public void execute(CreateOrderCommand command) {
SagaBuilder.create()
.step("Create Order")
.invoke(() -> orderService.createOrder(command))
.compensate(() -> orderService.cancelOrder(command.getOrderId()))
.step("Reserve Inventory")
.invoke(() -> inventoryService.reserve(command.getItems()))
.compensate(() -> inventoryService.release(command.getItems()))
.step("Process Payment")
.invoke(() -> paymentService.charge(command.getUserId(), command.getTotal()))
.compensate(() -> paymentService.refund(command.getPaymentId()))
.step("Confirm Order")
.invoke(() -> orderService.confirmOrder(command.getOrderId()))
.build()
.execute();
}
}Choreography vs Orchestration:
| Aspect | Choreography | Orchestration |
|---|---|---|
| Coordination | Decentralized (events) | Centralized (orchestrator) |
| Coupling | Looser | Tighter (to orchestrator) |
| Visibility | Harder to track flow | Easy to see the full flow |
| Complexity | Simple for 2-3 steps | Better for 4+ steps |
| Debugging | Harder (follow events) | Easier (check orchestrator) |
Eventual Consistency
In a microservices system, strong consistency across services is not achievable (see CAP theorem). Instead, you embrace eventual consistency: the system will become consistent over time, but at any given moment, different services may have slightly different views of the data.
This requires changes in thinking:
- UI design — show "Processing..." instead of immediately confirmed states
- Idempotency — every operation must be safe to retry (because messages can be delivered more than once)
- Conflict resolution — have strategies for when data diverges
// Idempotent event handler — safe to process the same event twice
@KafkaListener(topics = "payment-events")
public void handlePaymentCompleted(PaymentCompletedEvent event) {
// Use event ID as idempotency key
if (processedEvents.contains(event.getEventId())) {
log.info("Event already processed, skipping: {}", event.getEventId());
return;
}
orderService.confirmOrder(event.getOrderId());
processedEvents.add(event.getEventId());
}Deployment and Infrastructure
Microservices and containers go hand in hand. Each service is packaged as a container image and orchestrated with Kubernetes (or similar platforms).
Container per Service
# Order Service — Dockerfile
FROM eclipse-temurin:21-jre-alpine
COPY target/order-service.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]Kubernetes Deployment
# order-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
spec:
replicas: 3 # Scale independently
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
spec:
containers:
- name: order-service
image: registry.example.com/order-service:v2.1.0
ports:
- containerPort: 8080
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
---
apiVersion: v1
kind: Service
metadata:
name: order-service
spec:
selector:
app: order-service
ports:
- port: 80
targetPort: 8080With Kubernetes, you get:
- Service discovery —
order-service.default.svc.cluster.local - Load balancing — traffic distributed across 3 replicas
- Health checks — automatically restart unhealthy pods
- Rolling deployments — zero-downtime deployments
- Auto-scaling — scale based on CPU/memory/custom metrics
Observability: Logging, Monitoring, Tracing
In a monolith, debugging is straightforward — check the logs. In microservices, a single user request might flow through 5 different services. Without proper observability, debugging is a nightmare.
The Three Pillars of Observability
1. Centralized Logging
Aggregate logs from all services into a single platform (ELK Stack, Grafana Loki, Datadog).
Every log entry should include a correlation ID — a unique identifier that follows the request across all services:
// Correlation ID propagation
@Component
public class CorrelationFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String correlationId = httpRequest.getHeader("X-Correlation-ID");
if (correlationId == null) {
correlationId = UUID.randomUUID().toString();
}
MDC.put("correlationId", correlationId); // Add to all log entries
chain.doFilter(request, response);
MDC.remove("correlationId");
}
}2. Distributed Tracing
Track the full journey of a request across services using tools like Jaeger, Zipkin, or OpenTelemetry.
Each step is a span. All spans for one request share a trace ID. You can visualize exactly where time is spent and which service is the bottleneck.
3. Metrics & Monitoring
Track RED metrics for every service:
- Rate — requests per second
- Errors — error rate percentage
- Duration — response time distribution (p50, p95, p99)
Tools: Prometheus + Grafana, Datadog, New Relic
Testing Microservices
Testing microservices is fundamentally different from testing a monolith. You can't just spin up the whole system for every test. Instead, use a testing pyramid adapted for distributed systems.
Unit Tests (Fastest, Most Numerous)
Test individual classes and functions. Same as in a monolith.
@Test
void shouldCalculateOrderTotal() {
Order order = new Order();
order.addItem(new OrderItem("product-1", 2, new BigDecimal("10.00")));
order.addItem(new OrderItem("product-2", 1, new BigDecimal("25.00")));
assertEquals(new BigDecimal("45.00"), order.getTotalAmount());
}Contract Tests (Key for Microservices)
Contract tests verify that the API contract between two services is maintained. If the User Service changes its response format, contract tests catch it before deployment.
// Consumer side — Order Service defines what it expects from User Service
@Pact(consumer = "order-service", provider = "user-service")
public V4Pact createPact(PactDslWithProvider builder) {
return builder
.given("user 123 exists")
.uponReceiving("a request for user 123")
.path("/users/123")
.method("GET")
.willRespondWith()
.status(200)
.body(newJsonBody(body -> {
body.stringType("id", "123");
body.stringType("name", "John Doe");
body.stringType("email", "john@example.com");
}).build())
.toPact(V4Pact.class);
}
@Test
@PactTestFor(pactMethod = "createPact")
void shouldGetUserFromUserService(MockServer mockServer) {
UserClient client = new UserClient(mockServer.getUrl());
User user = client.getUser("123");
assertEquals("John Doe", user.getName());
assertEquals("john@example.com", user.getEmail());
}Contract tests run in each service's CI pipeline. If the User Service team changes the API in a way that breaks the contract, their build fails — not the Order Service's build.
Integration Tests
Test that your service works correctly with its own database and external dependencies (using test containers):
@SpringBootTest
@Testcontainers
class OrderServiceIntegrationTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16");
@Container
static KafkaContainer kafka = new KafkaContainer(
DockerImageName.parse("confluentinc/cp-kafka:7.5.0")
);
@Test
void shouldCreateOrderAndPublishEvent() {
// Given
CreateOrderCommand command = new CreateOrderCommand("user-123", items);
// When
Order order = orderService.createOrder(command);
// Then
assertNotNull(order.getId());
assertEquals("PENDING", order.getStatus());
// Verify event was published to Kafka
ConsumerRecord<String, String> record = KafkaTestUtils
.getSingleRecord(consumer, "order-events");
assertThat(record.value()).contains("OrderCreated");
}
}End-to-End Tests (Fewest, Most Expensive)
Test the full user journey across all services. Run these sparingly — they're slow, flaky, and expensive to maintain.
Organizational Alignment: Conway's Law
"Any organization that designs a system will produce a design whose structure is a copy of the organization's communication structure." — Melvin Conway
This isn't just an observation — it's a law of nature in software engineering. Your architecture will mirror your team structure, whether you plan it or not.
Inverse Conway Maneuver
Smart organizations use Conway's Law intentionally: design your team structure to match the architecture you want.
Each team:
- Owns one or more services end-to-end (development, testing, deployment, operations)
- Is cross-functional (frontend, backend, QA, DevOps within the same team)
- Communicates through APIs, not meetings
- Has full autonomy over technology choices within their services
Amazon calls this "two-pizza teams" — small enough that two pizzas feed the whole team (5-8 people).
Team Topologies for Microservices
Based on the Team Topologies framework:
- Stream-aligned teams — own a business capability end-to-end (Order team, Payment team)
- Platform team — provides shared infrastructure (Kubernetes, CI/CD, observability)
- Enabling team — helps stream-aligned teams adopt new practices (security, performance)
- Complicated subsystem team — owns technically complex components (ML pipeline, video encoding)
When NOT to Use Microservices
Microservices are not a universal improvement. Here are situations where they're the wrong choice:
1. Small Team (< 10 Developers)
If you have 5 developers managing 15 microservices, most of their time goes to infrastructure, not business logic. A monolith with clean modules would be far more productive.
2. Early-Stage Product
You don't know your domain well enough yet. Service boundaries drawn too early will be wrong, and reshuffling microservices is much harder than reshuffling modules in a monolith.
"If you can't build a well-structured monolith, what makes you think you can build a well-structured distributed system?" — Simon Brown
3. No DevOps Capability
Microservices require:
- Containerization (Docker)
- Orchestration (Kubernetes or equivalent)
- CI/CD pipelines per service
- Centralized logging and monitoring
- Distributed tracing
If your team doesn't have this infrastructure, you'll spend months building the platform before writing any business logic.
4. Strong Consistency Requirements
If your domain requires strict transactions across what would be different services (banking, financial trading), the eventual consistency model of microservices adds enormous complexity. A monolith with a single database gives you ACID transactions for free.
5. The "Distributed Monolith" Anti-Pattern
The worst outcome is a system that has all the complexity of microservices with none of the benefits:
- Services that must deploy together
- Services that share a database
- Services that can't function without synchronous calls to other services
- One team owns all the services
This is a distributed monolith — you've taken a perfectly functional monolith and added network latency, partial failures, and operational overhead.
Migration: From Monolith to Microservices
If you've decided microservices are right for your situation, don't rewrite everything at once. Use incremental strategies.
The Strangler Fig Pattern
Named after strangler fig trees that grow around existing trees and eventually replace them. Gradually extract functionality from the monolith into microservices:
Migration Steps
- Start with the edges — extract services that have clear boundaries and minimal dependencies (notifications, email, file uploads)
- Add an API gateway — route traffic between the monolith and new services
- Duplicate, then migrate — run both the monolith and the new service in parallel before cutting over
- Extract data — split the shared database into service-specific databases (this is the hardest part)
- Repeat — continue extracting services one at a time
What to Extract First
Good candidates for first extraction:
- ✅ Services with clear, simple boundaries (notifications, email)
- ✅ Services with high change frequency (experimented-on features)
- ✅ Services with different scaling needs (image processing, search)
- ✅ Services owned by a different team
Bad candidates for first extraction:
- ❌ Core domain logic (users, orders) — too many dependencies
- ❌ Shared data entities — too tightly coupled
- ❌ Authentication — cross-cutting concern, extract last
Real-World Architecture: E-Commerce Microservices
Let's bring everything together with a complete e-commerce architecture:
Key decisions:
- API Gateway — single entry point, authentication, rate limiting
- Synchronous — Gateway → Services for queries (catalog browsing, user profile)
- Asynchronous — Order/Payment events via Kafka for downstream processing
- Polyglot persistence — PostgreSQL for transactions, MongoDB for catalog, Redis for inventory counts, Elasticsearch for search
- Event-driven updates — Search index updated via Kafka events, not direct DB access
Quick Reference: Microservices Checklist
Before adopting microservices, verify your readiness:
Prerequisites:
- Team size > 10 developers
- Mature CI/CD pipeline
- Container orchestration (Kubernetes or equivalent)
- Centralized logging and monitoring
- Distributed tracing capability
- Domain well-understood (not a greenfield experiment)
Per-service checklist:
- Service owns a clear business capability
- Service has its own database
- Service is independently deployable
- Service has its own CI/CD pipeline
- API is versioned and documented
- Health check endpoint exists
- Circuit breakers for downstream calls
- Correlation ID propagation in logs
- Contract tests with consumers
Summary
Microservices architecture decomposes a system into independently deployable services, each owning a business capability and its own data.
Core principles:
- Single responsibility per business capability
- Autonomous deployment — no lockstep releases
- Database per service — no shared databases
- Design for failure — circuit breakers, retries, timeouts
Communication patterns:
- Synchronous (REST/gRPC) — for queries and immediate responses
- Asynchronous (events/messages) — for commands and cross-service workflows
- Saga pattern — for distributed transactions with compensating actions
Infrastructure requirements:
- Containers + orchestration (Kubernetes)
- API gateway for client-facing traffic
- Service discovery (built into Kubernetes)
- Distributed tracing and centralized logging
- Contract testing between services
When to use microservices:
- Large team (10+ developers) needing independent deployability
- Different parts of the system need different scaling profiles
- Multiple teams need to work independently
- Domain is well-understood and bounded contexts are clear
When NOT to use microservices:
- Small team (< 10 developers)
- Early-stage product with uncertain requirements
- No DevOps capability or container infrastructure
- Strong consistency requirements across the whole system
- You'd end up with a distributed monolith
Start with a well-structured monolith. Extract microservices only when the pain of the monolith outweighs the complexity of distribution.
What's Next in the Software Architecture Series
This is post 5 of 12 in the Software Architecture Patterns series:
- ✅ ARCH-1: Software Architecture Patterns Roadmap
- ✅ ARCH-2: Monolithic Architecture
- ✅ ARCH-3: Layered (N-Tier) Architecture
- ✅ ARCH-4: MVC, MVP & MVVM Patterns
- ✅ ARCH-5: Microservices Architecture (this post)
- 🔜 ARCH-6: Event-Driven Architecture
- 🔜 ARCH-7: CQRS & Event Sourcing
- 🔜 ARCH-8: Hexagonal Architecture (Ports & Adapters)
- 🔜 ARCH-9: Clean Architecture
- 🔜 ARCH-10: Domain-Driven Design (DDD)
- 🔜 ARCH-11: Serverless & Function-as-a-Service
- 🔜 ARCH-12: Choosing the Right Architecture
Related posts:
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.