Skip to content
Back to blog

Design Patterns

A summary of common design patterns.

Updated

Singleton Pattern

The singleton pattern has several important characteristics:

  • Pros:

    • Guarantees that a class has only one instance. (Same as module global state)
    • Provides a global access point.
    • Initializes only when the singleton object is first requested. (Different from module global state)
  • Cons:

    • Violates the single-responsibility principle.
    • Masks bad design; high coupling between the components of a program.
    • Requires special handling in multi-threaded environments to avoid creating the singleton object multiple times.
class Singleton {
  /** Globally stores the unique instance */
  private static instance: Singleton

  /** Tests whether the instance is the same one */
  private connectionCount = 0

  /** Supports creating the singleton with new */
  constructor() {
    if (!Singleton.instance) {
      // If no instance has been created, use this as the instance and save it in the static member
      // Cannot call getInstance from the static method here, otherwise an infinite loop occurs
      Singleton.instance = this
    }

    return Singleton.instance
  }

  /** Supports getting the singleton through a static method */
  public static getInstance(): Singleton {
    if (!Singleton.instance) {
      Singleton.instance = new Singleton()
    }
    return Singleton.instance
  }

  public incrementConnection() {
    this.connectionCount++
    return this.connectionCount
  }
}

const instance1 = Singleton.getInstance()
const instance2 = Singleton.getInstance()

console.log('Same instance?', instance1 === instance2)

console.log('Connection count:', instance1.incrementConnection()) // 1
console.log('Connection count:', instance1.incrementConnection()) // 2
console.log('Connection count:', instance2.incrementConnection()) // 3

Factory Pattern

The factory pattern is essentially a function that returns an object. Due to the particularities of JS, using an arrow function is simpler than the factory pattern, while using new class has lower memory overhead than a factory function.

  • Pros:

    • Improves code maintainability and extensibility.
    • Creating objects through a factory method avoids direct use of the new keyword, reducing code coupling.
    • Can return different types of objects as needed, increasing code flexibility.
  • Cons:

    • Increases system complexity, requiring additional factory classes or factory methods.
    • May increase the number of classes, raising maintenance costs.
    • In some cases may affect performance, because objects need to be created through the factory method.
interface Product {
  operation: () => string
}

class ConcreteProductA implements Product {
  public operation(): string {
    return 'Result of ConcreteProductA'
  }
}

class ConcreteProductB implements Product {
  public operation(): string {
    return 'Result of ConcreteProductB'
  }
}

function createProduct(type: string): Product {
  if (type === 'A') {
    return new ConcreteProductA()
  } else if (type === 'B') {
    return new ConcreteProductB()
  } else {
    throw new Error('Unknown product type')
  }
}

const productA = createProduct('A')
console.log(productA.operation()) // Result of ConcreteProductA

const productB = createProduct('B')
console.log(productB.operation()) // Result of ConcreteProductB

Strategy Pattern

The strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. The strategy pattern lets the algorithm vary independently from the clients that use it.

  • Pros:

    • You can swap the algorithm used inside an object at runtime.
    • You can isolate the implementation details of an algorithm from the code that uses it.
    • You can use composition instead of inheritance.
    • Open/closed principle. You can introduce new strategies without changing the context.
  • Cons:

    • If your algorithm rarely changes, there is no reason to introduce new classes and interfaces. Using this pattern would make the program overly complex.
    • Clients must be aware of the differences between strategies to select the appropriate one.
    • Many modern programming languages support function types, allowing you to implement different versions of an algorithm in a set of anonymous functions. Then you can use these functions exactly as you would use strategy objects, without extra classes and interfaces, keeping the code clean.
interface Strategy {
  execute: (a: number, b: number) => number
}

class ConcreteStrategyAdd implements Strategy {
  public execute(a: number, b: number): number {
    return a + b
  }
}

class ConcreteStrategySubtract implements Strategy {
  public execute(a: number, b: number): number {
    return a - b
  }
}

class ConcreteStrategyMultiply implements Strategy {
  public execute(a: number, b: number): number {
    return a * b
  }
}

class Context {
  private strategy: Strategy

  constructor(strategy: Strategy) {
    this.strategy = strategy
  }

  public setStrategy(strategy: Strategy) {
    this.strategy = strategy
  }

  public executeStrategy(a: number, b: number): number {
    return this.strategy.execute(a, b)
  }
}

const addStrategy = new ConcreteStrategyAdd()
const context = new Context(addStrategy)
console.log('Add: ', context.executeStrategy(1, 2)) // 1 + 2 = 3

