Architecture Guide · System Design
Backend Architecture Patterns: When to Use What
This is not a catalog of patterns — it's a decision framework. Every pattern has a cost. The right choice depends on team size, traffic profile, consistency requirements, and operational maturity. Based on building production systems at 10K+ RPS across e-commerce, social, and analytics domains.
Monolith vs Microservices: The Real Decision Factors
Start with a modular monolith. Extract services only when you have a specific scaling or organizational need. Most "microservices" failures are actually premature decomposition.
Stay with monolith when:
- Team < 10 engineers
- Single deployable unit simplifies CI/CD, debugging, transactions
- Domain boundaries unclear (early product)
- Low traffic (< 10K RPS) — vertical scaling works
- Strong consistency required across domains
Extract services when:
- Different scaling profiles (e.g., image processing vs API)
- Team autonomy needed (separate deploy cycles, ownership)
- Technology heterogeneity (ML service in Python, API in Go)
- Failure isolation critical (payment service shouldn't crash search)
The modular monolith middle ground
// Package-by-feature, not layer
src/
├── modules/
│ ├── orders/
│ │ ├── domain/ # Entities, value objects, domain events
│ │ ├── application/ # Use cases, commands, queries
│ │ ├── infrastructure/ # Repositories, external adapters
│ │ └── api/ # HTTP controllers
│ ├── products/
│ │ └── ...
│ └── users/
│ └── ...
├── shared/ # Kernel: events, value objects, utils
└── main.ts # Composition root
Clear module boundaries = easy extraction later. Shared kernel = controlled coupling. This is how Shopify, GitHub, and Basecamp operate at massive scale.
Event-Driven Architecture: Not Just for Microservices
Events decouple producers from consumers. Use for: audit trails, async workflows, cross-module communication, eventual consistency. Can live inside a monolith (in-memory event bus) or across services (Kafka, RabbitMQ).
When to use
- Multiple downstream reactions to one action (order placed → email, inventory, analytics, loyalty)
- Long-running processes (order → payment → fulfillment → delivery)
- Integration with external systems (webhooks, CDC)
Event design principles
// Good: Domain event (past tense, immutable, expressive)
interface OrderPlaced {
eventType: "OrderPlaced";
eventId: string; // UUID for idempotency
occurredAt: string; // ISO 8601
aggregateId: string; // Order ID
payload: {
orderId: string;
customerId: string;
items: OrderItem[];
total: Money;
shippingAddress: Address;
};
metadata: {
correlationId: string; // Trace across services
causationId: string; // What triggered this
};
}
// Bad: CRUD event (leaks implementation)
interface OrderCreated {
table: "orders";
operation: "INSERT";
data: { ... };
}
Pitfalls to avoid
- Event sourcing by default: Adds massive complexity. Only when you need full audit/replay.
- Choreography everywhere: Hard to trace. Use orchestration (Saga) for business workflows.
- No schema registry: Breaks consumers. Use Avro/Protobuf + Schema Registry.
- At-least-once without idempotency: Duplicate processing. Every consumer must be idempotent.
CQRS (Command Query Responsibility Segregation)
Separate read and write models. Write model handles commands (validation, invariants). Read model handles queries (denormalized, optimized). Not the same as Event Sourcing — can use same database with different schemas.
When CQRS pays off
- Read/write patterns vastly different (complex queries vs simple commands)
- Read models need different data shapes (dashboard vs detail view)
- Team can maintain two models (operational cost)
Simple CQRS in a monolith (same DB, different tables)
// Write model (normalized, invariants enforced)
@Entity
class Order {
@Id OrderId id;
CustomerId customerId;
List items;
OrderStatus status;
// Invariants: items not empty, status transitions valid
void addItem(ProductId, Quantity) { ... }
void cancel() { if (status != PENDING) throw; status = CANCELLED; }
}
// Read model (denormalized, query-optimized)
@Entity
class OrderSummaryView {
@Id String orderId;
String customerName;
String customerEmail;
int itemCount;
Money total;
OrderStatus status;
LocalDateTime placedAt;
// Updated via @EventListener on OrderPlaced/OrderCancelled
}
When NOT to use CQRS
- Simple CRUD — adds indirection without benefit
- Team unfamiliar — eventual consistency bugs are subtle
- Low query complexity — single model serves both well
Saga Pattern: Distributed Transactions Without 2PC
Chain of local transactions with compensating actions for rollback. Two flavors: Choreography (events) vs Orchestration (central coordinator). Orchestration wins for complex workflows — visible, testable, debuggable.
Orchestration-based Saga (recommended)
// Saga orchestrator (state machine)
class OrderSaga {
private enum Step { RESERVE_INVENTORY, CHARGE_PAYMENT, CONFIRM_ORDER, COMPENSATE }
async execute(orderId: OrderId) {
const state = { orderId, step: Step.RESERVE_INVENTORY, completed: [] };
try {
// Step 1: Reserve inventory
await inventoryClient.reserve(orderId);
state.completed.push(Step.RESERVE_INVENTORY);
// Step 2: Charge payment
await paymentClient.charge(orderId);
state.completed.push(Step.CHARGE_PAYMENT);
// Step 3: Confirm order
await orderClient.confirm(orderId);
state.completed.push(Step.CONFIRM_ORDER);
} catch (error) {
await this.compensate(state.completed.reverse(), orderId);
throw error;
}
}
private async compensate(completed: Step[], orderId: OrderId) {
for (const step of completed) {
switch (step) {
case Step.CHARGE_PAYMENT: await paymentClient.refund(orderId); break;
case Step.RESERVE_INVENTORY: await inventoryClient.release(orderId); break;
}
}
}
}
Saga design rules
- Each step must be idempotent (retry safe)
- Compensations must be idempotent and commutative
- Store saga state persistently (survive orchestrator restart)
- Timeouts on each step (prevent stuck sagas)
- Observability: log every step start/complete/compensate
Choreography alternative (simpler, less control)
Services emit/consume events. OrderService emits OrderCreated → InventoryService reserves → emits InventoryReserved → PaymentService charges. Harder to debug, no central visibility. OK for 2-3 steps max.
Circuit Breaker: Fail Fast, Recover Gracefully
Prevent cascade failures when downstream dependency degrades. Three states: Closed (normal) → Open (failing fast) → Half-Open (testing recovery). Essential for any service calling external APIs or other services.
Implementation (Resilience4j / custom)
@Configuration
public class CircuitBreakerConfig {
@Bean
public CircuitBreakerRegistry circuitBreakerRegistry() {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.failureRateThreshold(50) // Open at 50% failure
.waitDurationInOpenState(Duration.ofSeconds(30))
.slidingWindowSize(10) // Last 10 calls
.minimumNumberOfCalls(5) // Need 5 calls to evaluate
.permittedNumberOfCallsInHalfOpenState(3)
.recordExceptions(ConnectException.class, SocketTimeoutException.class)
.build();
return CircuitBreakerRegistry.of(config);
}
}
// Usage
@Service
class PaymentClient {
private final CircuitBreaker cb = cbRegistry.circuitBreaker("payment-service");
public PaymentResponse charge(ChargeRequest req) {
return cb.executeSupplier(() -> httpClient.post("/charge", req));
}
// Fallback for graceful degradation
public PaymentResponse chargeWithFallback(ChargeRequest req) {
return Try.ofSupplier(() -> cb.executeSupplier(() -> httpClient.post("/charge", req)))
.recover(throwable -> PaymentResponse.deferred(req.id())) // Queue for later
.get();
}
}
Monitoring the circuit
- Metrics: state transitions, failure rate, call latency
- Alert on: Open state > 1min, frequent Half-Open failures
- Dashboard: per-dependency circuit state + latency percentiles
Pattern Decision Matrix
Use this as a starting point. Context always wins.
| Scenario | Recommended Pattern(s) | Avoid |
|---|---|---|
| New product, small team, unclear domain | Modular monolith | Microservices, CQRS, Event Sourcing |
| High traffic, different scaling needs | Microservices (strategic split) | Premature extraction |
| Complex multi-step business workflow | Orchestrated Saga | Choreography >3 steps, 2PC |
| Audit trail / regulatory compliance | Event Sourcing + CQRS | Simple logging |
| Read-heavy, complex queries | CQRS (read replicas, materialized views) | Over-engineering simple CRUD |
| External API dependencies | Circuit Breaker + Retry + Timeout | No resilience patterns |
| Real-time notifications / async processing | Event-Driven (Kafka/RabbitMQ) | Polling, tight coupling |
| Team autonomy required | Microservices (with clear contracts) | Shared database, distributed monolith |
Anti-patterns to recognize
- Distributed monolith: Services share DB, deploy together, fail together
- Event-driven spaghetti: Events for everything, no ownership, circular dependencies
- CQRS without need: Double the models, half the velocity
- Saga without compensation: Partial failure = data corruption
Continue Learning
Read the companion guides on API design, database scaling, and the backend developer roadmap.