TypeScript Blitz Day 8: Design Patterns

Master 10 essential design patterns in TypeScript - Singleton, Factory, Observer, Strategy, Decorator, Builder, Adapter, Repository, Facade, and Dependency Injection for writing maintainable, scalable code.

29 min read

Goal

After this lesson you’ll implement common design patterns in TypeScript for writing maintainable, scalable code.


Theory Part (20 min)

1. Singleton Pattern - One Instance Only

// Singleton ensures only one instance exists
class Database {
  private static instance: Database;
  private connected: boolean = false;
  
  // Private constructor prevents direct instantiation
  private constructor() {
    console.log("Database instance created");
  }
  
  // Static method to get the single instance
  public static getInstance(): Database {
    if (!Database.instance) {
      Database.instance = new Database();
    }
    return Database.instance;
  }
  
  public connect(): void {
    if (!this.connected) {
      console.log("Connecting to database...");
      this.connected = true;
    }
  }
  
  public query(sql: string): void {
    if (this.connected) {
      console.log(`Executing: ${sql}`);
    }
  }
}

// Usage
const db1 = Database.getInstance();
const db2 = Database.getInstance();

console.log(db1 === db2); // true - same instance

db1.connect();
db1.query("SELECT * FROM users");

// const db3 = new Database(); // ❌ Error - constructor is private

When to use:

  • Database connections
  • Configuration managers
  • Logging services
  • Cache managers
  • Thread pools

Pros: Global access, lazy initialization, guaranteed single instance Cons: Difficult to test, violates Single Responsibility Principle, tight coupling


2. Factory Pattern - Object Creation

// Factory pattern centralizes object creation
interface Vehicle {
  drive(): void;
  fuel(): string;
}

class Car implements Vehicle {
  drive(): void {
    console.log("Driving a car");
  }
  
  fuel(): string {
    return "Gasoline";
  }
}

class Truck implements Vehicle {
  drive(): void {
    console.log("Driving a truck");
  }
  
  fuel(): string {
    return "Diesel";
  }
}

class Motorcycle implements Vehicle {
  drive(): void {
    console.log("Riding a motorcycle");
  }
  
  fuel(): string {
    return "Gasoline";
  }
}

// Factory class
class VehicleFactory {
  static createVehicle(type: "car" | "truck" | "motorcycle"): Vehicle {
    switch (type) {
      case "car":
        return new Car();
      case "truck":
        return new Truck();
      case "motorcycle":
        return new Motorcycle();
      default:
        throw new Error("Unknown vehicle type");
    }
  }
}

// Usage
const car = VehicleFactory.createVehicle("car");
car.drive(); // "Driving a car"

const truck = VehicleFactory.createVehicle("truck");
console.log(truck.fuel()); // "Diesel"

// Client doesn't need to know about concrete classes
function processVehicle(type: "car" | "truck" | "motorcycle"): void {
  const vehicle = VehicleFactory.createVehicle(type);
  vehicle.drive();
}

When to use:

  • Object creation is complex
  • Client shouldn’t know about concrete classes
  • Need to centralize object creation logic
  • Want to add new types without changing client code

Abstract Factory Pattern:

// Creates families of related objects
interface GUIFactory {
  createButton(): Button;
  createCheckbox(): Checkbox;
}

interface Button {
  render(): void;
}

interface Checkbox {
  render(): void;
}

class WindowsButton implements Button {
  render(): void {
    console.log("Rendering Windows button");
  }
}

class WindowsCheckbox implements Checkbox {
  render(): void {
    console.log("Rendering Windows checkbox");
  }
}

class MacButton implements Button {
  render(): void {
    console.log("Rendering Mac button");
  }
}

class MacCheckbox implements Checkbox {
  render(): void {
    console.log("Rendering Mac checkbox");
  }
}

class WindowsFactory implements GUIFactory {
  createButton(): Button {
    return new WindowsButton();
  }
  
  createCheckbox(): Checkbox {
    return new WindowsCheckbox();
  }
}

class MacFactory implements GUIFactory {
  createButton(): Button {
    return new MacButton();
  }
  
  createCheckbox(): Checkbox {
    return new MacCheckbox();
  }
}

// Usage
function renderUI(factory: GUIFactory): void {
  const button = factory.createButton();
  const checkbox = factory.createCheckbox();
  
  button.render();
  checkbox.render();
}

renderUI(new WindowsFactory());
renderUI(new MacFactory());

3. Observer Pattern - Event System

// Observer pattern for pub-sub functionality
interface Observer {
  update(data: any): void;
}

interface Subject {
  attach(observer: Observer): void;
  detach(observer: Observer): void;
  notify(data: any): void;
}

// Concrete Subject
class NewsAgency implements Subject {
  private observers: Observer[] = [];
  private latestNews: string = "";
  
  attach(observer: Observer): void {
    this.observers.push(observer);
  }
  
  detach(observer: Observer): void {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }
  
  notify(data: any): void {
    this.observers.forEach(observer => observer.update(data));
  }
  
  setNews(news: string): void {
    this.latestNews = news;
    this.notify({ news: this.latestNews, timestamp: new Date() });
  }
}

// Concrete Observers
class NewsChannel implements Observer {
  constructor(private name: string) {}
  
  update(data: any): void {
    console.log(`${this.name} received news: ${data.news}`);
  }
}

class NewsWebsite implements Observer {
  constructor(private url: string) {}
  
  update(data: any): void {
    console.log(`${this.url} publishing: ${data.news}`);
  }
}

// Usage
const agency = new NewsAgency();

const channel1 = new NewsChannel("CNN");
const channel2 = new NewsChannel("BBC");
const website = new NewsWebsite("news.com");

agency.attach(channel1);
agency.attach(channel2);
agency.attach(website);

agency.setNews("Breaking: TypeScript 5.0 released!");
// CNN received news: Breaking: TypeScript 5.0 released!
// BBC received news: Breaking: TypeScript 5.0 released!
// news.com publishing: Breaking: TypeScript 5.0 released!

agency.detach(channel2);
agency.setNews("Update: New features announced");
// Only CNN and website receive this

Type-safe Observer with generics:

interface TypedObserver<T> {
  update(data: T): void;
}

class TypedSubject<T> {
  private observers: TypedObserver<T>[] = [];
  
