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
moduleglobal state) - Provides a global access point.
- Initializes only when the singleton object is first requested. (Different from
moduleglobal state)
- Guarantees that a class has only one instance. (Same as
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
newkeyword, 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)
}
}