Design Patterns intermediate

Developer 101: Intro to Design Patterns using TypeScript and Angular

Learn essential design patterns through practical TypeScript and Angular examples: Factory, Observer, Singleton, Adapter, Decorator, and Strategy patterns.

Alex Muturi
August 21, 2024
8 min read
#Design Patterns #TypeScript #Angular #Software Architecture #OOP #Best Practices

Developer 101: Intro to Design Patterns using TypeScript and Angular

Design patterns are typical or proven solutions to common software design problems. They help create more modular, reusable, and maintainable code. They are like pre-made blueprints that you can customize to solve a recurring design problem in your code.

A pattern is a general concept for solving a particular problem. You follow the pattern details and implement a solution that suits the problem.

Patterns are not algorithms. Both algorithms and patterns are concepts describing typical solutions to some known problems. While an algorithm always defines a clear set of actions that can achieve some goal, a pattern is a more high-level description of a solution.

An algorithm can be likened to a cooking recipe with clear steps to achieve a goal. A pattern is more like a blueprint or guide: you know its results and features, but the exact order of implementation is up to you.

Design patterns are grouped into three major categories:

Creational Patterns

These patterns provide various object creation mechanisms, which increase flexibility and reuse of existing code (e.g., Factory Pattern, Singleton Pattern).

Structural Patterns

These patterns explain how to assemble objects and classes into larger structures while keeping these structures flexible and efficient (e.g., Adapter Pattern, Decorator Pattern).

Behavioral Patterns

These patterns are concerned with algorithms and the assignment of responsibilities between objects (e.g., Observer Pattern, Strategy Pattern).

In this tutorial, we'll explore a few of these patterns using oversimplified examples using TypeScript and Angular.

1. Factory Pattern

The Factory Pattern provides a way to create objects without specifying the exact class of the object that will be created. This pattern is useful when the creation process is complex or when the type of the object to be created is decided at runtime.

Example: Creating a Notification System

Let's implement a notification system that can create different types of notifications (e.g., Email, SMS).

Step 1: Define the Notification Interface

Create an interface for notifications:

// src/app/notification/notification.ts
export interface Notification {
  send(message: string): void;
}

Step 2: Implement Concrete Notifications

Implement the EmailNotification and SMSNotification classes:

// src/app/notification/email-notification.ts
import { Notification } from "./notification";

export class EmailNotification implements Notification {
  send(message: string): void {
    console.log(`Sending email: ${message}`);
  }
}

// src/app/notification/sms-notification.ts
import { Notification } from "./notification";

export class SMSNotification implements Notification {
  send(message: string): void {
    console.log(`Sending SMS: ${message}`);
  }
}

Step 3: Create the Notification Factory

Implement the factory to create notifications:

// src/app/notification/notification-factory.ts
import { Notification } from "./notification";
import { EmailNotification } from "./email-notification";
import { SMSNotification } from "./sms-notification";

export class NotificationFactory {
  static createNotification(type: string): Notification {
    if (type === "email") {
      return new EmailNotification();
    } else if (type === "sms") {
      return new SMSNotification();
    } else {
      throw new Error("Unknown notification type");
    }
  }
}

Step 4: Use the Factory in a Component

Use the factory in an Angular component:

// src/app/app.component.ts
import { Component } from "@angular/core";
import { NotificationFactory } from "./notification/notification-factory";
import { Notification } from "./notification/notification";

@Component({
  selector: "app-factory-demo",
  standalone: true,
  template: `<h1>Factory Demo</h1>`
})
export class FactoryAppComponent {
  title = "design-patterns-demo";

  constructor() {
    const emailNotification: Notification = NotificationFactory.createNotification("email");
    emailNotification.send("Hello via Email");

    const smsNotification: Notification = NotificationFactory.createNotification("sms");
    smsNotification.send("Hello via SMS");
  }
}

2. Observer Pattern

The Observer Pattern defines a one-to-many dependency between objects, so that when one object changes state, all its dependents are notified and updated automatically.

Please note that we are not using RxJS; instead, we are creating a simplified implementation of the observer pattern. I believe this approach will deepen your understanding of the Observer pattern and RxJS as well.

Example: Implementing a Simple Event System

Let's create a simple event (Observable) system where multiple listeners (observers) can subscribe to an event and be notified when the event occurs.

Step 1: Define the Observer and Subject Interfaces

Create interfaces for the observer and the subject:

// src/app/event-system/observer.ts
export interface Observer {
  update(data: any): void;
}

// src/app/event-system/subject.ts
import { Observer } from "./observer";

export interface EventSubject {
  subscribe(observer: Observer): void;
  unsubscribe(observer: Observer): void;
  notify(data: any): void;
}

Step 2: Implement the Subject

Implement the EventManager class:

// src/app/event-system/event-manager.ts
import { EventSubject } from "./subject";
import { Observer } from "./observer";

export class EventManager implements EventSubject {
  private observers: Observer[] = [];

  subscribe(observer: Observer): void {
    this.observers.push(observer);
  }

  unsubscribe(observer: Observer): void {
    this.observers = this.observers.filter((obs) => obs !== observer);
  }

  notify(data: any): void {
    this.observers.forEach((observer) => observer.update(data));
  }
}

Step 3: Implement Concrete Observers

Implement concrete observers:

// src/app/event-system/console-logger.ts
import { Observer } from "./observer";

export class ConsoleLogger implements Observer {
  update(data: any): void {
    console.log("ConsoleLogger:", data);
  }
}

// src/app/event-system/alert-logger.ts
import { Observer } from "./observer";

export class AlertLogger implements Observer {
  update(data: any): void {
    alert("AlertLogger: " + data);
  }
}

Step 4: Use the Event System in a Component

Use the event system in an Angular component:

// src/app/app.component.ts
import { Component } from '@angular/core';
import { EventManager } from './event-system/event-manager';
import { ConsoleLogger } from './event-system/console-logger';
import { AlertLogger } from './event-system/alert-logger';

@Component({
  selector: 'app-observer-demo',
  standalone: true,
  template: `<h1>Observer Demo</h1>`
})
export class ObserverAppComponent {
  title = 'design-patterns-demo';

  constructor() {
    const eventManager = new EventManager();
    const consoleLogger = new ConsoleLogger();
    const alertLogger = new AlertLogger();

    eventManager.subscribe(consoleLogger);
    eventManager.subscribe(alertLogger);
    eventManager.notify('Event 1 occurred');

    eventManager.unsubscribe(alertLogger);
    eventManager.notify('Event 2 occurred');
  }
}

3. Singleton Pattern

The Singleton Pattern ensures a class has only one instance and provides a global point of access to it. Outsiders or importers cannot directly create an instance of the class. One key detail to note is that its constructor has a private access modifier.

Example: Creating a Logger Service

Let's create a logger service that follows the Singleton Pattern.

Step 1: Implement the Singleton Logger

Implement the Logger class:

// src/app/logger/logger.ts
export class Logger {
  private static instance: Logger;

  private constructor() {}

  static getInstance(): Logger {
    if (!Logger.instance) {
      Logger.instance = new Logger();
    }
    return Logger.instance;
  }

  log(message: string): void {
    console.log(message);
  }
}

Step 2: Use the Singleton Logger in a Component

Use the singleton logger in an Angular component:

// src/app/app.component.ts
import { Component } from "@angular/core";
import { Logger } from "./logger/logger";

@Component({
  selector: "app-singleton-demo",
  standalone: true,
  template: `<h1>Singleton Demo</h1>`
})
export class SingletonAppComponent {
  title = "design-patterns-demo";

  constructor() {
    const logger1 = Logger.getInstance();
    logger1.log("Logger 1 instance");

    const logger2 = Logger.getInstance();
    logger2.log("Logger 2 instance");

    console.log("logger1 === logger2:", logger1 === logger2); // true
  }
}

4. Adapter Pattern

The Adapter Pattern allows incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces.

Example: Adapting a Legacy Service

Let's assume you have a legacy logging service that you want to use in your Angular application but its interface is different from what your application expects.

Step 1: Define the Target Interface

Define the interface that your application expects:

// src/app/logger/logger.ts
export interface Logger {
  log(message: string): void;
}

Step 2: Implement the Legacy Service

Implement the legacy logging service:

// src/app/logger/legacy-logger.ts
export class LegacyLogger {
  writeLog(msg: string): void {
    console.log(`LegacyLogger: ${msg}`);
  }
}

Step 3: Create the Adapter

Create an adapter that makes the legacy logger compatible with the Logger interface:

// src/app/logger/legacy-logger-adapter.ts
import { Logger } from "./logger";
import { LegacyLogger } from "./legacy-logger";

export class LegacyLoggerAdapter implements Logger {
  private legacyLogger: LegacyLogger;

  constructor() {
    this.legacyLogger = new LegacyLogger();
  }

  log(message: string): void {
    this.legacyLogger.writeLog(message);
  }
}

Step 4: Use the Adapter in a Component

Use the adapter in an Angular component:

// src/app/app.component.ts
import { Component } from "@angular/core";
import { Logger } from "./logger/logger";
import { LegacyLoggerAdapter } from "./logger/legacy-logger-adapter";

@Component({
  selector: "app-adapter-demo",
  standalone: true,
  template: `<h1>Adapter Demo</h1>`
})
export class AdapterAppComponent {
  title = "design-patterns-demo";

  constructor() {
    const logger: Logger = new LegacyLoggerAdapter();
    logger.log("Hello using Legacy Logger");
  }
}