  attach(observer: TypedObserver<T>): void {
    this.observers.push(observer);
  }
  
  detach(observer: TypedObserver<T>): void {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }
  
  notify(data: T): void {
    this.observers.forEach(observer => observer.update(data));
  }
}

interface UserEvent {
  userId: string;
  action: "login" | "logout" | "update";
  timestamp: Date;
}

class UserEventLogger implements TypedObserver<UserEvent> {
  update(data: UserEvent): void {
    console.log(`[${data.timestamp}] User ${data.userId} ${data.action}`);
  }
}

4. Strategy Pattern - Algorithm Selection

// Strategy pattern allows selecting algorithm at runtime
interface PaymentStrategy {
  pay(amount: number): void;
}

class CreditCardPayment implements PaymentStrategy {
  constructor(
    private cardNumber: string,
    private cvv: string
  ) {}
  
  pay(amount: number): void {
    console.log(`Paid ${amount} using Credit Card ending in ${this.cardNumber.slice(-4)}`);
  }
}

class PayPalPayment implements PaymentStrategy {
  constructor(private email: string) {}
  
  pay(amount: number): void {
    console.log(`Paid ${amount} using PayPal account ${this.email}`);
  }
}

class CryptoPayment implements PaymentStrategy {
  constructor(private walletAddress: string) {}
  
  pay(amount: number): void {
    console.log(`Paid ${amount} using Crypto wallet ${this.walletAddress}`);
  }
}

// Context class
class ShoppingCart {
  private items: Array<{ name: string; price: number }> = [];
  private paymentStrategy?: PaymentStrategy;
  
  addItem(name: string, price: number): void {
    this.items.push({ name, price });
  }
  
  setPaymentStrategy(strategy: PaymentStrategy): void {
    this.paymentStrategy = strategy;
  }
  
  checkout(): void {
    const total = this.items.reduce((sum, item) => sum + item.price, 0);
    
    if (!this.paymentStrategy) {
      throw new Error("Payment strategy not set");
    }
    
    this.paymentStrategy.pay(total);
  }
}

// Usage
const cart = new ShoppingCart();
cart.addItem("Laptop", 999);
cart.addItem("Mouse", 25);

// Choose payment method at runtime
cart.setPaymentStrategy(new CreditCardPayment("1234567890123456", "123"));
cart.checkout(); // Paid 1024 using Credit Card

// Change strategy
cart.setPaymentStrategy(new PayPalPayment("user@example.com"));
cart.checkout(); // Paid 1024 using PayPal

When to use:

  • Multiple algorithms for same task
  • Avoid conditional statements (if/else, switch)
  • Runtime algorithm selection
  • Open/Closed Principle (open for extension, closed for modification)

5. Decorator Pattern - Add Functionality

// Decorator pattern adds behavior without modifying original class
interface Coffee {
  cost(): number;
  description(): string;
}

class SimpleCoffee implements Coffee {
  cost(): number {
    return 5;
  }
  
  description(): string {
    return "Simple coffee";
  }
}

// Decorator base class
abstract class CoffeeDecorator implements Coffee {
  constructor(protected coffee: Coffee) {}
  
  abstract cost(): number;
  abstract description(): string;
}

// Concrete decorators
class MilkDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 2;
  }
  
  description(): string {
    return this.coffee.description() + ", milk";
  }
}

class SugarDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 1;
  }
  
  description(): string {
    return this.coffee.description() + ", sugar";
  }
}

class WhipCreamDecorator extends CoffeeDecorator {
  cost(): number {
    return this.coffee.cost() + 3;
  }
  
  description(): string {
    return this.coffee.description() + ", whip cream";
  }
}

// Usage - wrap with multiple decorators
let coffee: Coffee = new SimpleCoffee();
console.log(`${coffee.description()}: $${coffee.cost()}`);
// Simple coffee: $5

coffee = new MilkDecorator(coffee);
console.log(`${coffee.description()}: $${coffee.cost()}`);
// Simple coffee, milk: $7

coffee = new SugarDecorator(coffee);
console.log(`${coffee.description()}: $${coffee.cost()}`);
// Simple coffee, milk, sugar: $8

coffee = new WhipCreamDecorator(coffee);
console.log(`${coffee.description()}: $${coffee.cost()}`);
// Simple coffee, milk, sugar, whip cream: $11

Function decorators (TypeScript decorators):

// Method decorator for logging
function log(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor
) {
  const originalMethod = descriptor.value;
  
  descriptor.value = function(...args: any[]) {
    console.log(`Calling ${propertyKey} with args:`, args);
    const result = originalMethod.apply(this, args);
    console.log(`Result:`, result);
    return result;
  };
  
  return descriptor;
}

class Calculator {
  @log
  add(a: number, b: number): number {
    return a + b;
  }
}

const calc = new Calculator();
calc.add(5, 3);
// Calling add with args: [5, 3]
// Result: 8

6. Builder Pattern - Complex Object Construction

// Builder pattern for constructing complex objects step by step
class User {
  constructor(
    public name: string,
    public email: string,
    public age?: number,
    public address?: string,
    public phone?: string,
    public role?: string
  ) {}
}

// Builder class
class UserBuilder {
  private name: string = "";
  private email: string = "";
  private age?: number;
  private address?: string;
  private phone?: string;
  private role?: string;
  
  setName(name: string): this {
    this.name = name;
    return this;
  }
  
  setEmail(email: string): this {
    this.email = email;
    return this;
  }
  
  setAge(age: number): this {
    this.age = age;
    return this;
  }
  
  setAddress(address: string): this {
    this.address = address;
    return this;
  }
  
  setPhone(phone: string): this {
    this.phone = phone;
    return this;
  }
  
  setRole(role: string): this {
    this.role = role;
    return this;
  }
  
  build(): User {
    if (!this.name || !this.email) {
      throw new Error("Name and email are required");
    }
    
    return new User(
      this.name,
      this.email,
      this.age,
      this.address,
      this.phone,
      this.role
    );
  }
}

// Usage - fluent interface
const user = new UserBuilder()
  .setName("John Doe")
  .setEmail("john@example.com")
  .setAge(30)
  .setAddress("123 Main St")
  .setRole("admin")
  .build();

