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!