const subtractStrategy = new ConcreteStrategySubtract()
context.setStrategy(subtractStrategy)
console.log('Subtract: ', context.executeStrategy(5, 3)) // 5 - 3 = 2

const multiplyStrategy = new ConcreteStrategyMultiply()
context.setStrategy(multiplyStrategy)
console.log('Multiply: ', context.executeStrategy(5, 2)) // 5 * 2 = 10

Proxy Pattern

The proxy pattern provides a substitute with the same API as an object, in order to control the object's behavior.

  • Pros:

    • You can control the service object without clients knowing.
    • You can manage the lifecycle of the service object if clients don't care about it.
    • The proxy works even if the service object isn't ready or is not available.
    • Open/closed principle. You can introduce new proxies without changing the service or clients.
  • Cons:

    • The code may become more complicated since you need to introduce a lot of new classes.
    • The response from the service might get delayed.
interface ThirdPartyTVLib {
  listVideos: () => string[]
  getVideoInfo: (id: string) => string
  downloadVideo: (id: string) => void
}

// The concrete third-party video service class, simulating fetching video info from a remote service
class ThirdPartyTVClass implements ThirdPartyTVLib {
  listVideos(): string[] {
    console.log('Fetching list of videos from remote service...')
    return ['Video1', 'Video2', 'Video3']
  }

  getVideoInfo(id: string): string {
    console.log(`Fetching info for video ${id} from remote service...`)
    return `Info about video ${id}`
  }

  downloadVideo(id: string): void {
    console.log(`Downloading video ${id} from remote service...`)
  }
}

// A caching proxy class that caches data from the third-party video service
class CachedTVClass implements ThirdPartyTVLib {
  private service: ThirdPartyTVLib
  private listCache: string[] | null = null
  private videoCache: { [key: string]: string } = {}
  private needReset: boolean = false

  constructor(service: ThirdPartyTVLib) {
    this.service = service
  }

  // Cache the video list
  listVideos(): string[] {
    if (this.listCache === null || this.needReset) {
      this.listCache = this.service.listVideos()
    }
    return this.listCache
  }

  // Cache video info
  getVideoInfo(id: string): string {
    if (!this.videoCache[id] || this.needReset) {
      this.videoCache[id] = this.service.getVideoInfo(id)
    }
    return this.videoCache[id]
  }

  // Cache video downloads
  downloadVideo(id: string): void {
    if (!this.videoCache[id] || this.needReset) {
      this.service.downloadVideo(id)
    }
  }
}

// The class that manages the video service
class TVManager {
  protected service: ThirdPartyTVLib

  constructor(service: ThirdPartyTVLib) {
    this.service = service
  }

  // Render the video page
  renderVideoPage(id: string): void {
    const info = this.service.getVideoInfo(id)
    console.log(`Rendering video page with info: ${info}`)
  }

  // Render the video list panel
  renderListPanel(): void {
    const list = this.service.listVideos()
    console.log(`Rendering list panel with videos: ${list.join(', ')}`)
  }

  // Respond to user input
  reactOnUserInput(): void {
    this.renderVideoPage('Video1')
    this.renderListPanel()
  }
}

// The application class, initializing the video manager
class Application {
  init(): void {
    const aTVService = new ThirdPartyTVClass()
    const aTVProxy = new CachedTVClass(aTVService)
    const manager = new TVManager(aTVProxy)
    manager.reactOnUserInput()
  }
}

const app = new Application()
app.init()

Publish/Subscribe Pattern

The publish/subscribe pattern allows objects to communicate by publishing and subscribing to events, without explicitly referencing each other.

  • Pros:

    • Improves loose coupling between components.
    • Allows event senders and receivers to evolve independently.
    • Provides a flexible way to handle events and messages.
  • Cons:

    • May increase system complexity.
    • Can cause memory leaks if not managed.
    • Debugging and tracing event flows can become difficult.
class PubSub {
  constructor() {
    this.messages = {}
    this.listeners = {}
  }

  // Add a publisher
  publish(type, content) {
    const existContent = this.messages[type]
    if (!existContent) {
      this.messages[type] = []
    }
    this.messages[type].push(content)
  }

  // Add a subscriber
  subscribe(type, cb) {
    const existListener = this.listeners[type]
    if (!existListener) {
      this.listeners[type] = []
    }
    this.listeners[type].push(cb)
  }