// Can create different configurations easily
const simpleUser = new UserBuilder()
  .setName("Jane")
  .setEmail("jane@example.com")
  .build();

Generic Builder:

class QueryBuilder<T> {
  private query: string = "";
  
  select(...fields: (keyof T)[]): this {
    this.query += `SELECT ${fields.join(", ")} `;
    return this;
  }
  
  from(table: string): this {
    this.query += `FROM ${table} `;
    return this;
  }
  
  where(condition: string): this {
    this.query += `WHERE ${condition} `;
    return this;
  }
  
  orderBy(field: keyof T, direction: "ASC" | "DESC" = "ASC"): this {
    this.query += `ORDER BY ${String(field)} ${direction} `;
    return this;
  }
  
  build(): string {
    return this.query.trim();
  }
}

interface User {
  id: number;
  name: string;
  email: string;
}

const query = new QueryBuilder<User>()
  .select("id", "name", "email")
  .from("users")
  .where("age > 18")
  .orderBy("name", "ASC")
  .build();

console.log(query);
// SELECT id, name, email FROM users WHERE age > 18 ORDER BY name ASC

7. Adapter Pattern - Interface Compatibility

// Adapter pattern makes incompatible interfaces work together

// Old interface
class OldLogger {
  logMessage(message: string): void {
    console.log(`[OLD] ${message}`);
  }
}

// New interface that client expects
interface NewLogger {
  log(level: string, message: string): void;
}

// Adapter wraps old interface to match new one
class LoggerAdapter implements NewLogger {
  constructor(private oldLogger: OldLogger) {}
  
  log(level: string, message: string): void {
    const formattedMessage = `[${level.toUpperCase()}] ${message}`;
    this.oldLogger.logMessage(formattedMessage);
  }
}

// Client code expects NewLogger interface
function clientCode(logger: NewLogger): void {
  logger.log("info", "System started");
  logger.log("error", "An error occurred");
}

// Use adapter to make old logger work with new interface
const oldLogger = new OldLogger();
const adapter = new LoggerAdapter(oldLogger);

clientCode(adapter);
// [OLD] [INFO] System started
// [OLD] [ERROR] An error occurred

Real-world example - API adapter:

// Third-party payment API
class StripeAPI {
  processPayment(cardNumber: string, amount: number): boolean {
    console.log(`Stripe: Processing $${amount}`);
    return true;
  }
}

// Our application interface
interface PaymentProcessor {
  pay(amount: number, details: PaymentDetails): boolean;
}

interface PaymentDetails {
  cardNumber: string;
  cvv: string;
  expiryDate: string;
}

// Adapter
class StripeAdapter implements PaymentProcessor {
  constructor(private stripe: StripeAPI) {}
  
  pay(amount: number, details: PaymentDetails): boolean {
    // Adapt our interface to Stripe's interface
    return this.stripe.processPayment(details.cardNumber, amount);
  }
}

// Usage
const stripe = new StripeAPI();
const processor: PaymentProcessor = new StripeAdapter(stripe);

processor.pay(100, {
  cardNumber: "1234-5678-9012-3456",
  cvv: "123",
  expiryDate: "12/25"
});

8. Repository Pattern - Data Access Layer

// Repository pattern abstracts data access
interface Repository<T> {
  findById(id: string): Promise<T | null>;
  findAll(): Promise<T[]>;
  create(item: T): Promise<T>;
  update(id: string, item: Partial<T>): Promise<T>;
  delete(id: string): Promise<void>;
}

// Generic repository implementation
class InMemoryRepository<T extends { id: string }> implements Repository<T> {
  protected items: Map<string, T> = new Map();
  
  async findById(id: string): Promise<T | null> {
    return this.items.get(id) || null;
  }
  
  async findAll(): Promise<T[]> {
    return Array.from(this.items.values());
  }
  
  async create(item: T): Promise<T> {
    this.items.set(item.id, item);
    return item;
  }
  
  async update(id: string, updates: Partial<T>): Promise<T> {
    const item = this.items.get(id);
    if (!item) {
      throw new Error("Item not found");
    }
    
    const updated = { ...item, ...updates };
    this.items.set(id, updated);
    return updated;
  }
  
  async delete(id: string): Promise<void> {
    this.items.delete(id);
  }
}

// Specific repository with custom methods
interface User {
  id: string;
  name: string;
  email: string;
}

class UserRepository extends InMemoryRepository<User> {
  async findByEmail(email: string): Promise<User | null> {
    const users = await this.findAll();
    return users.find(user => user.email === email) || null;
  }
  
  async findActiveUsers(): Promise<User[]> {
    // Custom query logic
    return this.findAll();
  }
}

// Usage
const userRepo = new UserRepository();

await userRepo.create({
  id: "1",
  name: "John",
  email: "john@example.com"
});

const user = await userRepo.findByEmail("john@example.com");
console.log(user); // { id: "1", name: "John", email: "john@example.com" }

9. Facade Pattern - Simplified Interface

// Facade provides simple interface to complex subsystem

// Complex subsystem classes
class CPU {
  freeze(): void {
    console.log("CPU: Freezing");
  }
  
  jump(position: number): void {
    console.log(`CPU: Jumping to ${position}`);
  }
  
  execute(): void {
    console.log("CPU: Executing");
  }
}

class Memory {
  load(position: number, data: string): void {
    console.log(`Memory: Loading "${data}" at ${position}`);
  }
}

class HardDrive {
  read(sector: number, size: number): string {
    console.log(`HardDrive: Reading ${size} bytes from sector ${sector}`);
    return "boot data";
  }
}

// Facade - simple interface
class ComputerFacade {
  private cpu: CPU;
  private memory: Memory;
  private hardDrive: HardDrive;
  
  constructor() {
    this.cpu = new CPU();
    this.memory = new Memory();
    this.hardDrive = new HardDrive();
  }
  
  start(): void {
    console.log("Computer: Starting...");
    this.cpu.freeze();
    const bootData = this.hardDrive.read(0, 1024);
    this.memory.load(0, bootData);
    this.cpu.jump(0);
    this.cpu.execute();
    console.log("Computer: Started!");
  }
}

// Usage - simple interface hides complexity
const computer = new ComputerFacade();
computer.start();
// Instead of:
// cpu.freeze();
// const data = hardDrive.read(0, 1024);
// memory.load(0, data);
// cpu.jump(0);
// cpu.execute();

