Back to Insights
design patternsJuly 25, 20268 min read

How to Choose a Design Pattern by the Problem, Not the Name

A decision-first method for selecting patterns from concrete forces—variation, coordination, lifecycle, failure, and scale—without turning pattern vocabulary into architecture theater.

John Mather, Founder of Prime Axiom AI
John Mather

Founder, Prime Axiom AI

Published July 25, 2026

Decision map connecting software problems to candidate design patterns
Patterns are selected by forces and consequences, not vocabulary.

How to Choose a Design Pattern by the Problem, Not the Name

Pattern discussions often begin too late. Someone says “we need a Factory,” “this should be CQRS,” or “let’s use Observer,” and the team debates implementations before agreeing on the problem. A design pattern is a named response to recurring forces. Without the forces, the name is decoration.

This article is a decision guide. It does not march through a pattern catalog. Instead, it starts with questions, uses a worked notification scenario, compares direct code with candidate structures, and ends with removal criteria. Microsoft's cloud design patterns catalog and AWS's cloud design patterns guidance are useful references after a problem has been characterized.

The one-page problem statement

Before naming a pattern, write:

  • Outcome: what behavior must users or operators observe?
  • Variation: what changes independently?
  • Constraints: latency, consistency, throughput, security, deployment, and team ownership.
  • Failure: what can partially succeed, and what recovery is acceptable?
  • Evidence: current incidents, repeated changes, measured load, or committed roadmap.
  • Simplest option: what would direct code look like?
If the problem statement contains no recurring variation, coordination difficulty, or lifecycle complexity, a pattern may add more cost than value.

Our scenario is an order service that sends transactional notifications. Today it sends email after order acceptance. Soon it must add SMS for urgent failures, suppress duplicates, respect per-channel preferences, and keep order placement responsive when providers are slow. Notifications can arrive eventually, but accepted orders must not lose the intent to notify.

First candidate: direct code

ts
async function acceptOrder(command: AcceptOrder): Promise<void> {
  const order = Order.accept(command);
  await orders.save(order);
  await email.send(order.customerEmail, renderConfirmation(order));
}

This block demonstrates the baseline. It is understandable and may be correct for a low-risk internal tool. Its weakness is not that it lacks a pattern name. Persistence and remote delivery form an unreliable sequence: the order can commit and email can fail, or email can succeed while a database retry duplicates work.

We can now state forces rather than preferences:

  1. Saving an accepted order and recording notification intent must be atomic.
  2. Provider I/O should not extend the request's transaction.
  3. Delivery is at least once, so handlers must be idempotent.
  4. Channel selection varies by notification kind and customer preference.
  5. Operators need retry and dead-letter visibility.

Map forces to pattern families

Dominant problemCandidate responseWarning sign
Select one interchangeable algorithmStrategybehavior does not truly vary
Create families with complex setupFactoryconstruction is already one line
Add behavior around a stable contractDecoratororder of wrappers becomes hidden
Notify in-process observersObserverdelivery must survive process failure
Persist data and message intent atomicallyTransactional outboxdatabase polling load is unacceptable
Coordinate a long multi-step business flowSaga/process managera local transaction is sufficient
Separate read and write modelsCQRSsame model serves both clearly
Protect remote calls from repeated failureCircuit breakerfailures are not transient or fallback is unsafe

The table demonstrates problem-to-pattern matching. Several candidates may cooperate, but each must pay for a specific force. In our scenario, an outbox addresses atomic intent, Strategy can address channel policy, and a retry policy may protect providers. Observer alone does not survive a crash.

Decision path: durable notification intent

ts
async function acceptOrder(command: AcceptOrder): Promise<void> {
  await database.transaction(async (tx) => {
    const order = Order.accept(command);
    await tx.orders.save(order);
    await tx.outbox.append({
      id: crypto.randomUUID(),
      type: 'OrderAccepted',
      aggregateId: order.id,
      occurredAt: clock.now().toISOString(),
      payload: { customerId: order.customerId },
    });
  });
}

This code demonstrates the Transactional Outbox pattern: business state and event intent commit in one local database transaction. A relay later publishes unpublished rows. It does not guarantee exactly-once delivery; consumers still handle duplicates. It also introduces table growth, polling or change-data-capture operations, and monitoring.

mermaid
flowchart LR
  API --> TX[(Order + Outbox Transaction)]
  TX --> Relay
  Relay --> Broker
  Broker --> NotificationWorker
  NotificationWorker --> Email
  NotificationWorker --> SMS

The diagram demonstrates the failure boundary. Provider latency is outside order acceptance. If publication fails, the row remains for retry. If publication succeeds but marking the row fails, a duplicate can occur, so event identity matters.

Would a Saga help? Not yet. Accepting the order and recording intent fit one transaction. A saga becomes relevant if the business process coordinates inventory reservation, payment authorization, shipment booking, and compensations across independently owned services. Choosing it now would introduce durable process state with no force to justify it.

Decision path: channel selection

A switch may be the smallest correct solution:

ts
function channelsFor(event: NotificationEvent, preferences: Preferences): Channel[] {
  if (event.type === 'PaymentFailed') {
    return preferences.sms ? ['sms', 'email'] : ['email'];
  }
  return preferences.email ? ['email'] : [];
}