  // Notify
  notify(type) {
    const messages = this.messages[type]
    const subscribers = this.listeners[type] || []
    subscribers.forEach((cb, index) => cb(messages[index]))
  }
}

class Publisher {
  constructor(name, context) {
    this.name = name
    this.context = context
  }

  publish(type, content) {
    this.context.publish(type, content)
  }
}

class Subscriber {
  constructor(name, context) {
    this.name = name
    this.context = context
  }

  subscribe(type, cb) {
    this.context.subscribe(type, cb)
  }
}

const TYPE_A = 'music'
const TYPE_B = 'movie'
const TYPE_C = 'novel'

const pubsub = new PubSub()

const publisherA = new Publisher('publisherA', pubsub)
publisherA.publish(TYPE_A, 'we are young')
publisherA.publish(TYPE_B, 'the silicon valley')
const publisherB = new Publisher('publisherB', pubsub)
publisherB.publish(TYPE_A, 'stronger')
const publisherC = new Publisher('publisherC', pubsub)
publisherC.publish(TYPE_C, 'a brief history of time')

const subscriberA = new Subscriber('subscriberA', pubsub)
subscriberA.subscribe(TYPE_A, (res) => {
  console.log('subscriberA received', res)
})
const subscriberB = new Subscriber('subscriberB', pubsub)
subscriberB.subscribe(TYPE_C, (res) => {
  console.log('subscriberB received', res)
})
const subscriberC = new Subscriber('subscriberC', pubsub)
subscriberC.subscribe(TYPE_B, (res) => {
  console.log('subscriberC received', res)
})

pubsub.notify(TYPE_A)
pubsub.notify(TYPE_B)
pubsub.notify(TYPE_C)

Observer Pattern

The observer pattern lets you define a subscription mechanism to notify multiple objects that "observe" an object about any events that happen to it.

  • Pros:

    • Open/closed principle. You can introduce new subscriber classes without having to change the publisher's code (and vice versa if there's a publisher interface).
    • You can establish relations between objects at runtime.
  • Cons:

    • Subscribers are notified in random order.
// The base publisher class includes subscription management code and notification methods.
class EventManager {
  private listeners: { [eventType: string]: EventListener[] } = {}

  // Subscribe to an event
  public subscribe(eventType: string, listener: EventListener): void {
    if (!this.listeners[eventType]) {
      this.listeners[eventType] = []
    }
    this.listeners[eventType].push(listener)
  }

  // Unsubscribe from an event
  public unsubscribe(eventType: string, listener: EventListener): void {
    if (!this.listeners[eventType]) {
      return
    }
    this.listeners[eventType] = this.listeners[eventType].filter(l => l !== listener)
  }

  // Notify all subscribers
  public notify(eventType: string, data: any): void {
    if (!this.listeners[eventType]) {
      return
    }
    this.listeners[eventType].forEach(listener => listener.update(data))
  }
}

// The concrete publisher contains real business logic that's interesting to some subscribers.
class Editor {
  public events: EventManager
  private file: File | null = null

  constructor() {
    this.events = new EventManager()
  }

  // Open a file and notify the subscribers
  public openFile(path: string): void {
    this.file = new File(path)
    this.events.notify('open', this.file.name)
  }

  // Save the file and notify the subscribers
  public saveFile(): void {
    if (this.file) {
      this.file.write()
      this.events.notify('save', this.file.name)
    }
  }
}

// The subscriber interface
interface EventListener {
  update: (filename: string) => void
}

// Concrete subscribers react to updates
class LoggingListener implements EventListener {
  private log: File
  private message: string

  constructor(logFilename: string, message: string) {
    this.log = new File(logFilename)
    this.message = message
  }

  public update(filename: string): void {
    this.log.write(this.message.replace('%s', filename))
  }
}

class EmailAlertsListener implements EventListener {
  private email: string
  private message: string

  constructor(email: string, message: string) {
    this.email = email
    this.message = message
  }

  public update(filename: string): void {
    // Simulate sending an email
    console.log(`Email to ${this.email}: ${this.message.replace('%s', filename)}`)
  }
}

// The application configures publishers and subscribers
class Application {
  public config(): void {
    const editor = new Editor()

    const logger = new LoggingListener(
      '/path/to/log.txt',
      'Someone has opened the file: %s'
    )
    editor.events.subscribe('open', logger)

    const emailAlerts = new EmailAlertsListener(
      'admin@example.com',
      'Someone has changed the file: %s'
    )
    editor.events.subscribe('save', emailAlerts)
  }
}