10. Dependency Injection - Loose Coupling

// Dependency Injection inverts control of dependencies

// Without DI - tight coupling
class EmailService {
  send(to: string, message: string): void {
    console.log(`Sending email to ${to}: ${message}`);
  }
}

class UserService {
  private emailService = new EmailService(); // Tightly coupled
  
  register(email: string): void {
    // Registration logic
    this.emailService.send(email, "Welcome!");
  }
}

// With DI - loose coupling
interface NotificationService {
  send(to: string, message: string): void;
}

class EmailNotification implements NotificationService {
  send(to: string, message: string): void {
    console.log(`Email to ${to}: ${message}`);
  }
}

class SMSNotification implements NotificationService {
  send(to: string, message: string): void {
    console.log(`SMS to ${to}: ${message}`);
  }
}

class UserServiceDI {
  constructor(private notificationService: NotificationService) {}
  
  register(contact: string): void {
    // Registration logic
    this.notificationService.send(contact, "Welcome!");
  }
}

// Usage - inject dependency
const emailService = new EmailNotification();
const userService = new UserServiceDI(emailService);
userService.register("user@example.com");

// Easy to switch implementations
const smsService = new SMSNotification();
const userService2 = new UserServiceDI(smsService);
userService2.register("+1234567890");

// Easy to test - inject mock
class MockNotification implements NotificationService {
  send(to: string, message: string): void {
    console.log("Mock: notification sent");
  }
}

const testService = new UserServiceDI(new MockNotification());

DI Container:

class Container {
  private services = new Map<string, any>();
  
  register<T>(name: string, instance: T): void {
    this.services.set(name, instance);
  }
  
  resolve<T>(name: string): T {
    const service = this.services.get(name);
    if (!service) {
      throw new Error(`Service ${name} not found`);
    }
    return service;
  }
}

// Usage
const container = new Container();
container.register("notification", new EmailNotification());
container.register("userService", new UserServiceDI(
  container.resolve("notification")
));

const service = container.resolve<UserServiceDI>("userService");
service.register("test@example.com");

Practice Part (35 min)

Exercise 1: Blog System with Multiple Patterns (15 min)

Build a blog system combining Singleton, Factory, and Observer patterns.

// TODO: Implement blog system with design patterns

// 1. BlogPost class
interface BlogPost {
  id: string;
  title: string;
  content: string;
  author: string;
  createdAt: Date;
}

// 2. Singleton Database
class BlogDatabase {
  private posts: Map<string, BlogPost> = new Map();
  private static instance: BlogDatabase;
  
  // TODO: Implement singleton pattern
  // - Private constructor
  // - Static getInstance()
  // - Methods: savePost, getPost, getAllPosts
}

// 3. Factory for different post types
interface Post {
  format(): string;
}

class TextPost implements Post {
  constructor(public content: string) {}
  format(): string {
    return this.content;
  }
}

class MarkdownPost implements Post {
  constructor(public content: string) {}
  format(): string {
    return `Markdown: ${this.content}`;
  }
}

class HTMLPost implements Post {
  constructor(public content: string) {}
  format(): string {
    return `<div>${this.content}</div>`;
  }
}

class PostFactory {
  // TODO: Implement factory pattern
  // static createPost(type: "text" | "markdown" | "html", content: string): Post
}

// 4. Observer pattern for notifications
interface PostObserver {
  notify(post: BlogPost): void;
}

class EmailNotifier implements PostObserver {
  // TODO: Implement
}

class SlackNotifier implements PostObserver {
  // TODO: Implement
}

class BlogService {
  private observers: PostObserver[] = [];
  
  // TODO: Implement observer pattern
  // - subscribe(observer: PostObserver): void
  // - unsubscribe(observer: PostObserver): void
  // - publishPost(post: BlogPost): void (notifies all observers)
}

// Tests - uncomment after implementation
// const db = BlogDatabase.getInstance();
// const db2 = BlogDatabase.getInstance();
// console.log(db === db2); // true

// const textPost = PostFactory.createPost("text", "Hello World");
// console.log(textPost.format());

// const blog = new BlogService();
// blog.subscribe(new EmailNotifier());
// blog.subscribe(new SlackNotifier());
// blog.publishPost({
//   id: "1",
//   title: "First Post",
//   content: "Hello",
//   author: "John",
//   createdAt: new Date()
// });

Solution:

Click to see solution
interface BlogPost {
  id: string;
  title: string;
  content: string;
  author: string;
  createdAt: Date;
}

class BlogDatabase {
  private posts: Map<string, BlogPost> = new Map();
  private static instance: BlogDatabase;
  
  private constructor() {
    console.log("Database initialized");
  }
  
  public static getInstance(): BlogDatabase {
    if (!BlogDatabase.instance) {
      BlogDatabase.instance = new BlogDatabase();
    }
    return BlogDatabase.instance;
  }
  
  savePost(post: BlogPost): void {
    this.posts.set(post.id, post);
  }
  
  getPost(id: string): BlogPost | undefined {
    return this.posts.get(id);
  }
  
  getAllPosts(): BlogPost[] {
    return Array.from(this.posts.values());
  }
}

interface Post {
  format(): string;
}

class TextPost implements Post {
  constructor(public content: string) {}
  format(): string {
    return this.content;
  }
}

class MarkdownPost implements Post {
  constructor(public content: string) {}
  format(): string {
    return `Markdown: ${this.content}`;
  }
}

class HTMLPost implements Post {
  constructor(public content: string) {}
  format(): string {
    return `<div>${this.content}</div>`;
  }
}

class PostFactory {
  static createPost(type: "text" | "markdown" | "html", content: string): Post {
    switch (type) {
      case "text":
        return new TextPost(content);
      case "markdown":
        return new MarkdownPost(content);
      case "html":
        return new HTMLPost(content);
      default:
        throw new Error("Unknown post type");
    }
  }
}

interface PostObserver {
  notify(post: BlogPost): void;
}

class EmailNotifier implements PostObserver {
  notify(post: BlogPost): void {
    console.log(`Email: New post "${post.title}" by ${post.author}`);
  }
}