This block demonstrates direct policy. It is localized, exhaustive, and easy to test. Do not replace it merely because Strategy exists.

Strategy becomes useful when policies are independently deployed, selected by tenant, or substantially different:

ts
interface ChannelPolicy {
  select(event: NotificationEvent, preferences: Preferences): readonly Channel[];
}

class RegulatedTenantChannelPolicy implements ChannelPolicy {
  select(event: NotificationEvent, preferences: Preferences): readonly Channel[] {
    if (event.type === 'LegalNotice') return ['registered_mail'];
    return preferences.email ? ['email'] : [];
  }
}

This code demonstrates a variation boundary. The interface pays for tenant-specific policy; it is not a generic “pattern application.” A composition root maps tenant configuration to a policy. Contract tests ensure every policy returns supported channels and never suppresses mandatory notices.

Score candidates by consequences

For a consequential choice, use a lightweight scorecard from 1 (poor) to 5 (strong):

CriterionDirect synchronous sendOutbox + worker
Request simplicity52
Survives process crash15
Provider isolation15
Operational burden52
Duplicate handling needed42
Fits stated reliability15

The table demonstrates explicit trade-offs. Numbers are relative judgments, not objective measurements. Document assumptions beside them. If email is optional and loss is acceptable, direct sending may win. In this scenario durable intent is required, so reliability outweighs operational simplicity.

Validate with a thin experiment

Patterns often fail on operational details rather than class diagrams. Build a vertical slice. Write one outbox row, relay it, crash after publish but before acknowledgement, and show that the consumer deduplicates by event ID:

ts
async function handle(event: OrderAcceptedEvent): Promise<void> {
  await database.transaction(async (tx) => {
    if (await tx.processedEvents.exists(event.id)) return;
    await sendPlannedNotifications(event);
    await tx.processedEvents.add(event.id);
  });
}

This block demonstrates inbox-style deduplication, but it also exposes a second dual-write: the external provider send and processed marker are not atomic. Provider idempotency keys can close that gap when supported. Otherwise define acceptable duplicate behavior and reconciliation.

Load-test relay queries, observe lock behavior, and define retention. Inject provider timeouts and malformed responses. Pattern selection is incomplete until the team can operate its failure modes.

Pattern combinations and interference

Patterns do not compose automatically. A Retry decorator around a non-idempotent Command can multiply side effects. A Cache-Aside layer in front of a strongly consistent read can violate freshness assumptions. Observer plus asynchronous dispatch changes event ordering and error propagation. CQRS plus event sourcing is common but neither requires the other.

Represent the stack in execution order:

request deadline -> bulkhead -> circuit breaker -> bounded retry -> provider adapter

This ASCII diagram demonstrates that wrapper order changes semantics. Retrying outside a circuit breaker records failures differently from retrying inside it. The architecture decision should state the chosen order and why.

Ask how the pattern leaves

Every pattern should have a removal condition. A Factory can collapse to direct construction when only one implementation remains. A Strategy can return to a function when variation disappears. An outbox can be removed if notification loss becomes acceptable or the owning platform supplies equivalent transactional messaging. A cache should have an owner and evidence that latency or load still requires it.

Write an architecture decision record with:

  • context and forces;
  • direct alternative;
  • selected pattern and rejected candidates;
  • expected operational costs;
  • validation plan;
  • review date or removal trigger.

This checklist demonstrates that architecture is a reversible decision process, not a collection of permanent nouns.

Common pattern-selection failures

Vocabulary matching: a requirement says “notify,” so Observer is chosen without examining durability. Words are clues, not decisions.

Resume-driven design: the system gains Event Sourcing or CQRS because the technology is attractive. The team inherits projections, replay, schema evolution, and debugging costs without a matching business need.

Premature generality: a plug-in framework is built for two stable cases. Direct conditionals would make supported behavior easier to discover.

Ignoring platform fit: a pattern that assumes transactions, ordered streams, or compare-and-swap may conflict with actual infrastructure.

Stopping at code shape: the implementation resembles a diagram, but no one defines retries, metrics, ownership, or disaster recovery.

Review checklist

  • [ ] Is the problem written without a pattern name?
  • [ ] Is direct code inadequate for a demonstrated reason?
  • [ ] Does each selected pattern address one named force?
  • [ ] Are consistency, ordering, and failure semantics explicit?
  • [ ] Has a thin slice tested the riskiest operational assumption?
  • [ ] Can engineers trace execution through combined patterns?
  • [ ] Is there a removal or simplification condition?

Choose consequences, not prestige

In the notification case, the outbox was selected because durable intent and request isolation were required. Strategy was introduced only at the tenant-policy boundary. Saga and CQRS were rejected because their forces were absent. This is a stronger design argument than “we use enterprise patterns.”

Pattern literacy is valuable because it provides tested options and a vocabulary for consequences. Pattern restraint is equally valuable. Start from observable outcomes and failure modes, compare against direct code, and validate with production-shaped experiments. For adjacent guidance, read Event-Driven Architecture, Microservices Trade-Offs, and /resources.

Major references

Apply the idea

Turn technical clarity into a working system.

Talk with Prime Axiom about architecture, AI automation, integrations, and the operational workflow behind your next build.

Continue reading