Design Patterns: The Superheroes of Software Architecture

Design Patterns: The Superheroes of Software Architecture

A Deep Dive into the Key Design Patterns and their Importance in Creating Efficient and Scalable Systems.

Table of contents

No heading

No headings in the article.

Design patterns play a critical role in software architecture by providing proven solutions to commonly occurring software design problems. They are reusable templates that can be applied to different software development contexts, helping software architects to create efficient, reliable, and scalable systems. Let's take a closer look at some of the popular design patterns.

  • Singleton Pattern

The Singleton pattern is a design pattern that ensures that a class has only one instance and provides a global point of access to that instance. This pattern is useful when we need to limit the number of instances of a class, such as when we're dealing with resources that can only be accessed by a single instance, like a database connection.

Suppose you are building a web application that interacts with a backend server using a RESTful API. You need to ensure that there is only one instance of the API client class created throughout the application and that all components use the same instance to interact with the server.

Here's an example implementation of the Singleton pattern for this scenario in TypeScript:

class APIClient {
  private static instance: APIClient | null = null;

  private constructor() {
    // Initialize the API client
  }

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

  // Other methods to interact with the API
}

const apiClient = APIClient.getInstance();
export default apiClient;

In this implementation, the APIClient class has a private constructor to prevent other objects from creating instances of the class. The static getInstance() method provides access to the single instance of the class, which is created on the first call to the method and returned on subsequent calls.

By using the Singleton pattern in this way, you can ensure that all components in your TypeScript application use the same instance of the APIClient class to interact with the server, which can help avoid inconsistencies and improve performance by reducing the overhead of creating multiple instances of the class.

Common use cases of singleton pattern include database connections, logging, configuration settings, caching, and GUI components. The pattern helps manage resources efficiently and avoid conflicts that can arise when multiple instances of a class are created.

In React, the ReactDOM object, which is used to interact with the DOM, is implemented as a Singleton. This ensures that only one instance of the ReactDOM object is created and shared across the application, allowing for efficient updates to the DOM. Additionally, other React components, such as the React.Children and React.PropTypes, are also implemented as Singletons to provide global access to these objects throughout the application.

  • Factory Pattern

The Factory pattern is used when we want to create objects without specifying the exact class of object that will be created. This pattern is useful when we want to decouple the creation of objects from the code that uses those objects, making our code more flexible and easier to maintain.

Let's take an example of payment gateways in an e-commerce application. In an e-commerce application different payment gateways can be used to process transactions, such as PayPal, Stripe, or Square. The application can use the Factory pattern to create different payment gateway objects, each responsible for processing payments using their respective API. Each payment gateway object can implement the same interface, but the specific implementation would differ depending on the payment gateway being used.

Here's an example implementation of the Factory pattern for this scenario in TypeScript:

// PaymentGateway interface
interface PaymentGateway {
  processPayment(amount: number): boolean;
}

// PayPalPaymentGateway class
class PayPalPaymentGateway implements PaymentGateway {
  processPayment(amount: number): boolean {
    console.log(`Processing payment of $${amount} using PayPal...`);
    // Implementation of PayPal payment processing logic
    return true;
  }
}

// StripePaymentGateway class
class StripePaymentGateway implements PaymentGateway {
  processPayment(amount: number): boolean {
    console.log(`Processing payment of $${amount} using Stripe...`);
    // Implementation of Stripe payment processing logic
    return true;
  }
}

// PaymentGatewayFactory class
class PaymentGatewayFactory {
  static createPaymentGateway(gatewayType: string): PaymentGateway {
    switch(gatewayType) {
      case 'paypal':
        return new PayPalPaymentGateway();
      case 'stripe':
        return new StripePaymentGateway();
      default:
        throw new Error(`Unsupported payment gateway type: ${gatewayType}`);
    }
  }
}

// Usage example
const gatewayType = 'paypal'; // This value can be read from configuration or user input
const paymentGateway = PaymentGatewayFactory.createPaymentGateway(gatewayType);
paymentGateway.processPayment(100); // Process a payment of $100 using the PayPalPaymentGateway object

In this example, we define a PaymentGateway interface that defines the processPayment method. We then create two classes that implement this interface: PayPalPaymentGateway and StripePaymentGateway. Each class has its own implementation of the processPayment method.

We then define a PaymentGatewayFactory class that has a static method createPaymentGateway that takes a gatewayType parameter and returns a PaymentGateway object based on the type specified. In this case, the factory can create PayPalPaymentGateway or StripePaymentGateway objects.

