Skip to main content
Architecture10 min readMarch 3, 2026

Software Design Patterns Every Architect Should Have in Their Toolkit

Software design patterns become architectural tools when applied at the right scale. Here's how Factory, Strategy, Observer, Saga, Outbox, and Repository patterns serve architectural goals beyond their textbook definitions.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

Patterns at the Right Level

The classic Design Patterns book by the Gang of Four catalogued 23 patterns in 1994. They're well-documented and widely taught. They're also frequently applied at the wrong level of abstraction — used as implementation tricks rather than as architectural tools.

The patterns that matter most to an architect are the ones that solve structural problems: how do you compose behavior without coupling implementations? How do you coordinate distributed transactions without a 2-phase commit? How do you ensure that database writes and event publishing don't diverge? These are architectural problems, and the patterns that address them operate at a different scale than "how do I avoid if-else chains."

Here's a practitioner's view of the patterns I reach for most often as an architect.


Strategy Pattern: Composing Variable Behavior

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. At the code level, this is a pattern for avoiding switch statements and conditional chains. At the architectural level, it's a tool for making systems extensible without modification.

The architectural application: when you need to support multiple variants of a behavior that share the same interface but different implementations — payment processors, notification channels, data export formats, authentication providers — the strategy pattern lets you add new variants without touching existing code.

interface PaymentStrategy {
  charge(amount: Money, customer: Customer): Promise<ChargeResult>
  refund(transactionId: string, amount: Money): Promise<RefundResult>
}

class StripePaymentStrategy implements PaymentStrategy { /* ... */ }
class PayPalPaymentStrategy implements PaymentStrategy { /* ... */ }
class ACHPaymentStrategy implements PaymentStrategy { /* ... */ }

class PaymentService {
  constructor(private readonly strategy: PaymentStrategy) {}

  async processPayment(order: Order): Promise<PaymentResult> {
    return this.strategy.charge(order.total, order.customer)
  }
}

The PaymentService doesn't know about Stripe or PayPal. Adding a new payment processor requires implementing the PaymentStrategy interface — no changes to existing code. This is the Open/Closed Principle made concrete.

Architecturally, Strategy is how you keep core business logic stable while allowing integration points to vary. Every external service your domain interacts with is a candidate for a strategy interface.


Factory Pattern: Managing Object Creation Complexity

The Factory pattern centralizes object creation logic, hiding the complexity of instantiation from the calling code. At the architectural level, it's how you manage the creation of complex objects whose construction requires decisions based on runtime conditions.

The architectural application: when the "right" implementation to create depends on context — configuration, environment, request parameters — a factory centralizes that decision rather than scattering if (env === 'production') { ... } conditionals throughout the codebase.

class StorageAdapterFactory {
  create(config: StorageConfig): StorageAdapter {
    switch (config.provider) {
      case 's3': return new S3StorageAdapter(config.s3)
      case 'gcs': return new GCSStorageAdapter(config.gcs)
      case 'azure-blob': return new AzureBlobStorageAdapter(config.azure)
      default: throw new Error(`Unknown storage provider: ${config.provider}`)
    }
  }
}

The factory is the one place that knows about all the concrete implementations. Every other part of the system works against the StorageAdapter interface. Switching storage providers is a configuration change, not a code change.


Observer Pattern: Decoupled Event Handling

The Observer pattern lets objects subscribe to events published by another object without the publisher knowing about the subscribers. At the architectural level, this is the foundation of event-driven design within a single bounded context.

The architectural application: domain events. When an Order is placed, multiple things might need to happen — inventory reservation, notification sending, analytics tracking, fraud checking. If the Order aggregate directly calls each of these, it becomes coupled to every consumer. Observer (via domain events) lets the aggregate publish OrderPlaced and delegate the reaction to whoever cares.

class Order {
  private events: DomainEvent[] = []

  place(): void {
    // ... business logic
    this.events.push(new OrderPlaced(this.id, this.customerId, this.items))
  }

  pullEvents(): DomainEvent[] {
    const events = [...this.events]
    this.events = []
    return events
  }
}

// In the application layer after saving the order:
const events = order.pullEvents()
for (const event of events) {
  await this.eventBus.publish(event)
}

