Object oriented design pattern
What is a Design Pattern?
Design patterns are established solutions created to solve common problems developers encounter.
Why Do We Need Design Patterns?
The answer is simple: they help us solve recurring issues more efficiently and consistently.
How Do We Apply Them?
By implementing the right design patterns along with proper design principles. (I know design principles could be a new topic, but we’ll discuss them later. For now, just keep in mind that design principles are guidelines that help us avoid potential issues.)
“Design Patterns: Elements of Reusable Object-Oriented Software” by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides, collectively known as the “Gang of Four” (GoF). In this classic book, they introduce 23 foundational design patterns, which are categorized into Creational, Structural, and Behavioural patterns.
1. Creational Patterns
Creational patterns are concerned with the way objects are created. They provide mechanisms to instantiate objects in a way that suits the situation.
Abstract Factory:
The Abstract Factory Pattern is an extension of the Factory Pattern that allows the creation of families of related objects without specifying their concrete classes. It is commonly used when a system needs to be independent of the way its objects are created, composed, and represented. This pattern is useful for applications that work with multiple families of products (like different UI components for different operating systems) and need to enforce consistency between them.
Why We Need the Abstract Factory Pattern
- Consistency Across Related Products: The Abstract Factory ensures that products from the same family (e.g., Windows UI components or Mac UI components) are compatible with each other.
- Easier Product Family Management: When there are multiple related products, an Abstract Factory provides a centralized location to manage them.
- Scalability: You can easily add new product families by introducing new factory classes, keeping client code unchanged.
Before Applying the Abstract Factory Pattern
Suppose we need to create related UI components, Button
and Checkbox
, for different operating systems (Windows and Mac).
// Button and Checkbox interfaces
interface Button {
render(): void;
}
interface Checkbox {
check(): void;
}
// Concrete Button and Checkbox classes for Windows
class WindowsButton implements Button {
render() {
console.log("Rendering Windows button");
}
}
class WindowsCheckbox implements Checkbox {
check() {
console.log("Checking Windows checkbox");
}
}
// Concrete Button and Checkbox classes for Mac
class MacButton implements Button {
render() {
console.log("Rendering Mac button");
}
}
class MacCheckbox implements Checkbox {
check() {
console.log("Checking Mac checkbox");
}
}
// Client code
function createUIComponents(osType: string): { button: Button; checkbox: Checkbox } {
if (osType === "Windows") {
return {
button: new WindowsButton(),
checkbox: new WindowsCheckbox()
};
} else if (osType === "Mac") {
return {
button: new MacButton(),
checkbox: new MacCheckbox()
};
} else {
throw new Error("Unsupported OS type");
}
}
// Usage
const { button, checkbox } = createUIComponents("Windows");
button.render();
checkbox.check();
Issues with this approach:
- Code Duplication and Tight Coupling: The client code depends on specific classes (like
WindowsButton
andMacCheckbox
), which makes it difficult to extend with new operating systems. - Difficult to Add Product Families: Adding a new OS (e.g., Linux) would require modifying the client code to add new
Button
andCheckbox
classes, violating the Open-Closed Principle.
After Applying the Abstract Factory Pattern
With the Abstract Factory, we can encapsulate the creation of related objects within separate factory classes for each OS. The client code only interacts with the abstract factory interface, not with specific classes.
// Abstract factory interfaces for Button and Checkbox
interface Button {
render(): void;
}
interface Checkbox {
check(): void;
}
// Abstract factory interface for creating related products
interface UIFactory {
createButton(): Button;
createCheckbox(): Checkbox;
}
// Concrete factories for Windows
class WindowsFactory implements UIFactory {
createButton(): Button {
return new WindowsButton();
}
createCheckbox(): Checkbox {
return new WindowsCheckbox();
}
}
// Concrete factories for Mac
class MacFactory implements UIFactory {
createButton(): Button {
return new MacButton();
}
createCheckbox(): Checkbox {
return new MacCheckbox();
}
}
// Concrete Button and Checkbox classes for Windows
class WindowsButton implements Button {
render() {
console.log("Rendering Windows button");
}
}
class WindowsCheckbox implements Checkbox {
check() {
console.log("Checking Windows checkbox");
}
}
// Concrete Button and Checkbox classes for Mac
class MacButton implements Button {
render() {
console.log("Rendering Mac button");
}
}
class MacCheckbox implements Checkbox {
check() {
console.log("Checking Mac checkbox");
}
}
// Client code
function initializeUI(factory: UIFactory) {
const button = factory.createButton();
const checkbox = factory.createCheckbox();
button.render();
checkbox.check();
}
// Usage
const factory = new WindowsFactory();
initializeUI(factory);
Advantages of this approach:
- Single Responsibility and Clean Code: Each factory is responsible for creating objects of a specific family, so the client code does not need to change when adding new OS types.
- Consistency in Product Families: The Abstract Factory enforces that only compatible components (like
WindowsButton
andWindowsCheckbox
) are used together, ensuring UI consistency. - Scalability: To add support for a new OS, we can simply create a new factory (e.g.,
LinuxFactory
) that implements theUIFactory
interface, without modifying the client code.
Builder:
The Builder Pattern is a creational design pattern used when you want to construct complex objects step-by-step, rather than all at once through a constructor. It allows for more control over the construction process, especially when objects have multiple fields or require validation for specific configurations. The pattern is particularly useful when creating different representations of a product or when an object has optional parameters.
Why Use the Builder Pattern?
- Improves Readability: If a class has many optional fields or complex initialization, using a builder can make the code more readable than a long constructor with numerous parameters.
- Separates Construction and Representation: It allows for complex object construction to be separated from its representation, which makes the code more flexible and easier to maintain.
- Avoids Constructor Overload: With a builder, you don’t need to create multiple constructors to handle different combinations of parameters.
- Encourages Immutability: Builders can help create immutable objects by only allowing setting values during construction and then producing a final, unmodifiable object.
Example: Before Applying the Builder Pattern
Suppose we have a House
class that requires several fields to be set, but many of them are optional.
class House {
private foundation: string;
private structure: string;
private roof: string;
private hasGarden: boolean;
private hasPool: boolean;
private hasGarage: boolean;
constructor(foundation: string, structure: string, roof: string, hasGarden?: boolean, hasPool?: boolean, hasGarage?: boolean) {
this.foundation = foundation;
this.structure = structure;
this.roof = roof;
this.hasGarden = hasGarden || false;
this.hasPool = hasPool || false;
this.hasGarage = hasGarage || false;
}
}
// Usage
const house = new House("Concrete", "Wood", "Shingles", true, false, true);
In this case:
- The constructor becomes hard to read when many optional parameters are involved.
- It’s not obvious what each
true
orfalse
stands for, leading to readability issues.
After Applying the Builder Pattern
Using the Builder Pattern, we can improve readability and flexibility by breaking down the object construction process.
// House class with a nested Builder class
class House {
private foundation: string;
private structure: string;
private roof: string;
private hasGarden: boolean;
private hasPool: boolean;
private hasGarage: boolean;
private constructor(builder: HouseBuilder) {
this.foundation = builder.foundation;
this.structure = builder.structure;
this.roof = builder.roof;
this.hasGarden = builder.hasGarden;
this.hasPool = builder.hasPool;
this.hasGarage = builder.hasGarage;
}
static get Builder() {
return new HouseBuilder();
}
}
// Builder class
class HouseBuilder {
foundation!: string;
structure!: string;
roof!: string;
hasGarden: boolean = false;
hasPool: boolean = false;
hasGarage: boolean = false;
setFoundation(foundation: string): this {
this.foundation = foundation;
return this;
}
setStructure(structure: string): this {
this.structure = structure;
return this;
}
setRoof(roof: string): this {
this.roof = roof;
return this;
}
addGarden(): this {
this.hasGarden = true;
return this;
}
addPool(): this {
this.hasPool = true;
return this;
}
addGarage(): this {
this.hasGarage = true;
return this;
}
build(): House {
return new House(this);
}
}
// Usage
const house = House.Builder
.setFoundation("Concrete")
.setStructure("Wood")
.setRoof("Shingles")
.addGarden()
.addGarage()
.build();
Advantages of Using the Builder Pattern:
- Clearer Initialization: It’s now clear what each option represents, and you only specify what you need.
- Optional Parameters Handling: Only the fields that need customization are set, which is helpful when dealing with optional fields.
- Better Code Organization: The
House
class focuses on what aHouse
is, whileHouseBuilder
focuses on the construction details, improving code organization and readability.
When to Use the Builder Pattern
Use the Builder Pattern when:
- The object has many optional parameters.
- You want to ensure the construction of the object is consistent.
- You want to separate the construction logic from the representation.
In summary, the Builder Pattern is a great way to handle complex object creation with readability and flexibility, especially when dealing with many optional parameters or configurations. It is commonly used in applications that require immutable objects or require complex object creation processes.
Factory Method:
The Factory Pattern is a creational design pattern that provides a way to create objects without specifying the exact class of the object that will be created. It helps manage and centralize object creation, making it easier to add new types of objects without modifying the client code that depends on them.
Why We Need the Factory Pattern
- Decoupling: The Factory Pattern reduces the dependency of the client code on concrete classes, allowing it to depend on interfaces or abstract classes instead. This makes the code more flexible and easier to extend.
- Centralized Creation Logic: Centralizing object creation logic in a factory makes it easier to manage and reuse. It also makes it easier to apply changes, such as adding caching or singleton behavior, to all instances of a particular type.
- Ease of Extension: When new types are added, the factory pattern allows us to incorporate them without changing the client code.
Before Applying the Factory Pattern
Let’s assume we have different types of Button
objects (WindowsButton
and MacButton
), and we want to create instances of these in the client code.
// Button interface
interface Button {
render(): void;
}
// Concrete Button classes
class WindowsButton implements Button {
render() {
console.log("Rendering Windows button");
}
}
class MacButton implements Button {
render() {
console.log("Rendering Mac button");
}
}
// Client code
function createButton(osType: string): Button {
if (osType === "Windows") {
return new WindowsButton();
} else if (osType === "Mac") {
return new MacButton();
} else {
throw new Error("Unsupported OS type");
}
}
// Usage
const button = createButton("Windows");
button.render();
Issues with this approach:
- Tight Coupling: The client code directly depends on the specific button classes (
WindowsButton
andMacButton
). - Difficult to Extend: Adding a new type of button (e.g.,
LinuxButton
) would require modifying thecreateButton
function, which violates the Open-Closed Principle (OCP).
After Applying the Factory Pattern
With the Factory Pattern, we encapsulate the creation of objects into a separate ButtonFactory
class. This allows us to extend button types without modifying the client code.
// Button interface remains the same
interface Button {
render(): void;
}
// Concrete Button classes
class WindowsButton implements Button {
render() {
console.log("Rendering Windows button");
}
}
class MacButton implements Button {
render() {
console.log("Rendering Mac button");
}
}
// Factory class
class ButtonFactory {
static createButton(osType: string): Button {
switch (osType) {
case "Windows":
return new WindowsButton();
case "Mac":
return new MacButton();
default:
throw new Error("Unsupported OS type");
}
}
}
// Usage
const button = ButtonFactory.createButton("Windows");
button.render();
Advantages of this approach:
- Decoupling: The client code only interacts with the
Button
interface and theButtonFactory
, not the concrete button classes. - Easy to Extend: To add a new button type (e.g.,
LinuxButton
), you only need to modify theButtonFactory
, not the client code. The client code stays the same.
The Factory Pattern provides a clean way to centralize object creation, which promotes maintainability and scalability. It makes it easy to add new types or modify object creation logic without disrupting the rest of the application.
Prototype
The Prototype Pattern is a creational design pattern that allows you to create new objects by copying (or “cloning”) an existing instance, known as a “prototype.” This pattern is particularly useful when the cost of creating a new object from scratch is expensive or complex, or when there are many similar objects that only differ in a few details.
Why Use the Prototype Pattern?
- Efficient Cloning: It allows for efficient object creation by duplicating an existing instance rather than creating it from scratch.
- Dynamic Object Creation: Enables you to create objects at runtime, particularly useful when object configurations are unknown beforehand or need to be generated dynamically.
- Reduces Subclassing: Instead of creating subclasses for every configuration of an object, you can simply clone and modify an existing object.
Example: Before Applying the Prototype Pattern
Imagine you have a Document
class with several fields. Without the Prototype Pattern, you would create new instances by setting all properties from scratch, which could be inefficient if you need many similar documents.
class Document {
title: string;
content: string;
author: string;
constructor(title: string, content: string, author: string) {
this.title = title;
this.content = content;
this.author = author;
}
}
// Usage
const doc1 = new Document("Title 1", "Content of document 1", "Author A");
const doc2 = new Document("Title 2", "Content of document 2", "Author A");
In this case, creating similar documents involves repeating the initialization logic, which can be inefficient or complex if the objects are large.
After Applying the Prototype Pattern
With the Prototype Pattern, you can create a clone
method that duplicates the existing Document
instance.
// Document class with a clone method
class Document {
title: string;
content: string;
author: string;
constructor(title: string, content: string, author: string) {
this.title = title;
this.content = content;
this.author = author;
}
clone(): Document {
return new Document(this.title, this.content, this.author);
}
}
// Usage
const originalDoc = new Document("Title 1", "Content of document 1", "Author A");
const clonedDoc = originalDoc.clone();
clonedDoc.title = "Title 2"; // Modify only what’s needed
Advantages of Using the Prototype Pattern:
- Simplifies Object Creation: The
clone
method makes it easy to create a copy of an object and modify only the necessary fields, which is helpful when objects are complex. - Dynamic and Flexible: It supports runtime object generation without needing to know the exact type in advance.
- Reduces Memory Usage: By copying an existing object rather than re-creating complex configurations, it can reduce the memory overhead.
When to Use the Prototype Pattern
Use the Prototype Pattern when:
- Creating new instances is resource-intensive.
- You need to create objects that are similar to existing instances but may vary slightly.
- You want to avoid the complexity of creating subclasses for every object configuration.
In summary, the Prototype Pattern is useful for creating complex or resource-intensive objects by cloning a pre-existing instance. It adds flexibility to object creation, reduces code duplication, and can improve efficiency, especially when dealing with a large number of similar objects.
Singleton
The Singleton Pattern is a creational design pattern that ensures a class has only one instance and provides a global point of access to that instance. It’s commonly used when you need only one instance of a class throughout the lifecycle of an application, such as for configuration settings, logging, database connections, or thread pools.
Why Use the Singleton Pattern?
- Controlled Access to a Single Instance: Ensures that only one instance of a class is created, which is useful when exactly one object is needed to coordinate actions.
- Global Access Point: Provides a single global access point to the instance, making it convenient to access from anywhere in the code.
- Lazy Initialization: Can be implemented to instantiate the object only when needed, potentially improving performance.
Example: Before Applying the Singleton Pattern
Without the Singleton Pattern, multiple instances of a configuration class can be created, which can lead to inconsistent data and inefficient resource use.
class Database {
private connection: string;
constructor(connection: string) {
this.connection = connection;
}
connect() {
console.log(`Connected to ${this.connection}`);
}
}
// Usage
const db1 = new Database("MySQL");
const db2 = new Database("PostgreSQL");
db1.connect();
db2.connect();
In this case, every time a Database
instance is created, a new connection is established. This can be inefficient if only one connection is necessary.
After Applying the Singleton Pattern
The Singleton Pattern restricts instantiation to only one instance by making the constructor private and providing a static method to access the single instance.
class Database {
private static instance: Database | null = null;
private connection: string;
private constructor(connection: string) {
this.connection = connection;
}
public static getInstance(connection: string): Database {
if (!Database.instance) {
Database.instance = new Database(connection);
}
return Database.instance;
}
connect() {
console.log(`Connected to ${this.connection}`);
}
}
// Usage
const db1 = Database.getInstance("MySQL");
const db2 = Database.getInstance("PostgreSQL");
db1.connect(); // "Connected to MySQL"
db2.connect(); // "Connected to MySQL"
In this example:
- Even though we called
getInstance
twice with different connection strings, only one instance is created, and subsequent calls return the same instance. - This ensures that the application uses the same
Database
instance everywhere, preventing potential issues with multiple connections.
Advantages of Using the Singleton Pattern
- Consistency: Guarantees a single instance, which is critical for cases where shared state or resources are needed.
- Efficiency: Reduces resource use by preventing duplicate instances, especially for heavy objects like database connections.
- Easier Maintenance: Centralized access simplifies tracking, maintenance, and debugging.
When to Use the Singleton Pattern
Use the Singleton Pattern when:
- Only one instance of a class is needed for the entire application.
- You want to provide global access to a shared resource or service.
- You need to manage a shared resource in a thread-safe manner.
In summary, the Singleton Pattern is ideal for managing shared resources, maintaining consistency, and improving efficiency by limiting the instantiation of a class to one object. It ensures a global access point and reduces overhead in applications that rely on centralized management of critical resources.
Until I explain with sample code, here are the patterns (I will continue with these patterns in the next post).
2. Structural Patterns
Structural patterns deal with object composition and typically help make complex relationships between objects easier to understand and maintain.
- Adapter: Matches interfaces of different classes (also known as “Wrapper”).
- Bridge: Separates an object’s interface from its implementation.
- Composite: Composes objects into tree structures to represent part-whole hierarchies.
- Decorator: Adds responsibilities to objects dynamically.
- Facade: Provides a unified interface to a set of interfaces in a subsystem.
- Flyweight: Reduces the cost of creating and manipulating a large number of similar objects.
- Proxy: Provides a surrogate or placeholder for another object to control access to it.
3. Behavioural Patterns
Behavioural patterns focus on communication between objects, helping to establish effective and flexible communication flows.
- Chain of Responsibility: Passes a request along a chain of potential handlers until it is handled.
- Command: Encapsulates a request as an object, thereby allowing users to parameterize clients with queues, requests, and operations.
- Interpreter: Defines a grammatical representation for a language and provides an interpreter to deal with this grammar.
- Iterator: Provides a way to access elements of an aggregate object sequentially without exposing its underlying representation.
- Mediator: Defines an object that encapsulates how a set of objects interact.
- Memento: Captures and restores an object’s internal state.
- Observer: Defines a dependency between objects so that when one changes state, all its dependents are notified.
- State: Allows an object to alter its behavior when its internal state changes.
- Strategy: Defines a family of algorithms, encapsulates each one, and makes them interchangeable.
- Template Method: Defines the skeleton of an algorithm, deferring some steps to subclasses.
- Visitor: Represents an operation to be performed on elements of an object structure, allowing new operations to be defined without changing the structure.