Finally, we use the PaymentGatewayFactory to create a PaymentGateway object based on the gatewayType value (in this case, paypal). We then call the processPayment method on the created object to process a payment of $100.

  • Observer Pattern

The Observer pattern is used when we want to establish a one-to-many relationship between objects, where changes to one object are automatically reflected in all other objects that depend on it. This pattern is useful when we want to implement event-driven systems, such as GUIs, where user actions trigger updates to various parts of the system.

Many user interface frameworks, such as Angular and React, use the Observer pattern to implement a reactive programming model. The framework observes changes in the application state and updates the UI accordingly.

Lets take an example of a stock market ticker implemented using the Observer pattern in TypeScript:

interface Observer {
  update(stock: string, price: number): void;
}

class StockMarketTicker {
  private observers: Observer[] = [];

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

  public removeObserver(observer: Observer): void {
    const index = this.observers.indexOf(observer);
    if (index !== -1) {
      this.observers.splice(index, 1);
    }
  }

  public updateStockPrice(stock: string, price: number): void {
    console.log(`Updating price of ${stock} to ${price}`);
    this.notifyObservers(stock, price);
  }

  private notifyObservers(stock: string, price: number): void {
    for (const observer of this.observers) {
      observer.update(stock, price);
    }
  }
}

class StockPriceDisplay implements Observer {
  private stock: string;

  constructor(stock: string) {
    this.stock = stock;
  }

  public update(stock: string, price: number): void {
    if (stock === this.stock) {
      console.log(`Price of ${stock} has been updated to ${price}`);
    }
  }
}

// create the stock market ticker
const ticker = new StockMarketTicker();

// create some stock price displays
const appleDisplay = new StockPriceDisplay("AAPL");
const microsoftDisplay = new StockPriceDisplay("MSFT");

// add the displays as observers
ticker.addObserver(appleDisplay);
ticker.addObserver(microsoftDisplay);

// update the stock prices
ticker.updateStockPrice("AAPL", 135.50);
ticker.updateStockPrice("MSFT", 250.75);

// remove one of the displays as an observer
ticker.removeObserver(microsoftDisplay);

// update the stock price again
ticker.updateStockPrice("AAPL", 137.25);

In this example, we have a StockMarketTicker class that represents the subject being observed. It maintains a list of observers that are interested in updates to the stock prices. The addObserver and removeObserver methods are used to add and remove observers from the list.

The StockPriceDisplay class represents an observer. It has a update method that is called when the subject updates its state. In this case, the method checks if the updated stock price is for the stock that it is interested in, and if so, it logs the updated price.

Finally, we create a StockMarketTicker instance and some StockPriceDisplay instances, and add the displays as observers to the ticker. We then update the stock prices and see that the displays are notified of the updates. We also remove one of the displays as an observer and update the stock price again to see that the removed display no longer receives updates.

  • Decorator Pattern

The Decorator pattern is used when we want to add behaviour to an object dynamically without changing its class. This pattern is useful when we want to add functionality to an object without creating a subclass for each new combination of behaviours.

Imagine you are developing an e-commerce application where users can purchase products and manage their accounts. In this application, you want to implement different levels of authorization and authentication to ensure that users can only access the features they are authorized to use.

To achieve this, you can use the Decorator pattern to add authorization and authentication features to the base user class. You can create a base user class that has basic information such as username, password, and email. Then, you can create decorators that add additional authorization and authentication features to the base user class.

For example, you can create a decorator called "AdminUser" that adds admin-level authorization and authentication features to the base user class. This decorator can add features such as the ability to edit and delete products, manage user accounts, and view reports.

Similarly, you can create a decorator called "PremiumUser" that adds premium-level authorization and authentication features to the base user class. This decorator can add features such as the ability to access exclusive products, receive discounts, and view order history.

These decorators can be applied to individual user objects dynamically, based on the user's role and privileges. For example, when a user logs in, the system can check the user's role and apply the appropriate decorator to their user object.

This approach allows you to create a flexible and scalable authorization and authentication system that can be customized for different user roles and privileges. It also allows you to add new features and roles to the system without modifying the base user class.

Here's an example of an e-commerce application using the Decorator pattern for authorization and authentication:

interface IUser {
  username: string;
  password: string;
  email: string;
}

class BaseUser implements IUser {
  constructor(
    public username: string,
    public password: string,
    public email: string
  ) {}
}

class UserDecorator implements IUser {
  constructor(private user: IUser) {}

  get username() {
    return this.user.username;
  }