class SlackNotifier implements PostObserver {
  notify(post: BlogPost): void {
    console.log(`Slack: 📢 ${post.author} published "${post.title}"`);
  }
}

class BlogService {
  private observers: PostObserver[] = [];
  private database = BlogDatabase.getInstance();
  
  subscribe(observer: PostObserver): void {
    this.observers.push(observer);
  }
  
  unsubscribe(observer: PostObserver): void {
    const index = this.observers.indexOf(observer);
    if (index > -1) {
      this.observers.splice(index, 1);
    }
  }
  
  publishPost(post: BlogPost): void {
    this.database.savePost(post);
    this.observers.forEach(observer => observer.notify(post));
  }
}

// Tests
const db = BlogDatabase.getInstance();
const db2 = BlogDatabase.getInstance();
console.log(db === db2); // true

const textPost = PostFactory.createPost("text", "Hello World");
console.log(textPost.format()); // Hello World

const markdownPost = PostFactory.createPost("markdown", "# Title");
console.log(markdownPost.format()); // Markdown: # Title

const blog = new BlogService();
blog.subscribe(new EmailNotifier());
blog.subscribe(new SlackNotifier());

blog.publishPost({
  id: "1",
  title: "First Post",
  content: "Hello World",
  author: "John",
  createdAt: new Date()
});
// Email: New post "First Post" by John
// Slack: 📢 John published "First Post"

Exercise 2: E-commerce with Strategy and Decorator (12 min)

Implement payment strategies and order decorators.

// TODO: Implement e-commerce system

// 1. Strategy pattern for payment methods
interface PaymentStrategy {
  pay(amount: number): boolean;
}

class CreditCardStrategy implements PaymentStrategy {
  constructor(private cardNumber: string, private cvv: string) {}
  // TODO: Implement pay method
}

class PayPalStrategy implements PaymentStrategy {
  constructor(private email: string) {}
  // TODO: Implement pay method
}

class CryptoStrategy implements PaymentStrategy {
  constructor(private wallet: string) {}
  // TODO: Implement pay method
}

// 2. Decorator pattern for order extras
interface Order {
  cost(): number;
  description(): string;
}

class BasicOrder implements Order {
  constructor(private basePrice: number) {}
  
  cost(): number {
    return this.basePrice;
  }
  
  description(): string {
    return "Basic order";
  }
}

abstract class OrderDecorator implements Order {
  constructor(protected order: Order) {}
  
  abstract cost(): number;
  abstract description(): string;
}

class GiftWrapDecorator extends OrderDecorator {
  // TODO: Add $5 to cost, add "gift wrap" to description
}

class ExpressShippingDecorator extends OrderDecorator {
  // TODO: Add $15 to cost, add "express shipping" to description
}

class InsuranceDecorator extends OrderDecorator {
  // TODO: Add $10 to cost, add "insurance" to description
}

// 3. Checkout class combining both patterns
class Checkout {
  private order: Order;
  private paymentStrategy?: PaymentStrategy;
  
  constructor(order: Order) {
    this.order = order;
  }
  
  // TODO: Implement setPaymentStrategy and processPayment
}

// Tests - uncomment after implementation
// let order: Order = new BasicOrder(100);
// order = new GiftWrapDecorator(order);
// order = new ExpressShippingDecorator(order);

// console.log(order.description());
// console.log(`Total: ${order.cost()}`);

// const checkout = new Checkout(order);
// checkout.setPaymentStrategy(new CreditCardStrategy("1234", "123"));
// checkout.processPayment();

Solution:

Click to see solution
interface PaymentStrategy {
  pay(amount: number): boolean;
}

class CreditCardStrategy implements PaymentStrategy {
  constructor(
    private cardNumber: string,
    private cvv: string
  ) {}
  
  pay(amount: number): boolean {
    console.log(`Paid ${amount} with Credit Card ending in ${this.cardNumber.slice(-4)}`);
    return true;
  }
}

class PayPalStrategy implements PaymentStrategy {
  constructor(private email: string) {}
  
  pay(amount: number): boolean {
    console.log(`Paid ${amount} via PayPal (${this.email})`);
    return true;
  }
}

class CryptoStrategy implements PaymentStrategy {
  constructor(private wallet: string) {}
  
  pay(amount: number): boolean {
    console.log(`Paid ${amount} with Crypto wallet ${this.wallet.slice(0, 10)}...`);
    return true;
  }
}

interface Order {
  cost(): number;
  description(): string;
}

class BasicOrder implements Order {
  constructor(private basePrice: number) {}
  
  cost(): number {
    return this.basePrice;
  }
  
  description(): string {
    return "Basic order";
  }
}

abstract class OrderDecorator implements Order {
  constructor(protected order: Order) {}
  
  abstract cost(): number;
  abstract description(): string;
}

class GiftWrapDecorator extends OrderDecorator {
  cost(): number {
    return this.order.cost() + 5;
  }
  
  description(): string {
    return this.order.description() + ", gift wrap";
  }
}

class ExpressShippingDecorator extends OrderDecorator {
  cost(): number {
    return this.order.cost() + 15;
  }
  
  description(): string {
    return this.order.description() + ", express shipping";
  }
}

class InsuranceDecorator extends OrderDecorator {
  cost(): number {
    return this.order.cost() + 10;
  }
  
  description(): string {
    return this.order.description() + ", insurance";
  }
}

class Checkout {
  private order: Order;
  private paymentStrategy?: PaymentStrategy;
  
  constructor(order: Order) {
    this.order = order;
  }
  
  setPaymentStrategy(strategy: PaymentStrategy): void {
    this.paymentStrategy = strategy;
  }
  
  processPayment(): boolean {
    if (!this.paymentStrategy) {
      throw new Error("Payment strategy not set");
    }
    
    const amount = this.order.cost();
    console.log(`Processing: ${this.order.description()}`);
    return this.paymentStrategy.pay(amount);
  }
}

// Tests
let order: Order = new BasicOrder(100);
order = new GiftWrapDecorator(order);
order = new ExpressShippingDecorator(order);
order = new InsuranceDecorator(order);

console.log(order.description());
// Basic order, gift wrap, express shipping, insurance
console.log(`Total: ${order.cost()}`);
// Total: $130

