Lesson 10

Design Patterns

Proven solutions to recurring software design problems

Factory Builder Adapter Decorator Facade Strategy Observer Repository

What Are Design Patterns?

Design patterns are reusable solutions to problems that occur over and over in software design. They are not code you copy-paste — they are templates for solving a category of problem.

💡 When to Apply a Pattern

Pattern Categories

🏗️
Creational Factory Method, Builder
🧱
Structural Adapter, Decorator, Facade
🎭
Behavioral Strategy, Observer, Repository

Creational Patterns

Factory Method

Problem: You have a growing if/switch chain to create different objects based on a type.

Solution: Replace conditional creation with a factory that maps types to constructors.

❌ Problem — Growing Switch

function createNotification(type, message) {
  if (type === 'email') return new EmailNotification(message);
  if (type === 'sms') return new SmsNotification(message);
  if (type === 'push') return new PushNotification(message);
  // Every new channel = modify this function
}

✅ Solution — Factory Pattern

class NotificationFactory {
  #creators = new Map();

  register(type, creator) {
    this.#creators.set(type, creator);
  }

  create(type, message) {
    const creator = this.#creators.get(type);
    if (!creator) throw new Error(`Unknown: ${type}`);
    return creator(message);
  }
}

// Registration — open for extension
const factory = new NotificationFactory();
factory.register('email', msg => new EmailNotification(msg));
factory.register('sms', msg => new SmsNotification(msg));
factory.register('push', msg => new PushNotification(msg));

// Adding Slack = just register, no modification:
factory.register('slack', msg => new SlackNotification(msg));

When to use: Object creation varies by type, and new types are added over time.

Builder

Problem: Constructing complex objects with many optional parameters leads to unreadable constructor calls or telescoping constructors.

Solution: A step-by-step builder with a fluent API.

✅ Solution — Builder Pattern

class QueryBuilder {
  #table = '';
  #conditions = [];
  #orderBy = null;
  #limit = null;