  set username(username: string) {
    this.user.username = username;
  }

  get password() {
    return this.user.password;
  }

  set password(password: string) {
    this.user.password = password;
  }

  get email() {
    return this.user.email;
  }

  set email(email: string) {
    this.user.email = email;
  }
}

class AdminUser extends UserDecorator {
  editProduct(productId: number) {
    // Logic for editing a product
  }

  deleteProduct(productId: number) {
    // Logic for deleting a product
  }

  manageUsers() {
    // Logic for managing user accounts
  }

  viewReports() {
    // Logic for viewing reports
  }
}

class PremiumUser extends UserDecorator {
  accessExclusiveProducts() {
    // Logic for accessing exclusive products
  }

  receiveDiscount() {
    // Logic for receiving discounts
  }

  viewOrderHistory() {
    // Logic for viewing order history
  }
}

// Usage example
const baseUser = new BaseUser("johndoe", "password123", "johndoe@example.com");
const adminUser = new AdminUser(baseUser);
adminUser.editProduct(1);
adminUser.deleteProduct(2);

const premiumUser = new PremiumUser(baseUser);
premiumUser.accessExclusiveProducts();
premiumUser.receiveDiscount();
  • Adapter Pattern

The Adapter pattern is a design pattern that allows two incompatible interfaces to work together by creating a middle layer or "adapter" between them. The adapter converts the interface of one class into another interface that the client expects.

The Adapter pattern is useful in situations where two existing interfaces cannot work together directly. Instead of modifying the existing interfaces, the Adapter pattern creates a new class that acts as a bridge between them.

For example, imagine you have an application that uses a third-party library to retrieve data from a database. The library has its own interface for retrieving data, which is incompatible with the interface used by your application. Rather than modifying the library or your application, you can create an adapter class that translates between the two interfaces.

Here is an example of how the Adapter pattern can be used in TypeScript:

// Define the incompatible interface used by the third-party library
interface DatabaseInterface {
  getData(query: string): string[];
}

// Define the interface used by the client application
interface ClientInterface {
  fetchData(): string[];
}

// Create a concrete implementation of the DatabaseInterface
class Database implements DatabaseInterface {
  getData(query: string): string[] {
    // Implementation of database query here
    return ['result1', 'result2', 'result3'];
  }
}

// Create an adapter that translates between the two interfaces
class Adapter implements ClientInterface {
  private database: DatabaseInterface;

  constructor(database: DatabaseInterface) {
    this.database = database;
  }

  fetchData(): string[] {
    const query = 'SELECT * FROM table';
    const results = this.database.getData(query);
    // Adapt the results to the expected format
    return results.map(result => `Data: ${result}`);
  }
}

// Usage example
const database = new Database();
const adapter = new Adapter(database);

console.log(adapter.fetchData()); // ["Data: result1", "Data: result2", "Data: result3"]

In this example, we have defined two interfaces: DatabaseInterface, which is used by the third-party library, and ClientInterface, which is used by the client application. We have also defined a concrete implementation of DatabaseInterface and an adapter class called Adapter, which translates between the two interfaces. Finally, we have instantiated the database and the adapter, and used the adapter to retrieve data in the format expected by the client application.

  • Facade Pattern

The Facade pattern is a design pattern that provides a simplified interface to a complex system or set of classes. It acts as a mediator between the client and the subsystem, shielding the client from the complexities of the subsystem's components.

The Facade pattern is useful in situations where a complex system or set of classes needs to be used by a client application, but the client does not need to know the details of how the system works. Instead, the client can use a simplified interface provided by the Facade to access the system's functionality.

For example, imagine you have a multimedia player application that can play different types of media files, such as audio and video. The application has a complex set of classes that handle different aspects of media playback, such as codecs, decoders, and renderers. Rather than exposing all of these classes to the client, you can create a Facade class that provides a simplified interface for playing media files.

Here is an example of how the Facade pattern can be used in TypeScript:

// Complex subsystem classes
class Codec {}
class AudioCodec extends Codec {}
class VideoCodec extends Codec {}

class Decoder {}
class AudioDecoder extends Decoder {}
class VideoDecoder extends Decoder {}

class Renderer {}
class AudioRenderer extends Renderer {}
class VideoRenderer extends Renderer {}

// Facade class that simplifies the interface to the subsystem
class MultimediaPlayer {
  private audioCodec: AudioCodec;
  private audioDecoder: AudioDecoder;
  private audioRenderer: AudioRenderer;

