Back to blog

Microservices Architecture: Principles, Patterns & Pitfalls

software-architecturemicroservicesbackendsystem-designdesign-patterns
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:

  1. Can this be deployed independently? — If changes to this service always require changes to another, the boundary is wrong.
  2. Does one team own this? — A good service boundary aligns with team ownership.
  3. Is the data cohesive? — Data that's always read/written together should stay together.
  4. 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:

FeatureREST (HTTP/JSON)gRPC (HTTP/2 + Protobuf)
FormatText-based (JSON)Binary (Protobuf)
SpeedSlower (serialization overhead)Faster (compact binary)
SchemaOptional (OpenAPI)Required (.proto files)
StreamingLimitedBidirectional streaming
Browser supportNativeRequires gRPC-Web proxy
Best forPublic APIs, external clientsInternal 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 CaseSync (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 → inventoryPossible✅ 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

  1. Request routing — routes requests to the appropriate backend service
  2. Composition/Aggregation — combines data from multiple services into a single response
  3. Authentication & Authorization — validates tokens, enforces access control
  4. Rate limiting — protects backend services from traffic spikes
  5. Protocol translation — translates between HTTP/REST (external) and gRPC (internal)
  6. SSL termination — handles HTTPS at the edge
  7. 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:

AspectChoreographyOrchestration
CoordinationDecentralized (events)Centralized (orchestrator)
CouplingLooserTighter (to orchestrator)
VisibilityHarder to track flowEasy to see the full flow
ComplexitySimple for 2-3 stepsBetter for 4+ steps
DebuggingHarder (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: 8080

With Kubernetes, you get:

  • Service discoveryorder-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

  1. Start with the edges — extract services that have clear boundaries and minimal dependencies (notifications, email, file uploads)
  2. Add an API gateway — route traffic between the monolith and new services
  3. Duplicate, then migrate — run both the monolith and the new service in parallel before cutting over
  4. Extract data — split the shared database into service-specific databases (this is the hardest part)
  5. 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:

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.