Architectural Design Patterns in Microservices using Python

In today’s software development landscape, microservices architecture has emerged as a dominant pattern to build scalable, maintainable, and agile applications. Unlike monolithic architectures, where an application is built as a single unit, microservices break down an application into a collection of loosely coupled, independently deployable services. The benefits include improved scalability, resilience, and the flexibility to use different technologies for different services.

One of the keys to harnessing the power of microservices is understanding the architectural patterns that govern their design and communication. In this article, we’ll delve into some prominent design patterns used in microservices and illustrate them with Python examples.

1. API Gateway Pattern

The API Gateway pattern acts as a single entry point for external consumers of the microservices. It’s responsible for request routing, composition, and response transformation. This abstracts the underlying microservices and provides a layer to handle non-business logic concerns like authentication, logging, caching, etc.

Python Example: Using Flask, a lightweight Python web framework, we can create a simple API gateway:

from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/api/orders', methods=['GET'])
def get_orders():
    # Logic to fetch orders from the order microservice
    # e.g., using HTTP calls or a messaging protocol
    response = {"data": "Orders data from order service"}
    return jsonify(response)

if __name__ == '__main__':
    app.run(port=5000)

2. Service Discovery Pattern

Microservices often run in dynamic environments where they can be scaled up or down. The Service Discovery pattern enables automatic detection and tracking of these instances. There are two main variants:

  • Client-side discovery: Clients determine the available service instances using a service registry.
  • Server-side discovery: An intermediary (e.g., a load balancer) handles the discovery.

Python Example: Python’s consul library can be used to integrate with Consul, a service discovery tool:

import consul

c = consul.Consul()

# Register a service
c.agent.service.register('my-service', service_id='1', address='127.0.0.1', port=5000)

# Discover services
index, data = c.health.service('my-service')
for item in data:
    print(item['Service']['Address'], item['Service']['Port'])

3. Circuit Breaker Pattern

This pattern prevents a system from performing operations that are likely to fail, avoiding further system strain and cascading failures. When failures cross a certain threshold, the circuit breaker trips and stops all attempts to invoke the faulty service for a certain period.

Python Example: The pybreaker library provides a Circuit Breaker implementation:

import pybreaker
import requests

breaker = pybreaker.CircuitBreaker(fail_max=3, reset_timeout=60)

@breaker
def get_orders_from_service():
    return requests.get('http://order-service/api/orders').json()

# If the above service fails consecutively, the breaker will open, preventing further calls.

4. Aggregator Pattern

When a client request requires data from multiple services, instead of fetching this data piecemeal, an intermediary (aggregator) can be used to collect data from all required services and return the consolidated data.

Python Example:

@app.route('/api/user-dashboard/<user_id>', methods=['GET'])
def user_dashboard(user_id):
    user_data = requests.get(f'http://user-service/api/users/{user_id}').json()
    order_data = requests.get(f'http://order-service/api/orders?user_id={user_id}').json()
    return jsonify({"user": user_data, "orders": order_data})

5. Asynchronous Messaging

Microservices often communicate synchronously over HTTP, which can be slow and prone to failures. Asynchronous messaging allows services to decouple from each other, resulting in improved scalability and fault tolerance.

Python Example: Using RabbitMQ and the pika library:

import pika

# Sending a message
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()
channel.queue_declare(queue='task_queue', durable=True)

channel.basic_publish(exchange='',
                      routing_key='task_queue',
                      body='Hello World!')

# Receiving a message
def callback(ch, method, properties, body):
    print(f"Received {body}")

channel.basic_consume(queue='task_queue', on_message_callback=callback, auto_ack=True)
channel.start_consuming()

6. CQRS (Command Query Responsibility Segregation)

CQRS is an architectural pattern that separates reading data (query) from updating data (command). This separation allows for optimization of each operation and ensures that the domain logic doesn’t mix command and query operations.

Python Example:

Let’s consider an order service:

class OrderService:

    def place_order(self, order_data):
        # Command: Place a new order
        pass

    def get_order(self, order_id):
        # Query: Get details of an order
        pass

7. SAGAs Pattern:

Let’s say a user wants to purchase an item. The following needs to happen:

  1. Check the inventory for item availability.
  2. Create an order.
  3. Deduct the user’s balance.

Here, we’re using a choreography-based approach where each service listens to events from others.

import mysql.connector
import pika

# RabbitMQ setup
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()

def on_order_created(ch, method, properties, body):
    # This will be executed when the OrderService publishes an "OrderCreated" event
    user_id = body.decode("utf-8")
    # Deduct balance from UserService
    user_conn = mysql.connector.connect(host="localhost", user="user", password="password", database="users_db")
    cursor = user_conn.cursor()
    cursor.execute("UPDATE users SET balance = balance - <amount> WHERE id = %s", (user_id,))
    user_conn.commit()
    user_conn.close()

# Listening to events
channel.basic_consume(queue='order_created', on_message_callback=on_order_created, auto_ack=True)

If any step fails, a compensating transaction is executed to revert the operations. For instance, if the balance deduction fails, the order could be canceled.

8. Two-Phase Commit (2PC):

For the 2PC, we’ll use a coordinator to handle the distributed transaction.

def two_phase_commit(user_id, item_id, order_data):
    # Step 1: Prepare Phase
    try:
        # Check inventory
        inventory_conn = mysql.connector.connect(host="localhost", user="user", password="password", database="inventory_db")
        inventory_cursor = inventory_conn.cursor()
        inventory_cursor.execute("SELECT stock FROM inventory WHERE item_id = %s", (item_id,))
        stock = inventory_cursor.fetchone()[0]
        if stock <= 0:
            raise Exception("Out of Stock")

        # Tentatively create an order
        order_conn = mysql.connector.connect(host="localhost", user="user", password="password", database="orders_db")
        order_cursor = order_conn.cursor()
        order_cursor.execute("INSERT INTO orders (order_id, user_id, item_id, total) VALUES (%s, %s, %s, %s)", order_data)
        
        # Tentatively deduct balance
        user_conn = mysql.connector.connect(host="localhost", user="user", password="password", database="users_db")
        user_cursor = user_conn.cursor()
        user_cursor.execute("UPDATE users SET balance = balance - <amount> WHERE id = %s", (user_id,))

        # If we reached here, all tentative operations succeeded
        # Step 2: Commit Phase
        inventory_conn.commit()
        order_conn.commit()
        user_conn.commit()
        return "Transaction Committed!"
    except Exception as e:
        inventory_conn.rollback()
        order_conn.rollback()
        user_conn.rollback()
        return f"Transaction Aborted due to {str(e)}!"

In this example, the coordinator first ensures that the item is in stock. It then tentatively creates an order and tentatively deducts the user’s balance. If all operations are successful, the coordinator commits all transactions; otherwise, it rolls back.