Featured image of post Essential Microservices Design Patterns

Essential Microservices Design Patterns

A deep dive into key patterns for building resilient microservices architectures

Essential Microservices Design Patterns

Microservices architecture has become the standard for building scalable, resilient, and maintainable applications. However, distributed systems bring their own challenges. This post explores essential design patterns that help tackle these challenges effectively.

Communication Patterns

1. API Gateway Pattern

The API Gateway acts as a single entry point for all clients. It routes requests to the appropriate microservice, aggregates responses, and handles cross-cutting concerns like authentication and rate limiting.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@RestController
public class ApiGatewayController {
    
    @Autowired
    private ProductService productService;
    
    @Autowired
    private ReviewService reviewService;
    
    @GetMapping("/products/{id}")
    public ProductDetails getProductDetails(@PathVariable Long id) {
        // Get product information
        Product product = productService.getProduct(id);
        
        // Get reviews for the product
        List<Review> reviews = reviewService.getReviewsForProduct(id);
        
        // Combine data and return
        return new ProductDetails(product, reviews);
    }
}

2. Circuit Breaker Pattern

The Circuit Breaker pattern prevents cascading failures by failing fast when a downstream service is unavailable.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
@Service
public class ProductService {
    
    @Autowired
    private RestTemplate restTemplate;
    
    @CircuitBreaker(name = "productService", fallbackMethod = "getProductFallback")
    public Product getProduct(Long id) {
        return restTemplate.getForObject(
            "http://product-service/products/" + id,
            Product.class
        );
    }
    
    public Product getProductFallback(Long id, Exception e) {
        // Return a default product or cached data
        return new Product(id, "Fallback Product", "This is a fallback response", 0.0);
    }
}

3. Saga Pattern

The Saga pattern manages failures in distributed transactions by defining compensating actions for each step.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Service
public class OrderSaga {
    
    @Autowired
    private OrderService orderService;
    
    @Autowired
    private PaymentService paymentService;
    
    @Autowired
    private InventoryService inventoryService;
    
    @Autowired
    private ShippingService shippingService;
    
    @Transactional
    public OrderResult createOrder(Order order) {
        try {
            // Step 1: Create an order
            OrderEntity savedOrder = orderService.createOrder(order);
            
            try {
                // Step 2: Process payment
                Payment payment = paymentService.processPayment(order.getCustomerId(), order.getAmount());
                
                try {
                    // Step 3: Update inventory
                    inventoryService.updateInventory(order.getItems());
                    
                    try {
                        // Step 4: Schedule shipping
                        Shipping shipping = shippingService.scheduleDelivery(savedOrder);
                        return new OrderResult(savedOrder, payment, shipping);
                    } catch (Exception e) {
                        // Compensate step 3
                        inventoryService.restoreInventory(order.getItems());
                        // Compensate step 2
                        paymentService.refundPayment(payment.getId());
                        // Compensate step 1
                        orderService.cancelOrder(savedOrder.getId());
                        throw e;
                    }
                } catch (Exception e) {
                    // Compensate step 2
                    paymentService.refundPayment(payment.getId());
                    // Compensate step 1
                    orderService.cancelOrder(savedOrder.getId());
                    throw e;
                }
            } catch (Exception e) {
                // Compensate step 1
                orderService.cancelOrder(savedOrder.getId());
                throw e;
            }
        } catch (Exception e) {
            throw new OrderCreationException("Failed to create order", e);
        }
    }
}

Data Management Patterns

1. Database per Service

Each microservice has its own database, ensuring loose coupling and independent development.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// Product Service Database (PostgreSQL)
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    price DECIMAL(10, 2) NOT NULL,
    stock INTEGER NOT NULL DEFAULT 0
);

// Order Service Database (MongoDB)
{
  "_id": ObjectId("5fbd7c9d5d3b2c1a7c8f4a1b"),
  "customerId": "customer123",
  "items": [
    { "productId": 101, "quantity": 2, "price": 29.99 },
    { "productId": 102, "quantity": 1, "price": 49.99 }
  ],
  "totalAmount": 109.97,
  "status": "PROCESSING",
  "createdAt": ISODate("2023-08-24T10:15:30Z")
}

2. CQRS (Command Query Responsibility Segregation)

Separate read and write operations for better scalability and performance.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Command side (Writes)
@RestController
@RequestMapping("/products/commands")
public class ProductCommandController {
    
    @Autowired
    private ProductCommandService commandService;
    
    @PostMapping
    public ResponseEntity<String> createProduct(@RequestBody ProductCreateCommand command) {
        String productId = commandService.createProduct(command);
        return ResponseEntity.ok(productId);
    }
    
    @PutMapping("/{id}")
    public ResponseEntity<Void> updateProduct(@PathVariable String id, 
                                             @RequestBody ProductUpdateCommand command) {
        commandService.updateProduct(id, command);
        return ResponseEntity.ok().build();
    }
}