const checkout = new Checkout(order);
checkout.setPaymentStrategy(new CreditCardStrategy("1234567890123456", "123"));
checkout.processPayment();
// Processing: Basic order, gift wrap, express shipping, insurance
// Paid $130 with Credit Card ending in 3456

Exercise 3: Builder and Repository Patterns (8 min)

Create a query builder and repository system.

// TODO: Implement query builder and repository

// 1. Builder pattern for SQL queries
class QueryBuilder {
  private query: string = "";
  
  // TODO: Implement fluent interface
  // - select(...fields: string[]): this
  // - from(table: string): this
  // - where(condition: string): this
  // - orderBy(field: string, direction?: "ASC" | "DESC"): this
  // - limit(n: number): this
  // - build(): string
}

// 2. Generic Repository with QueryBuilder
interface Entity {
  id: string;
}

interface User extends Entity {
  name: string;
  email: string;
  age: number;
}

class Repository<T extends Entity> {
  protected items: Map<string, T> = new Map();
  
  // TODO: Implement basic CRUD
  // - save(item: T): void
  // - findById(id: string): T | undefined
  // - findAll(): T[]
  // - query(builder: QueryBuilder): T[] (simplified - just return all)
}

class UserRepository extends Repository<User> {
  // TODO: Add custom methods
  // - findByEmail(email: string): User | undefined
  // - findByAgeRange(min: number, max: number): User[]
}

// Tests - uncomment after implementation
// const query = new QueryBuilder()
//   .select("id", "name", "email")
//   .from("users")
//   .where("age > 18")
//   .orderBy("name", "ASC")
//   .limit(10)
//   .build();

// console.log(query);

// const userRepo = new UserRepository();
// userRepo.save({ id: "1", name: "John", email: "john@example.com", age: 25 });
// userRepo.save({ id: "2", name: "Jane", email: "jane@example.com", age: 30 });

// console.log(userRepo.findByEmail("john@example.com"));
// console.log(userRepo.findByAgeRange(20, 30));

Solution:

Click to see solution
class QueryBuilder {
  private query: string = "";
  
  select(...fields: string[]): this {
    this.query += `SELECT ${fields.join(", ")} `;
    return this;
  }
  
  from(table: string): this {
    this.query += `FROM ${table} `;
    return this;
  }
  
  where(condition: string): this {
    this.query += `WHERE ${condition} `;
    return this;
  }
  
  orderBy(field: string, direction: "ASC" | "DESC" = "ASC"): this {
    this.query += `ORDER BY ${field} ${direction} `;
    return this;
  }
  
  limit(n: number): this {
    this.query += `LIMIT ${n}`;
    return this;
  }
  
  build(): string {
    return this.query.trim();
  }
}

interface Entity {
  id: string;
}

interface User extends Entity {
  name: string;
  email: string;
  age: number;
}

class Repository<T extends Entity> {
  protected items: Map<string, T> = new Map();
  
  save(item: T): void {
    this.items.set(item.id, item);
  }
  
  findById(id: string): T | undefined {
    return this.items.get(id);
  }
  
  findAll(): T[] {
    return Array.from(this.items.values());
  }
  
  query(builder: QueryBuilder): T[] {
    console.log("Executing query:", builder.build());
    return this.findAll();
  }
}

class UserRepository extends Repository<User> {
  findByEmail(email: string): User | undefined {
    return this.findAll().find(user => user.email === email);
  }
  
  findByAgeRange(min: number, max: number): User[] {
    return this.findAll().filter(user => user.age >= min && user.age <= max);
  }
}

// Tests
const query = new QueryBuilder()
  .select("id", "name", "email")
  .from("users")
  .where("age > 18")
  .orderBy("name", "ASC")
  .limit(10)
  .build();

console.log(query);
// SELECT id, name, email FROM users WHERE age > 18 ORDER BY name ASC LIMIT 10

const userRepo = new UserRepository();
userRepo.save({ id: "1", name: "John", email: "john@example.com", age: 25 });
userRepo.save({ id: "2", name: "Jane", email: "jane@example.com", age: 30 });
userRepo.save({ id: "3", name: "Bob", email: "bob@example.com", age: 17 });

console.log(userRepo.findByEmail("john@example.com"));
// { id: "1", name: "John", email: "john@example.com", age: 25 }

console.log(userRepo.findByAgeRange(20, 30));
// [{ id: "1", ... }, { id: "2", ... }]

Quick Review - Key Concepts

1. When to use Singleton pattern?

Answer

Singleton pattern ensures only one instance of a class exists throughout the application.

When to use:

  1. Database connections:
class Database {
  private static instance: Database;
  private constructor() {}
  
  static getInstance(): Database {
    if (!this.instance) {
      this.instance = new Database();
    }
    return this.instance;
  }
}
  1. Configuration managers:
class Config {
  private static instance: Config;
  private settings: Map<string, any> = new Map();
  
  private constructor() {}
  
  static getInstance(): Config {
    if (!this.instance) {
      this.instance = new Config();
    }
    return this.instance;
  }
  
  get(key: string): any {
    return this.settings.get(key);
  }
}
  1. Logging services:
class Logger {
  private static instance: Logger;
  private constructor() {}
  
  static getInstance(): Logger {
    if (!this.instance) {
      this.instance = new Logger();
    }
    return this.instance;
  }
  
  log(message: string): void {
    console.log(`[${new Date().toISOString()}] ${message}`);
  }
}
  1. Cache systems:
class CacheManager {
  private static instance: CacheManager;
  private cache: Map<string, any> = new Map();
  
  private constructor() {}
  
  static getInstance(): CacheManager {
    if (!this.instance) {
      this.instance = new CacheManager();
    }
    return this.instance;
  }
}

Pros:

  • Controlled access to single instance
  • Lazy initialization
  • Global access point
  • Reduced memory footprint

Cons:

  • Difficult to test (global state)
  • Violates Single Responsibility Principle
  • Can hide dependencies
  • Thread safety issues (in multi-threaded environments)

Testing tip: Use dependency injection instead of Singleton for better testability:

// Instead of
const db = Database.getInstance();

// Use DI
class UserService {
  constructor(private db: Database) {}
}

2. Factory vs Abstract Factory - what’s the difference?

Answer

Factory Pattern creates objects without specifying exact class.

Abstract Factory Pattern creates families of related objects.

Factory Pattern:

interface Vehicle {
  drive(): void;
}

class Car implements Vehicle {
  drive(): void {
    console.log("Driving car");
  }
}

class Truck implements Vehicle {
  drive(): void {
    console.log("Driving truck");
  }
}

// Factory
class VehicleFactory {
  static create(type: "car" | "truck"): Vehicle {
    switch (type) {
      case "car": return new Car();
      case "truck": return new Truck();
    }
  }
}

// Usage
const vehicle = VehicleFactory.create("car");

Abstract Factory Pattern:

// Abstract factory for families of products
interface GUIFactory {
  createButton(): Button;
  createCheckbox(): Checkbox;
}

interface Button {
  render(): void;
}

interface Checkbox {
  render(): void;
}

// Windows family
class WindowsFactory implements GUIFactory {
  createButton(): Button {
    return new WindowsButton();
  }
  
  createCheckbox(): Checkbox {
    return new WindowsCheckbox();
  }
}

// Mac family
class MacFactory implements GUIFactory {
  createButton(): Button {
    return new MacButton();
  }
  
  createCheckbox(): Checkbox {
    return new MacCheckbox();
  }
}

// Usage - creates related objects
function createUI(factory: GUIFactory) {
  const button = factory.createButton();
  const checkbox = factory.createCheckbox();
  // Both are from same family (Windows or Mac)
}

Key differences:

FeatureFactoryAbstract Factory
PurposeCreate one type of objectCreate families of related objects
ComplexitySimpleMore complex
ProductsSingle productMultiple related products
When to useOne creation pointNeed consistent product families

When to use Factory:

  • Simple object creation
  • Centralize creation logic
  • Client doesn’t need to know concrete classes

When to use Abstract Factory:

  • Multiple related products
  • Need to ensure products work together
  • UI themes, database drivers, OS-specific components

3. Observer vs Pub-Sub - are they the same?

Answer

Observer and Pub-Sub are similar but have key differences.

Observer Pattern:

interface Observer {
  update(data: any): void;
}

class Subject {
  private observers: Observer[] = [];
  
  attach(observer: Observer): void {
    this.observers.push(observer);
  }
  
  notify(data: any): void {
    this.observers.forEach(obs => obs.update(data));
  }
}

// Direct coupling - Subject knows its observers

Pub-Sub Pattern:

class EventBus {
  private subscribers: Map<string, Function[]> = new Map();
  
  subscribe(event: string, callback: Function): void {
    if (!this.subscribers.has(event)) {
      this.subscribers.set(event, []);
    }
    this.subscribers.get(event)!.push(callback);
  }
  
  publish(event: string, data: any): void {
    const callbacks = this.subscribers.get(event) || [];
    callbacks.forEach(callback => callback(data));
  }
}

// Event bus mediates - publishers and subscribers don't know each other

Key differences:

FeatureObserverPub-Sub
CouplingSubject knows observersLoose coupling via event bus
CommunicationDirectVia mediator/event bus
ScopeSingle applicationCan be distributed
FlexibilityLess flexibleMore flexible

Observer example:

class Stock {
  private observers: Investor[] = [];
  private price: number = 0;
  
  attach(investor: Investor): void {
    this.observers.push(investor);
  }
  
  setPrice(price: number): void {
    this.price = price;
    this.notify();
  }
  
  private notify(): void {
    this.observers.forEach(obs => obs.update(this.price));
  }
}

Pub-Sub example:

const eventBus = new EventBus();

// Publisher doesn't know subscribers
function publishNews(news: string) {
  eventBus.publish("news", news);
}

// Subscribers don't know publishers
eventBus.subscribe("news", (news) => {
  console.log("Channel 1:", news);
});

eventBus.subscribe("news", (news) => {
  console.log("Channel 2:", news);
});

When to use Observer:

  • Tight coupling is acceptable
  • Small, contained systems
  • Direct notifications needed

When to use Pub-Sub:

  • Loose coupling required
  • Multiple unrelated components
  • Event-driven architecture
  • Distributed systems

4. Strategy vs Decorator - when to use which?

Answer

Both patterns add functionality, but in different ways.

Strategy Pattern - changes algorithm/behavior

Decorator Pattern - adds/wraps functionality

Strategy Pattern:

// Different algorithms for same task
interface CompressionStrategy {
  compress(data: string): string;
}

class ZipCompression implements CompressionStrategy {
  compress(data: string): string {
    return `Zipped: ${data}`;
  }
}

class RarCompression implements CompressionStrategy {
  compress(data: string): string {
    return `Rarred: ${data}`;
  }
}

class FileProcessor {
  constructor(private strategy: CompressionStrategy) {}
  
  process(data: string): string {
    return this.strategy.compress(data);
  }
  
  // Can change strategy at runtime
  setStrategy(strategy: CompressionStrategy): void {
    this.strategy = strategy;
  }
}

// Usage - choose one algorithm
const processor = new FileProcessor(new ZipCompression());

Decorator Pattern:

// Adds layers of functionality
interface Coffee {
  cost(): number;
  description(): string;
}

class SimpleCoffee implements Coffee {
  cost(): number { return 5; }
  description(): string { return "Coffee"; }
}

class MilkDecorator implements Coffee {
  constructor(private coffee: Coffee) {}
  
  cost(): number {
    return this.coffee.cost() + 2;
  }
  
  description(): string {
    return this.coffee.description() + ", milk";
  }
}

// Usage - wrap with multiple decorators
let coffee: Coffee = new SimpleCoffee();
coffee = new MilkDecorator(coffee);
coffee = new SugarDecorator(coffee);
// Each decorator adds to the original

Key differences:

FeatureStrategyDecorator
PurposeSelect algorithmAdd functionality
StructureReplaces behaviorWraps behavior
NumberOne at a timeMultiple layers
OriginalHiddenEnhanced

When to use Strategy:

  • Multiple ways to do same thing
  • Want to switch algorithms at runtime
  • Avoid conditional statements
  • Different behaviors for different contexts

When to use Decorator:

  • Add responsibilities dynamically
  • Extend functionality without subclassing
  • Multiple combinations of features
  • Flexible alternative to inheritance

Real-world analogy:

  • Strategy: Choosing transportation method (car, bike, walk)
  • Decorator: Adding toppings to pizza (each adds to the base)