  private videoCodec: VideoCodec;
  private videoDecoder: VideoDecoder;
  private videoRenderer: VideoRenderer;

  constructor() {
    this.audioCodec = new AudioCodec();
    this.audioDecoder = new AudioDecoder();
    this.audioRenderer = new AudioRenderer();

    this.videoCodec = new VideoCodec();
    this.videoDecoder = new VideoDecoder();
    this.videoRenderer = new VideoRenderer();
  }

  playAudio(file: string) {
    // Simplified interface to play audio files
    this.audioCodec.decode(file);
    this.audioDecoder.decode(file);
    this.audioRenderer.render(file);
  }

  playVideo(file: string) {
    // Simplified interface to play video files
    this.videoCodec.decode(file);
    this.videoDecoder.decode(file);
    this.videoRenderer.render(file);
  }
}

// Usage example
const player = new MultimediaPlayer();
player.playAudio('audio.mp3');
player.playVideo('video.mp4');

In this example, we have a set of complex subsystem classes that handle different aspects of media playback, such as codecs, decoders, and renderers. We have also created a Facade class called MultimediaPlayer that provides a simplified interface for playing audio and video files. The MultimediaPlayer class shields the client from the complexities of the subsystem's components, allowing the client to use a simple interface to play media files.

  • Strategy Pattern

The Strategy pattern is a behavioural design pattern that allows you to define a family of algorithms, encapsulate each one, and make them interchangeable at runtime. This pattern allows the algorithm to vary independently from clients that use it.

In other words, the Strategy pattern is useful when you have a set of related algorithms or behaviors that can be used interchangeably, and you want to choose which one to use at runtime.

For example, imagine you have a shopping cart application where customers can purchase items. You want to offer different types of discounts to customers, such as a percentage discount, a fixed discount, or a buy-one-get-one-free discount. Instead of hardcoding each type of discount into the shopping cart code, you can use the Strategy pattern to define a family of discount algorithms, encapsulate each one, and make them interchangeable at runtime.

Here is an example of how the Strategy pattern can be used in TypeScript:

// Strategy interface
interface DiscountStrategy {
  calculateDiscount(price: number): number;
}

// Concrete strategies
class PercentageDiscountStrategy implements DiscountStrategy {
  private percentage: number;

  constructor(percentage: number) {
    this.percentage = percentage;
  }

  calculateDiscount(price: number): number {
    return price * (this.percentage / 100);
  }
}

class FixedDiscountStrategy implements DiscountStrategy {
  private amount: number;

  constructor(amount: number) {
    this.amount = amount;
  }

  calculateDiscount(price: number): number {
    return this.amount;
  }
}

class BuyOneGetOneFreeDiscountStrategy implements DiscountStrategy {
  calculateDiscount(price: number): number {
    return price;
  }
}

// Context class
class ShoppingCart {
  private items: number[];
  private discountStrategy: DiscountStrategy;

  constructor() {
    this.items = [];
  }

  setDiscountStrategy(strategy: DiscountStrategy) {
    this.discountStrategy = strategy;
  }

  addItem(item: number) {
    this.items.push(item);
  }

  getTotal(): number {
    const price = this.items.reduce((acc, item) => acc + item, 0);
    const discount = this.discountStrategy.calculateDiscount(price);
    return price - discount;
  }
}

// Usage example
const cart = new ShoppingCart();
cart.addItem(10);
cart.addItem(20);

cart.setDiscountStrategy(new PercentageDiscountStrategy(10));
console.log(cart.getTotal()); // Output: 27

cart.setDiscountStrategy(new FixedDiscountStrategy(5));
console.log(cart.getTotal()); // Output: 25

cart.setDiscountStrategy(new BuyOneGetOneFreeDiscountStrategy());
console.log(cart.getTotal()); // Output: 20

In this example, we have defined a family of discount algorithms using the DiscountStrategy interface. We have also defined concrete strategies such as PercentageDiscountStrategy, FixedDiscountStrategy, and BuyOneGetOneFreeDiscountStrategy. We have a context class called ShoppingCart that uses a discount strategy to calculate the total price of items in the cart. We can change the discount strategy at runtime by calling the setDiscountStrategy method of the ShoppingCart class, which makes the different discount strategies interchangeable.

In conclusion, design patterns are the superheroes of software architecture that help developers solve common software problems efficiently and effectively. Whether you're building a small application or a large enterprise system, understanding and applying design patterns is an essential skill for any software developer. So next time you're faced with a difficult software problem, remember to consult the superhero of software architecture: design patterns!