Cloud Architecture Patterns: Microservices, Events, and Beyond
Cloud architecture patterns are reusable solutions to recurring infrastructure and application design challenges. These patterns emerge from thousands of production deployments and solve fundamental problems around scalability, resilience, maintainability, and cost. According to the AWS Well-Architected Framework, applying appropriate architecture patterns is the single highest-leverage activity for improving system reliability and operational efficiency. This guide covers the most important patterns for cloud-native systems.
Microservices Architecture
Microservices decompose applications into small, independently deployable services that communicate over a network. Each service owns its data, runs in its own process, and can be developed, deployed, and scaled independently.
When to use: You have multiple development teams working on the same application. Different components have different scaling requirements. You need independent deployment cycles for different features.
When to avoid: Your team has fewer than 10 engineers. The application is simple (CRUD over a single database). You lack operational maturity for distributed systems monitoring and debugging.
Design principles:
- Single responsibility — each service should do one thing well. If a service is responsible for user authentication AND payment processing, split it.
- Database per service — services should not share databases. Shared databases create coupling that defeats the purpose of microservices.
- API contracts — services communicate through well-defined APIs (REST, gRPC, or asynchronous events). Version APIs aggressively to allow independent evolution.
- Bounded contexts — align service boundaries with domain-driven design bounded contexts. The “Order” service owns order lifecycle; the “Inventory” service owns stock management.
# Service template with health check, metrics, and graceful shutdown
from fastapi import FastAPI
from prometheus_client import Counter
import signal, sys
app = FastAPI()
request_count = Counter('requests_total', 'Total requests')
@app.get("/health")
def health():
return {"status": "healthy", "service": "catalog"}
@app.get("/ready")
def ready():
# Verify database connectivity
return {"status": "ready"}
def shutdown_handler(signum, frame):
print("Shutting down gracefully...")
sys.exit(0)
signal.signal(signal.SIGTERM, shutdown_handler)Event-Driven Architecture
In event-driven architecture, services communicate through events rather than synchronous HTTP calls. Services publish events to a message broker (Kafka, RabbitMQ, SQS, Pub/Sub), and interested services consume those events asynchronously.
Pattern structure:
Service A (Produces Event) → Event Bus → Service B (Consumer)
→ Service C (Consumer)
→ Service D (Consumer)Benefits: Loose coupling (producers don’t know about consumers), scalability (consumers can scale independently), resilience (consumers can fail and retry without affecting producers).
Common use cases:
- Order processing — order placed → event → payment service charges → event → inventory service reserves → event → shipping service schedules
- User registration — user created → event → email service sends welcome → event → analytics service records → event → CRM service creates contact
- Data replication — changes in one service published as events and consumed by search index, cache, and analytics services
Implementation considerations:
- Event schema evolution — use Avro, Protobuf, or JSON Schema with a schema registry. Backward-compatible schemas allow consumers to upgrade independently.
- Idempotency — consumers may receive the same event multiple times. Design handlers to produce the same result regardless of duplicate delivery.
- Dead letter queues — events that fail processing after N retries should be moved to a dead letter queue for manual investigation.
CQRS (Command Query Responsibility Segregation)
CQRS separates read models from write models. Commands (writes) use one data model and typically go to a normalized database. Queries (reads) use a different, optimized data model — often denormalized, cached, or backed by a read-optimized store.
When to use: Read and write workloads have different performance requirements. Complex business logic makes writes costly. Read-heavy workloads need optimized query patterns.
Client → Command → Write Model → Write DB ←→ Sync → Read DB → Query → Client
↕
CacheExample: An e-commerce system:
- Write side:
create_order()validates inventory, applies pricing rules, processes payment, stores to PostgreSQL - Read side:
get_order_history()queries Elasticsearch with pre-joined data, returns in milliseconds
CQRS is often paired with Event Sourcing, where the write model stores a sequence of events rather than current state. Event sourcing provides an audit log, supports temporal queries, and enables rebuilding read models from scratch.
Saga Pattern
The saga pattern manages distributed transactions across multiple services. Instead of a traditional ACID transaction (impossible across distributed databases), a saga executes a series of local transactions, with compensating actions to undo changes if any step fails.
Choreography saga — each service publishes events that trigger the next step. No central coordinator:
def create_order(order_data):
order = Order.create(order_data)
event_bus.publish("order_created", order.id)
def handle_order_created(event):
inventory = reserve_inventory(event.order_id)
if inventory.reserved:
event_bus.publish("inventory_reserved", event.order_id)
else:
event_bus.publish("order_failed", event.order_id)
def handle_inventory_reserved(event):
payment = process_payment(event.order_id)
if payment.successful:
event_bus.publish("payment_successful", event.order_id)
else:
event_bus.publish("payment_failed", event.order_id)
event_bus.publish("release_inventory", event.order_id) # Compensating actionOrchestration saga — a coordinator service sends commands to each participant and handles failures centrally. Better for complex workflows with conditional branching.
Use cases: Order fulfillment, account opening, booking systems (flight + hotel + car).
Strangler Fig Pattern
Incrementally replace a legacy system by building new services around it, gradually routing more traffic to the new system until the legacy can be decommissioned.
Phases:
- Identify a bounded capability in the legacy system (e.g., search functionality)
- Build a new service with identical functionality
- Route traffic for that capability to the new service (feature flag or proxy)
- Repeat for each capability until the legacy system has no traffic
- Decommission the legacy system
Proxy-based routing: Deploy a proxy layer (API Gateway, Nginx, Envoy) in front of the legacy system. As new services are built, routes are added to the proxy. Traffic shifts gradually — no big bang migration.
Benefits: Continuous delivery during migration, reduced risk (small increments), ability to roll back individual capabilities.
Circuit Breaker
Prevent cascading failures by stopping calls to a failing service. When a service is unhealthy, the circuit breaker “opens” and subsequent calls return a fallback response or fail fast instead of waiting for a timeout.
┌─────────┐ ┌──────────────┐ ┌─────────┐
│ Client │ ──→ │ Circuit │ ──→ │ Service │
│ │ ←── │ Breaker │ ←── │ │
└─────────┘ └──────────────┘ └─────────┘States:
- Closed — requests pass through normally. Failures counted.
- Open — failures exceed threshold. Requests fail immediately without calling the service. After a timeout, transition to half-open.
- Half-open — allow a limited number of probe requests. If successful, close the circuit. If failed, return to open.
import pybreaker
catalog_breaker = pybreaker.CircuitBreaker(
fail_max=5,
reset_timeout=60,
exclude=[requests.HTTPError] # Don't count 4xx as circuit failures
)
@catalog_breaker
def get_product(product_id):
response = requests.get(f"http://catalog-service/products/{product_id}", timeout=2)
return response.json()
# Fallback when circuit is open
def get_product_with_fallback(product_id):
try:
return get_product(product_id)
except pybreaker.CircuitBreakerError:
return {"id": product_id, "name": "Product unavailable", "price": None}Twelve-Factor App
The twelve-factor app methodology defines patterns for building cloud-native applications:
- Codebase — one codebase tracked in revision control, many deploys
- Dependencies — explicitly declare and isolate dependencies (requirements.txt, package.json)
- Config — store configuration in environment variables, not code
- Backing services — treat databases, caches, queues as attached resources (no hardcoded connections)
- Build, release, run — strictly separate build and run stages
- Processes — execute the app as one or more stateless processes
- Port binding — export services via port binding (self-contained HTTP server)
- Concurrency — scale out via the process model (horizontal scaling)
- Disposability — fast startup and graceful shutdown for robustness
- Dev/prod parity — keep development, staging, and production as similar as possible
- Logs — treat logs as event streams (never store logs inside the application)
- Admin processes — run admin tasks as one-off processes (migrations, scripts)
Choosing the Right Pattern
Start with a modular monolith (well-structured code with clear boundaries) before distributing services. Premature distribution introduces complexity without benefit. Add patterns as the system grows:
- 100-1000 users: Single service, monolithic database
- 1K-100K users: Modular monolith, caching layer (Redis), read replicas
- 100K-1M users: Microservices for scaling pain points, async event processing
- 1M+ users: Full event-driven architecture, CQRS, circuit breakers, service mesh
FAQ
How do I decompose a monolith into microservices? Start with the Strangler Fig pattern. Identify one bounded context with clear API boundaries. Extract it into a standalone service. Route traffic gradually. Repeat. Never extract more than one service per sprint — premature decomposition leads to distributed monoliths.
What is the difference between orchestration and choreography in sagas? Orchestration uses a central coordinator to manage the saga workflow — easier to understand and debug, but creates a single point of failure. Choreography distributes decision-making to each service — more resilient but harder to trace and debug. Use orchestration for complex workflows, choreography for simple linear chains.
When should I use event-driven architecture vs REST APIs? Use REST for request-response patterns where the caller needs an immediate answer (user clicks a button, needs a response). Use event-driven for fire-and-forget operations, multi-step workflows, and broadcasting to multiple consumers. Most systems need both.
What is the difference between CQRS and event sourcing? CQRS separates read and write models (always paired but not always with event sourcing). Event sourcing stores events as the source of truth and derives current state by replaying them. CQRS is a pattern; event sourcing is a storage strategy. They work well together but are independent.
How do I handle distributed transactions without sagas? You cannot achieve ACID guarantees across distributed services. Use sagas (eventual consistency with compensating actions). If strong consistency is required, keep related data in the same service (database-per-service with local transactions) or use a distributed database like Spanner or CockroachDB.
Cloud Computing Overview — Serverless Computing Guide — Cloud-Native Development