5. Decorator Pattern

The Decorator Pattern allows behavior to be added to an individual object, dynamically, without affecting the behavior of other objects from the same class.

Example: Extending Logger Functionality

Let's extend the functionality of our Logger to include timestamps.

Step 1: Create the Base Logger Class

Define the base logger class:

// src/app/logger/simple-logger.ts
import { Logger } from "./logger";

export class SimpleLogger implements Logger {
  log(message: string): void {
    console.log(message);
  }
}

Step 2: Create the Decorator

Create a decorator that adds a timestamp to the log messages:

// src/app/logger/timestamp-logger.ts
import { Logger } from "./logger";

export class TimestampLogger implements Logger {
  constructor(private logger: Logger) {}

  log(message: string): void {
    const timestamp = new Date().toISOString();
    this.logger.log(`[${timestamp}] ${message}`);
  }
}

Step 3: Use the Decorator in a Component

Use the decorator in an Angular component:

// src/app/app.component.ts
import { Component } from "@angular/core";
import { Logger } from "./logger/logger";
import { SimpleLogger } from "./logger/simple-logger";
import { TimestampLogger } from "./logger/timestamp-logger";

@Component({
  selector: "app-decorator-demo",
  standalone: true,
  template: `<h1>Decorator Demo</h1>`
})
export class DecoratorAppComponent {
  title = "design-patterns-demo";

  constructor() {
    const simpleLogger: Logger = new SimpleLogger();
    const timestampLogger: Logger = new TimestampLogger(simpleLogger);

    simpleLogger.log("Simple Logger message");
    timestampLogger.log("Timestamped Logger message");
  }
}

6. Strategy Pattern

The Strategy Pattern defines a group of algorithms, encapsulates each one, and allows them to be swapped out interchangeably. This approach enables the algorithm to evolve independently of the clients that utilize it. A practical example of this pattern is offering users various options/strategies when sorting data.

Example: Implementing Different Sorting Strategies

Let's implement different sorting strategies that can be used interchangeably.

Step 1: Define the Strategy Interface

Create an interface for the sorting strategy:

// src/app/sorting/sorting-strategy.ts
export interface SortingStrategy {
  sort(data: number[]): number[];
}

Step 2: Implement Concrete Strategies

Implement different sorting strategies:

// src/app/sorting/bubble-sort.ts
import { SortingStrategy } from './sorting-strategy';

export class BubbleSort implements SortingStrategy {
  sort(data: number[]): number[] {
    const arr = [...data];
    for (let i = 0; i < arr.length; i++) {
      for (let j = 0; j < arr.length - 1 - i; j++) {
        if (arr[j] > arr[j + 1]) {
          [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
        }
      }
    }
    return arr;
  }
}

// src/app/sorting/quick-sort.ts
import { SortingStrategy } from './sorting-strategy';

export class QuickSort implements SortingStrategy {
  sort(data: number[]): number[] {
    if (data.length <= 1) {
      return data;
    }
    const pivot = data[0];
    const left = data.slice(1).filter(x => x < pivot);
    const right = data.slice(1).filter(x => x >= pivot);
    return [...this.sort(left), pivot, ...this.sort(right)];
  }
}

Step 3: Create a Context Class

Create a context class that uses a sorting strategy:

// src/app/sorting/sorter.ts
import { SortingStrategy } from "./sorting-strategy";

export class Sorter {
  constructor(private strategy: SortingStrategy) {}

  setStrategy(strategy: SortingStrategy): void {
    this.strategy = strategy;
  }

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

Step 4: Use the Strategies in a Component

Use the sorting strategies in an Angular component:

// src/app/app.component.ts
import { Component } from "@angular/core";
import { Sorter } from "./sorting/sorter";
import { BubbleSort } from "./sorting/bubble-sort";
import { QuickSort } from "./sorting/quick-sort";

@Component({
  selector: "app-strategy-demo",
  standalone: true,
  template: `<h1>Strategy Demo</h1>`
})
export class StrategyAppComponent {
  title = "design-patterns-demo";

  constructor() {
    const data = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5];

    const sorter = new Sorter(new BubbleSort());
    console.log("Bubble Sort:", sorter.sort(data));

    sorter.setStrategy(new QuickSort());
    console.log("Quick Sort:", sorter.sort(data));
  }
}

Conclusion

In this article, we've explored several essential design patterns — Factory Pattern, Observer Pattern, Singleton Pattern, Adapter Pattern, Decorator Pattern, and Strategy Pattern — using TypeScript and Angular. These patterns provide robust solutions to common software design problems and enhance code quality and maintainability.

You can read more on design patterns: https://refactoring.guru/design-patterns.

Experiment with these patterns in your projects to see how they can improve your software development process. Happy coding!

Share this article