5. What is Dependency Injection and why is it important?

Answer

Dependency Injection (DI) is providing dependencies from outside rather than creating them inside a class.

Without DI (tightly coupled):

class EmailService {
  send(to: string, message: string): void {
    console.log(`Email to ${to}: ${message}`);
  }
}

class UserService {
  private emailService = new EmailService(); // Created inside
  
  register(email: string): void {
    // Registration logic
    this.emailService.send(email, "Welcome!");
  }
}

// Problems:
// - Hard to test (can't mock EmailService)
// - Hard to change email implementation
// - UserService is responsible for creating EmailService

With DI (loosely coupled):

interface NotificationService {
  send(to: string, message: string): void;
}

class EmailService implements NotificationService {
  send(to: string, message: string): void {
    console.log(`Email: ${message}`);
  }
}

class SMSService implements NotificationService {
  send(to: string, message: string): void {
    console.log(`SMS: ${message}`);
  }
}

class UserService {
  constructor(private notifier: NotificationService) {} // Injected
  
  register(contact: string): void {
    this.notifier.send(contact, "Welcome!");
  }
}

// Usage
const emailService = new EmailService();
const userService = new UserService(emailService);

// Easy to switch
const smsService = new SMSService();
const userService2 = new UserService(smsService);

// Easy to test
class MockNotifier implements NotificationService {
  send(): void { /* mock */ }
}
const testService = new UserService(new MockNotifier());

Types of DI:

  1. Constructor Injection (recommended):
class Service {
  constructor(private dependency: Dependency) {}
}
  1. Property Injection:
class Service {
  dependency?: Dependency;
  
  setDependency(dep: Dependency): void {
    this.dependency = dep;
  }
}
  1. Method Injection:
class Service {
  process(dependency: Dependency): void {
    dependency.doSomething();
  }
}

Benefits:

  1. Testability:
// Easy to inject mocks
const mockDB = new MockDatabase();
const service = new UserService(mockDB);
  1. Flexibility:
// Easy to switch implementations
const prodDB = new PostgresDatabase();
const devDB = new InMemoryDatabase();
  1. Loose coupling:
// Service doesn't know concrete implementation
class Service {
  constructor(private logger: Logger) {}
  // Works with any logger implementation
}
  1. Single Responsibility:
// Service focuses on its logic, not on creating dependencies
class Service {
  constructor(private db: Database, private cache: Cache) {}
  // Service doesn't create db or cache
}

DI Container:

class Container {
  private services = new Map();
  
  register<T>(name: string, service: T): void {
    this.services.set(name, service);
  }
  
  resolve<T>(name: string): T {
    return this.services.get(name);
  }
}

// Setup
const container = new Container();
container.register("logger", new ConsoleLogger());
container.register("db", new PostgresDB());
container.register("userService", 
  new UserService(
    container.resolve("db"),
    container.resolve("logger")
  )
);

Best practices:

  • Inject interfaces, not concrete classes
  • Use constructor injection for required dependencies
  • Keep constructors simple (don’t do work)
  • One level of abstraction per class

Checklist - What You Should Know After Day 8

  • Implement Singleton pattern for single instances
  • Use Factory pattern for object creation
  • Build Observer pattern for event systems
  • Apply Strategy pattern for algorithm selection
  • Use Decorator pattern to add functionality
  • Create Builder pattern for complex objects
  • Implement Adapter pattern for interface compatibility
  • Use Repository pattern for data access
  • Apply Facade pattern for simplified interfaces
  • Understand and use Dependency Injection

Quick Reference Card

// Singleton
class Singleton {
  private static instance: Singleton;
  private constructor() {}
  
  static getInstance(): Singleton {
    if (!this.instance) {
      this.instance = new Singleton();
    }
    return this.instance;
  }
}

// Factory
class Factory {
  static create(type: string): Product {
    switch (type) {
      case "A": return new ProductA();
      case "B": return new ProductB();
    }
  }
}

// Observer
interface Observer {
  update(data: any): void;
}

class Subject {
  private observers: Observer[] = [];
  
  attach(obs: Observer): void {
    this.observers.push(obs);
  }
  
  notify(data: any): void {
    this.observers.forEach(obs => obs.update(data));
  }
}

// Strategy
interface Strategy {
  execute(data: any): any;
}

class Context {
  constructor(private strategy: Strategy) {}
  
  setStrategy(strategy: Strategy): void {
    this.strategy = strategy;
  }
  
  doSomething(data: any): any {
    return this.strategy.execute(data);
  }
}

// Decorator
interface Component {
  operation(): string;
}

class ConcreteComponent implements Component {
  operation(): string {
    return "Component";
  }
}

class Decorator implements Component {
  constructor(protected component: Component) {}
  
  operation(): string {
    return this.component.operation() + " + Decorator";
  }
}

// Builder
class Builder {
  private product: any = {};
  
  setA(value: any): this {
    this.product.a = value;
    return this;
  }
  
  setB(value: any): this {
    this.product.b = value;
    return this;
  }
  
  build(): any {
    return this.product;
  }
}

// Adapter
class Adapter implements TargetInterface {
  constructor(private adaptee: Adaptee) {}
  
  request(): void {
    this.adaptee.specificRequest();
  }
}

// Repository
class Repository<T extends { id: string }> {
  private items = new Map<string, T>();
  
  save(item: T): void {
    this.items.set(item.id, item);
  }
  
  findById(id: string): T | undefined {
    return this.items.get(id);
  }
  
  findAll(): T[] {
    return Array.from(this.items.values());
  }
}

// Facade
class Facade {
  private subsystem1: Subsystem1;
  private subsystem2: Subsystem2;
  
  constructor() {
    this.subsystem1 = new Subsystem1();
    this.subsystem2 = new Subsystem2();
  }
  
  operation(): void {
    this.subsystem1.operation1();
    this.subsystem2.operation2();
  }
}

// Dependency Injection
class Service {
  constructor(
    private dependency1: Dependency1,
    private dependency2: Dependency2
  ) {}
}

// Usage
const dep1 = new Dependency1();
const dep2 = new Dependency2();
const service = new Service(dep1, dep2);

Tomorrow: Advanced TypeScript Topics - declaration files, module augmentation, and advanced techniques

Back to blog