Design Patterns You Will Actually Encounter: Factory, Strategy, Observer


Design patterns are solutions to recurring problems. The GoF book catalogued 23 of them. Most engineers encounter maybe six regularly. This article covers three of the most common - not through UML diagrams and abstract definitions, but through the concrete problems they solve and the code they produce.

Factory - when creation is the problem

The problem: you need to create objects, but the exact type depends on runtime conditions. Scattering new ConcreteClass() throughout your codebase means every place that creates objects is coupled to the concrete type. Change the class name, change its constructor signature, switch to a different implementation - and you’re updating call sites everywhere.

The pattern: centralize creation in a factory. Everything that needs an object asks the factory. The factory decides what to instantiate.

interface Logger {
  log(message: string): void;
}

class ConsoleLogger implements Logger {
  log(message: string) {
    console.log(`[console] ${message}`);
  }
}

class FileLogger implements Logger {
  log(message: string) {
    fs.appendFileSync('app.log', `${message}\n`);
  }
}

class CloudLogger implements Logger {
  log(message: string) {
    cloudLoggingService.write(message);
  }
}

// Factory: creation is centralized
function createLogger(env: string): Logger {
  if (env === 'production') return new CloudLogger();
  if (env === 'test') return new ConsoleLogger();
  return new FileLogger();
}

// Call sites don't know or care which Logger they get
const logger = createLogger(process.env.NODE_ENV ?? 'development');
logger.log('Application started');

The call site depends on Logger, not on CloudLogger or FileLogger. Adding a new logger type means adding a class and updating the factory - nothing else changes.

Where you’ve seen this: Express middleware factories, database connection pools, any SDK that takes a { type: 'aws' | 'gcp' } config and returns the appropriate client. React’s createElement is a factory. Most dependency injection frameworks use factories internally.

The variant you’ll also see is the abstract factory - a factory that produces families of related objects (e.g., a UI factory that creates platform-specific Button and Input components). The principle is identical; the scope is larger.

Strategy - when the algorithm is the variable

The problem: you have an operation that can be performed in multiple ways, and which way depends on context. The naive approach is a chain of conditionals - if strategy === 'fast' ... else if strategy === 'thorough' .... This works until the number of strategies grows, or until you want to add a new one without touching the existing code.

The pattern: define the algorithm as an interface. Each variant is a class that implements it. The context that needs the algorithm takes one as a parameter.

interface SortStrategy<T> {
  sort(data: T[]): T[];
}

class QuickSort<T> implements SortStrategy<T> {
  sort(data: T[]): T[] {
    if (data.length <= 1) return data;
    const pivot = data[Math.floor(data.length / 2)];
    const left  = data.filter(x => x < pivot);
    const mid   = data.filter(x => x === pivot);
    const right = data.filter(x => x > pivot);
    return [...this.sort(left), ...mid, ...this.sort(right)];
  }
}

class InsertionSort<T> implements SortStrategy<T> {
  sort(data: T[]): T[] {
    const arr = [...data];
    for (let i = 1; i < arr.length; i++) {
      const key = arr[i];
      let j = i - 1;
      while (j >= 0 && arr[j] > key) { arr[j + 1] = arr[j]; j--; }
      arr[j + 1] = key;
    }
    return arr;
  }
}

class DataProcessor<T> {
  constructor(private strategy: SortStrategy<T>) {}

  process(data: T[]): T[] {
    return this.strategy.sort(data);
  }

  setStrategy(strategy: SortStrategy<T>) {
    this.strategy = strategy;
  }
}

// Small arrays: insertion sort wins. Large arrays: quicksort.
const processor = new DataProcessor(new InsertionSort<number>());
processor.process([3, 1, 4, 1, 5]);

processor.setStrategy(new QuickSort<number>());
processor.process(largeDataset);

Where you’ve seen this: Passport.js authentication strategies (passport-local, passport-google-oauth). Webpack loaders - each is a strategy for transforming a specific file type. Payment processors in e-commerce. Any code that takes a compareFn argument is using a function-based variant of Strategy.

The TypeScript/JavaScript world often implements Strategy with plain functions rather than classes. Passing a callback or a configuration object with methods is Strategy without the ceremony.

Observer - when things need to react to events

The problem: something happens in your system, and multiple unrelated parts of the system need to respond. The straightforward approach is to call each of them directly - but now the thing that fired the event is coupled to every subscriber. Every new subscriber requires modifying the event source.

The pattern: the source (subject) maintains a list of observers and notifies them when something happens. Observers register themselves. The subject doesn’t know or care who’s listening.

interface Observer<T> {
  update(event: T): void;
}

class EventEmitter<T> {
  private observers: Observer<T>[] = [];

  subscribe(observer: Observer<T>) {
    this.observers.push(observer);
  }

  unsubscribe(observer: Observer<T>) {
    this.observers = this.observers.filter(o => o !== observer);
  }

  protected notify(event: T) {
    for (const observer of this.observers) {
      observer.update(event);
    }
  }
}

interface OrderEvent {
  orderId: string;
  customerId: string;
  total: number;
}

class OrderService extends EventEmitter<OrderEvent> {
  placeOrder(order: OrderEvent) {
    // ... persist the order
    this.notify(order); // tell everyone
  }
}

class EmailNotifier implements Observer<OrderEvent> {
  update(event: OrderEvent) {
    mailer.send({ to: event.customerId, subject: `Order ${event.orderId} confirmed` });
  }
}

class InventoryUpdater implements Observer<OrderEvent> {
  update(event: OrderEvent) {
    inventory.reserve(event.orderId);
  }
}

class AnalyticsTracker implements Observer<OrderEvent> {
  update(event: OrderEvent) {
    analytics.track('order_placed', { value: event.total });
  }
}

const orderService = new OrderService();
orderService.subscribe(new EmailNotifier());
orderService.subscribe(new InventoryUpdater());
orderService.subscribe(new AnalyticsTracker());

OrderService knows nothing about email, inventory, or analytics. Each subscriber knows nothing about the others. Adding a new reaction to an order being placed means adding a class and one subscribe call.

Where you’ve seen this: Node’s EventEmitter is this pattern. DOM addEventListener. RxJS observables. React’s useEffect reacting to state changes is Observer with a reactive flavor. Redux’s store notifying components on state change. Message queues at a distributed level are Observer across process boundaries.


What these three patterns have in common: they move the decision about which implementation to use out of the code that does the work. Factory moves creation decisions to one place. Strategy makes the algorithm itself swappable. Observer decouples “something happened” from “what to do about it.”

You’ll recognize each of them once you’ve seen the violation a few times - the long constructor chain, the growing conditional block, the class that directly calls five unrelated things. The pattern is what you reach for when the smell is there and you know what direction to walk.

Part 2 covers Decorator, Adapter, and Command.