// Query side (Reads)
@RestController
@RequestMapping("/products/queries")
public class ProductQueryController {
    
    @Autowired
    private ProductQueryService queryService;
    
    @GetMapping
    public ResponseEntity<List<ProductDTO>> getAllProducts() {
        return ResponseEntity.ok(queryService.findAllProducts());
    }
    
    @GetMapping("/{id}")
    public ResponseEntity<ProductDTO> getProduct(@PathVariable String id) {
        return queryService.findById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }
}

3. Event Sourcing

Store all changes to the application state as a sequence of events.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
@Service
public class ProductEventSourcingService {
    
    @Autowired
    private EventStore eventStore;
    
    @Autowired
    private ProductProjection productProjection;
    
    public void createProduct(CreateProductCommand command) {
        ProductCreatedEvent event = new ProductCreatedEvent(
            command.getProductId(),
            command.getName(),
            command.getDescription(),
            command.getPrice()
        );
        
        eventStore.save("product", command.getProductId(), event);
        productProjection.apply(event);
    }
    
    public void updateProductPrice(UpdateProductPriceCommand command) {
        ProductPriceUpdatedEvent event = new ProductPriceUpdatedEvent(
            command.getProductId(),
            command.getNewPrice()
        );
        
        eventStore.save("product", command.getProductId(), event);
        productProjection.apply(event);
    }
    
    public Product getProduct(String productId) {
        List<Event> events = eventStore.getEvents("product", productId);
        Product product = new Product(productId);
        
        for (Event event : events) {
            if (event instanceof ProductCreatedEvent) {
                ProductCreatedEvent e = (ProductCreatedEvent) event;
                product.setName(e.getName());
                product.setDescription(e.getDescription());
                product.setPrice(e.getPrice());
            } else if (event instanceof ProductPriceUpdatedEvent) {
                ProductPriceUpdatedEvent e = (ProductPriceUpdatedEvent) event;
                product.setPrice(e.getNewPrice());
            }
            // Handle other events
        }
        
        return product;
    }
}

Deployment Patterns

1. Sidecar Pattern

Deploy helper services alongside the main service to handle cross-cutting concerns.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: v1
kind: Pod
metadata:
  name: web-app
  labels:
    app: web
spec:
  containers:
  - name: main-app
    image: my-web-app:latest
    ports:
    - containerPort: 8080
  - name: log-collector
    image: log-collector:latest
    volumeMounts:
    - name: logs
      mountPath: /var/log
  - name: metrics-collector
    image: prometheus-agent:latest
    ports:
    - containerPort: 9090
  volumes:
  - name: logs
    emptyDir: {}

2. Blue-Green Deployment

Maintain two identical production environments to minimize downtime during deployments.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: application-ingress
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
  - host: myapp.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: green-service  # Current active environment
            port:
              number: 80
---
# Later, after deploying the blue environment and verifying it works,
# update the ingress to point to the blue service
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: application-ingress
  annotations:
    kubernetes.io/ingress.class: nginx
spec:
  rules:
  - host: myapp.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: blue-service  # New active environment
            port:
              number: 80

Resilience Patterns

1. Bulkhead Pattern

Isolate components to prevent failures from cascading through the system.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Service
public class ResilientProductService {
    
    @Autowired
    private RestTemplate restTemplate;
    
    @Bulkhead(name = "productService", type = Bulkhead.Type.THREADPOOL)
    public Product getProduct(Long id) {
        return restTemplate.getForObject(
            "http://product-service/products/" + id,
            Product.class
        );
    }
    
    @Bulkhead(name = "inventoryService", type = Bulkhead.Type.THREADPOOL)
    public Inventory getInventory(Long productId) {
        return restTemplate.getForObject(
            "http://inventory-service/inventory/product/" + productId,
            Inventory.class
        );
    }
}

2. Retry Pattern

Automatically retry failed operations to handle transient failures.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Service
public class RetryableOrderService {
    
    @Autowired
    private RestTemplate restTemplate;
    
    @Retry(name = "orderService", fallbackMethod = "createOrderFallback")
    public Order createOrder(OrderRequest request) {
        return restTemplate.postForObject(
            "http://order-service/orders",
            request,
            Order.class
        );
    }
    
    public Order createOrderFallback(OrderRequest request, Exception e) {
        // Save to local queue for later processing
        return new Order(null, request.getCustomerId(), "PENDING", new Date());
    }
}

Conclusion

Microservices design patterns help address the inherent challenges of distributed systems. By understanding and applying these patterns appropriately, developers can build more resilient, scalable, and maintainable microservices architectures.

It’s important to remember that these patterns are tools, not rules. Each has its own trade-offs, and the right pattern depends on your specific requirements and constraints.

In future posts, we’ll dive deeper into implementation details and explore how these patterns can be combined to solve complex architectural problems.

Built with Hugo
Theme Stack designed by Jimmy