  from(table)       { this.#table = table; return this; }
  where(condition)   { this.#conditions.push(condition); return this; }
  orderBy(field)     { this.#orderBy = field; return this; }
  limit(n)           { this.#limit = n; return this; }

  build() {
    let sql = `SELECT * FROM ${this.#table}`;
    if (this.#conditions.length)
      sql += ` WHERE ${this.#conditions.join(' AND ')}`;
    if (this.#orderBy) sql += ` ORDER BY ${this.#orderBy}`;
    if (this.#limit) sql += ` LIMIT ${this.#limit}`;
    return sql;
  }
}

// Readable, composable, self-documenting:
const query = new QueryBuilder()
  .from('users')
  .where('status = "active"')
  .where('age >= 18')
  .orderBy('created_at DESC')
  .limit(10)
  .build();

When to use: Object construction has many optional parameters or requires step-by-step assembly.

Structural Patterns

Adapter

Problem: You need to use a class/library, but its interface does not match what your code expects.

Solution: Wrap it in an adapter that translates between the two interfaces.

✅ Solution — Adapter Pattern

// Your code expects this interface:
// { send(to, subject, body) }

// But the third-party library has:
// mailgun.messages().send({ from, to, subject, text })

class MailgunAdapter {
  constructor(mailgunClient, fromAddress) {
    this.client = mailgunClient;
    this.from = fromAddress;
  }

  send(to, subject, body) {
    return this.client.messages().send({
      from: this.from,
      to: to,
      subject: subject,
      text: body,
    });
  }
}

// Now it fits your interface:
const mailer = new MailgunAdapter(mailgun, 'noreply@app.com');
mailer.send('user@example.com', 'Welcome!', 'Hello...');

When to use: Integrating third-party libraries or legacy code with incompatible interfaces.

Decorator

Problem: You need to add behavior to objects dynamically without modifying their class.

Solution: Wrap the object in a decorator that adds behavior before/after delegating to the original.

✅ Solution — Decorator Pattern

// Base logger
class ConsoleLogger {
  log(message) { console.log(message); }
}

// Decorator: add timestamps
class TimestampLogger {
  constructor(logger) { this.inner = logger; }
  log(message) {
    const ts = new Date().toISOString();
    this.inner.log(`[${ts}] ${message}`);
  }
}

// Decorator: add log level prefix
class LevelLogger {
  constructor(logger, level) { this.inner = logger; this.level = level; }
  log(message) {
    this.inner.log(`${this.level}: ${message}`);
  }
}

// Compose decorators:
const logger = new TimestampLogger(
  new LevelLogger(new ConsoleLogger(), 'INFO')
);
logger.log('Server started');
// Output: [2025-01-15T10:30:00Z] INFO: Server started

When to use: Adding cross-cutting concerns (logging, caching, retries, auth) without modifying core logic.

Facade

Problem: A subsystem is complex with many interacting classes. Callers need a simple entry point.

Solution: A single facade class that orchestrates the subsystem behind a clean API.

✅ Solution — Facade Pattern

// Complex subsystem: validator, repository, emailer, logger
class OrderFacade {
  constructor(validator, repo, emailer, logger) {
    this.validator = validator;
    this.repo = repo;
    this.emailer = emailer;
    this.logger = logger;
  }

  async placeOrder(orderData) {
    this.validator.validate(orderData);
    const order = await this.repo.save(orderData);
    await this.emailer.sendConfirmation(order);
    this.logger.info('Order placed', { orderId: order.id });
    return order;
  }
}

// Callers see one simple method:
await orderFacade.placeOrder(data);

When to use: Simplifying access to a complex subsystem with many dependencies.

Behavioral Patterns

Strategy

Problem: An algorithm varies by context, leading to if/switch on type with different behavior in each branch.

Solution: Encapsulate each algorithm as a strategy object and swap them at runtime.

✅ Solution — Strategy Pattern

// Define strategies
const sortStrategies = {
  price:      items => [...items].sort((a, b) => a.price - b.price),
  rating:     items => [...items].sort((a, b) => b.rating - a.rating),
  newest:     items => [...items].sort((a, b) => b.createdAt - a.createdAt),
  popularity: items => [...items].sort((a, b) => b.sales - a.sales),
};

// Use the strategy
function sortProducts(items, strategyName) {
  const strategy = sortStrategies[strategyName];
  if (!strategy) throw new Error(`Unknown sort: ${strategyName}`);
  return strategy(items);
}

// Adding a new sort = add one entry. No modification needed.
sortStrategies.alphabetical = items =>
  [...items].sort((a, b) => a.name.localeCompare(b.name));

When to use: Replacing type-based if/switch chains with polymorphic behavior.

Observer

Problem: Multiple parts of your system need to react when something happens, but you do not want tight coupling between them.

Solution: An event emitter/subscriber model where publishers do not know about subscribers.

✅ Solution — Observer Pattern

class EventBus {
  #listeners = new Map();

  on(event, callback) {
    if (!this.#listeners.has(event))
      this.#listeners.set(event, []);
    this.#listeners.get(event).push(callback);
  }

  emit(event, data) {
    const callbacks = this.#listeners.get(event) || [];
    callbacks.forEach(cb => cb(data));
  }
}

// Usage — decoupled communication:
const bus = new EventBus();

bus.on('order:placed', order => sendConfirmationEmail(order));
bus.on('order:placed', order => updateInventory(order));
bus.on('order:placed', order => notifyWarehouse(order));

// Publisher doesn't know who is listening:
bus.emit('order:placed', { id: 'ORD-1', items: [...] });

When to use: Event-driven systems, UI updates, or decoupling side effects from core logic.

Repository

Problem: Data access logic is scattered throughout the application, mixing SQL/ORM calls with business logic.

Solution: Centralize data access behind a repository interface, hiding the storage mechanism.

✅ Solution — Repository Pattern

class UserRepository {
  constructor(db) { this.db = db; }

  async findById(id) {
    return this.db.query('SELECT * FROM users WHERE id = ?', [id]);
  }

  async findByEmail(email) {
    return this.db.query('SELECT * FROM users WHERE email = ?', [email]);
  }

  async save(user) {
    if (user.id) {
      return this.db.query('UPDATE users SET ... WHERE id = ?', [user.id]);
    }
    return this.db.query('INSERT INTO users ...', [user]);
  }

  async delete(id) {
    return this.db.query('DELETE FROM users WHERE id = ?', [id]);
  }
}

// Business logic never touches SQL:
const user = await userRepo.findByEmail('alice@example.com');

When to use: Any application with data persistence. Keeps business logic clean and storage swappable.

Anti-Patterns to Avoid

Anti-PatternSymptomFix
God Class One class does everything, 1000+ lines, touches every part of the system Apply SRP. Extract focused classes with single responsibilities.
Anemic Domain Model Classes are just data bags with getters/setters. All logic lives in service classes. Move behavior into domain objects. order.calculateTotal() not service.calculateTotal(order)
Feature Envy A method uses more data from another class than its own. It "envies" the other class. Move the method to the class whose data it uses most.
🚫 Pattern Overuse

The biggest mistake is applying patterns where they are not needed. A 50-line script does not need a Factory, Strategy, and Observer. Patterns add indirection — only add indirection when it pays for itself in flexibility or clarity.

✅ The Right Mindset

Start simple. When you notice a recurring pain (growing switch statements, tight coupling, scattered data access), reach for the pattern that solves that specific pain. Patterns are medicine, not vitamins — take them when sick, not prophylactically.

🧠 Knowledge Check

Which pattern replaces if/switch on type with polymorphic behavior?