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.

~15 min read · For senior engineers & architects · Next: API Design Best Practices →

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:

Extract services when:

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

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

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

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

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

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

Pattern Decision Matrix

Use this as a starting point. Context always wins.

Scenario Recommended Pattern(s) Avoid
New product, small team, unclear domainModular monolithMicroservices, CQRS, Event Sourcing
High traffic, different scaling needsMicroservices (strategic split)Premature extraction
Complex multi-step business workflowOrchestrated SagaChoreography >3 steps, 2PC
Audit trail / regulatory complianceEvent Sourcing + CQRSSimple logging
Read-heavy, complex queriesCQRS (read replicas, materialized views)Over-engineering simple CRUD
External API dependenciesCircuit Breaker + Retry + TimeoutNo resilience patterns
Real-time notifications / async processingEvent-Driven (Kafka/RabbitMQ)Polling, tight coupling
Team autonomy requiredMicroservices (with clear contracts)Shared database, distributed monolith

Anti-patterns to recognize

Continue Learning

Read the companion guides on API design, database scaling, and the backend developer roadmap.