The Order aggregate is decoupled from every downstream effect. Adding a new consumer (a FraudDetectionService that reacts to OrderPlaced) requires no changes to the Order aggregate.


Repository Pattern: Abstracting Data Access

The Repository pattern provides a collection-like interface for accessing domain objects, hiding the persistence implementation from the domain and application layers.

This is architecturally significant because it's one of the key enablers of clean and hexagonal architecture. The repository interface is defined in terms of domain concepts, not database concepts:

interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>
  findByCustomer(customerId: CustomerId): Promise<Order[]>
  findPendingOlderThan(date: Date): Promise<Order[]>
  save(order: Order): Promise<void>
  delete(order: Order): Promise<void>
}

The domain defines what it needs. The infrastructure implements it. The domain never calls Prisma, or ActiveRecord, or raw SQL. It calls orderRepo.findPendingOlderThan(date) and works with the result.

The architectural benefit: swap Prisma for Drizzle, or PostgreSQL for DynamoDB — the domain code doesn't change. The repository adapter changes; the domain is untouched.


Saga Pattern: Coordinating Distributed Transactions

The Saga pattern manages long-running transactions across multiple services in a distributed system. When a single database transaction can't span service boundaries, a saga breaks the transaction into a sequence of local transactions, each publishing an event or message that triggers the next step.

There are two saga implementations:

Choreography-based Saga: Each service reacts to events and publishes events to trigger the next step. No central coordinator.

OrderService publishes OrderCreated
→ InventoryService reacts, reserves stock, publishes InventoryReserved
→ PaymentService reacts, charges customer, publishes PaymentProcessed
→ OrderService reacts, marks order as confirmed

Failure handling requires compensating transactions: if payment fails after inventory is reserved, publish InventoryReservationCancelled to trigger the release.

Orchestration-based Saga: A central orchestrator sends commands to each service and handles responses.

OrderSaga sends ReserveInventory to InventoryService
→ InventoryService responds with InventoryReserved
→ OrderSaga sends ChargeCustomer to PaymentService
→ PaymentService responds with PaymentFailed
→ OrderSaga sends ReleaseInventory to InventoryService (compensating transaction)

Orchestration is easier to understand and debug but creates a central coordination dependency. Choreography is more resilient but harder to trace. Choose based on how complex the failure handling is and how important visibility into saga state is.

The Saga pattern is essential when you have multi-step business processes that span services and need to handle partial failures gracefully.


Outbox Pattern: Reliable Event Publishing

The Outbox pattern solves a specific but critical problem: how do you atomically update a database and publish an event to a message broker?

If you write to the database and then publish to Kafka, what happens when the broker is unavailable? The database write succeeds but the event is never published. Downstream consumers miss the event. State diverges.

The Outbox pattern writes the event to an outbox table in the same database transaction as the state change:

BEGIN TRANSACTION;
  UPDATE orders SET status = 'confirmed' WHERE id = $1;
  INSERT INTO outbox (event_type, payload)
    VALUES ('OrderConfirmed', '{"orderId": "..."}');
COMMIT;

A separate process (the outbox relay) polls the outbox table, publishes the events to the message broker, and marks them as published. The database transaction guarantees that the state change and the outbox entry either both succeed or both fail — the event publication is eventually guaranteed.

This pattern is foundational for event-driven microservices that need to reliably publish events without distributed transaction coordination.


Combining Patterns at the Architectural Level

The real power of these patterns emerges when they're composed. A typical order processing flow might combine:

  • Repository to abstract data access
  • Factory to instantiate the right payment strategy
  • Strategy to execute the payment through the appropriate provider
  • Observer (domain events) to decouple order confirmation from downstream effects
  • Outbox to reliably publish domain events to the message broker
  • Saga to coordinate the multi-service fulfillment flow

Each pattern addresses a specific structural concern. Together they produce a system where business logic is clear and isolated, infrastructure is pluggable, and distributed workflows are managed reliably.


Patterns are not solutions you reach for to signal sophistication. They're tools you reach for when the problem they solve is the problem you have. The architect's job is to recognize when a pattern fits, apply it at the right level, and have the judgment to leave it out when it doesn't add value.


If you're designing a system and want to think through which patterns apply to your specific architectural challenges, let's have that conversation.


Keep Reading