🔮 Comprehensive Guide to Design Patterns and SOLID Principles in TypeScript 🔮
Explore essential concepts in software engineering, such as Design Patterns and SOLID principles, for creating scalable, maintainable, and efficient code. This repo simplifies these ideas, ensuring accessibility for developers of all levels. Let's delve into this world together and unravel the secrets of effective software engineering!
To get started, follow the navigation below to explore different sections of this repository:
- Design Patterns: Reusable solutions to common problems in software design.
- SOLID Principles: Guiding principles for creating well-structured and maintainable code.
Feel free to dive into the content that interests you the most!
Design patterns are reusable solutions to common problems in software design, offering a structured and proven approach to addressing recurring challenges. They serve as templates or blueprints for solving specific types of problems, making it easier for developers to create efficient and maintainable code. Design patterns provide a shared vocabulary and understanding among developers, promoting reusability, modularity, and improved communication. They encapsulate the best practices of experienced developers, allowing for easier problem-solving and enhanced maintainability. However, it's crucial to apply design patterns judiciously, considering the specific context and potential trade-offs associated with their use.
Imagine building houses. Sometimes, you use similar designs for windows or doors because they work well. The same idea applies to computer programs. Design patterns help us solve common problems in a smart and reusable way.
Christopher Alexander, an architect, initially introduced the concept of design patterns in the 1970s through his work "A Pattern Language," where he explored the identification and application of patterns to solve recurring design dilemmas in architecture.
The adoption and adaptation of this concept for software engineering occurred when a group of computer scientists, often known as the "Gang of Four" (GoF), brought forth the idea. In their influential book "Design Patterns: Elements of Reusable Object-Oriented Software" (1994), Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides introduced 23 design patterns for object-oriented programming, marking a pivotal moment in the popularization of design patterns within software development.
Several compelling reasons drive the utilization of design patterns:
- Reusability: Design patterns offer proven solutions to common problems, reducing the time and effort required to address them from scratch, thereby promoting reusability and modularity in software systems.
- Improved Communication: These patterns establish a shared vocabulary and understanding among developers, facilitating more effective communication about design decisions and solutions.
- Best Practices: Encapsulating the best practices of experienced developers, design patterns provide a learning ground for novices to benefit from their expertise.
- Maintainability: The implementation of design patterns often results in more maintainable code, easing the process of updating, debugging, and extending the codebase in the future.
- Easier Problem-Solving: Design patterns offer a structured approach to problem-solving, aiding developers in breaking down complex issues into more manageable components.
It's super important to use design patterns wisely. Imagine you have a cool tool, but you shouldn't use it for everything. Here's why:
- Think About the Situation: Design patterns work best in certain situations. Using them blindly might not always be the right choice.
- Keep It Simple: Sometimes, a simple solution is better than a fancy one. Don't make things more complicated than they need to be.
- Watch Out for Speed Bumps: Design patterns can slow down our programs a bit. We need to decide if the benefits are worth it.
- Be Ready to Change: As projects grow, what worked before might not be the best choice anymore. We need to be flexible and adjust.
Using design patterns is like having a toolbox full of helpful tools. Just remember, not every tool is right for every job. We should pick the ones that fit the situation best. If we do that, our programs will be strong and reliable!
Creational design patterns 🎨 revolve around the intricacies of object creation. They introduce a level of abstraction to the instantiation process, ensuring the system remains agnostic to the specifics of how its objects come into existence, are composed, and represented. These design patterns offer a mechanism for object creation that conceals the intricacies of the creation logic, steering away from direct object instantiation using the new operator. By doing so, they grant greater flexibility in determining the objects necessary for a given use case. Notable examples of creational design patterns encompass Singleton, Factory Method, Abstract Factory, Builder, and Prototype. 🚀
The Singleton pattern is a creational design pattern ensuring that a class has only one instance while providing global access to this instance.
In simple words:
"Singleton - ensures that only one object of a particular class is ever created."
Implementing the Singleton pattern in object-oriented programming typically involves the following steps:
- Declare a private
static
attribute in the singleton class. - Create a public static method (commonly named
getInstance()
) to serve as a global access point for the singleton object. This method embraces "lazy initialization," meaning it generates a new instance only when necessary. - Set the constructor of the singleton class as
private
, preventing external objects from using thenew
operator with the singleton class. - Within the static method of the class, verify the existence of the singleton instance. If it exists, return it; otherwise, create a new instance and return it.
Here is how we might create a database connection using the Singleton pattern:
class Database {
// Step 1: Declare a private static instance
private static instance: Database;
// Step 3: Make the constructor private
private constructor() {}
// Step 2: Create a public static getInstance method
public static getInstance(): Database {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
public query(query: string): void {
console.log(`Executing query '${query}' on database.`);
}
}
// Usage
const db1 = Database.getInstance();
const db2 = Database.getInstance();
db1.query("SELECT * FROM users"); // Executing query `SELECT * FROM users` on database.
db2.query("DROP DATABASE users"); // Executing query `DROP DATABASE users` on database.
console.log(db1 === db2); // true
In this example, the Database
class represents a database connection. The getInstance method ensures that there is only one instance of the Database class, and the query method allows you to perform queries on the database.
The usage demonstrates that db1
and db2
are the same instance, showcasing the Singleton pattern behavior.
Consider using Singleton when:
- You have global variables that should be accessible universally.
- There is repeated, expensive initialization of the same resource.
- Multiple parts of your system access and potentially modify a shared resource.
- An entity is accessed inconsistently across the system.
- Duplicate instances are generated, and identical instances are unnecessary.
- Excessive parameters are passed through layers for an object.
Despite its advantages, the Singleton pattern has drawbacks:
-
Violates Single Responsibility Principle 🚫: Simultaneously managing object instantiation and global access might breach the Single Responsibility Principle.
-
Masking Design Issues 🎭: Singleton can hide underlying design problems, offering a quick fix without addressing the root causes.
-
Multithreading Challenges 🔄: Implementing Singleton in a multithreaded environment requires careful synchronization to prevent unintended multiple instantiations.
-
Unit Testing Complexity 🧪: Unit testing client code using Singleton can be complex due to private constructors and challenges in mocking the singleton instance.
Prototype is a creational design pattern that lets you copy existing objects without making your code dependent on their classes. It allows you to create a copy of an existing object and modify it to your needs, instead of going through the trouble of creating an object from scratch and setting it up.
In simple words:
Create a new object based on an existing object through cloning.
Let's see a simple implementation of the Prototype pattern in TS through an example in game development.
interface Prototype {
clone(): Prototype;
details: EnemyDetails;
}
interface EnemyDetails {
type: string;
strength: number;
}
/**
* Concrete Prototype representing an Enemy in a Game
*/
class Enemy implements Prototype {
constructor(public details: EnemyDetails) {}
public clone(): Enemy {
const clone = new Enemy({ ...this.details });
return clone;
}
}
// Usage
const originalEnemy: Prototype = new Enemy({ type: "Dragon", strength: 10 });
const clonedEnemy: Prototype = originalEnemy.clone();
console.log(originalEnemy.details); // { type: 'Dragon', strength: 10 }
console.log(clonedEnemy.details); // { type: 'Dragon', strength: 10 }
clonedEnemy.details = { type: 'Goblin', strength: 8 };
console.log(clonedEnemy.details); // { type: 'Goblin', strength: 8 }
This approach enhances code efficiency and maintainability, allowing easy modification of specific properties without creating new instances for each enemy.
The Prototype pattern is handy when copying existing objects is more efficient than creating new ones. It's beneficial for systems seeking independence in creating, composing, and representing products.
- Clone prototypes to avoid redoing intricate constructions for similar objects.
- Clone pre-loaded objects to enhance efficiency when creating from scratch is resource-intensive.
- Use the Prototype pattern when needing multiple similar but not identical objects.
- Facilitates storing and cloning prototypes for restoring previous states, ideal for undo features.
- Avoiding Object Reference Errors 🚫
- Efficient Object Cloning 🚀
- Simplifying Object Creation 🌐
- Shallow vs. Deep Copying 🔄 : Cloning complex objects that have circular references might be very tricky
- Complex Cloning Hierarchies 📜: Cloning hierarchical structures introduces complexities, particularly with interconnected objects and relationships.
Builder is a creational design pattern facilitating the step-by-step construction of complex objects. It enables the creation of various object types using a unified construction process, preventing constructor overload. Use the Builder pattern to get rid of a “telescoping constructor”.
In simple words:
Builder helps in creating different versions of an object without cluttering the constructor.
interface IPizza {
name: string;
size: string;
isCheese: boolean;
}
interface IPizzaBuilder {
setName(name: string): IPizzaBuilder;
setSize(size: string): IPizzaBuilder;
setCheese(isCheese: boolean): IPizzaBuilder;
build(): IPizza;
}
class Pizza implements IPizza {
constructor(
public name: string,
public size: string,
public isCheese: boolean
) { }
}
class PizzaBuilder implements IPizzaBuilder {
private name: string = "";
private size: string = "";
private isCheese: boolean = false;
setName(name: string): IPizzaBuilder {
this.name = name;
return this;
}
setSize(size: string): IPizzaBuilder {
this.size = size;
return this;
}
setCheese(isCheese: boolean): IPizzaBuilder {
this.isCheese = isCheese;
return this;
}
build(): IPizza {
return new Pizza(this.name, this.size, this.isCheese);
}
}
class PizzaDirector {
constructor(private builder: IPizzaBuilder) { }
public buildMinimalPizza(name: string, size: string): IPizza {
return this.builder
.setName(name)
.setSize(size)
.build();
}
public buildFullFeaturedPizza(name: string, size: string, isCheese: boolean): IPizza {
return this.builder
.setName(name)
.setSize(size)
.setCheese(isCheese)
.build();
}
}
// Usage:
const builder: IPizzaBuilder = new PizzaBuilder();
const director: PizzaDirector = new PizzaDirector(builder);
const pizzaWithoutCheese: IPizza = director.buildMinimalPizza('Pepperoni', 'Medium');
const pizzaWithCheese: IPizza = director.buildFullFeaturedPizza('Hawaiian', 'Small', true);
console.log(pizzaWithoutCheese); // Pizza: { name: 'Pepperoni', size: 'Medium', isCheese: false}
console.log(pizzaWithCheese); // Pizza: { name: 'Hawaiian', size :'Small', isCheese: true}
This TypeScript code implements a simplified Builder pattern for creating pizza objects, allowing customization of attributes like name, size, and the presence of cheese.
- Complex Object Creation 🧩: Simplify the creation of objects with numerous optional and mandatory attributes.
- Step-by-step Object Creation 🔨: Useful when an object needs to be built in multiple ordered steps.
- Immutable Objects 🔄: Construct immutable objects with many attributes, enhancing object integrity.
- Code Clarity 📝: Enhance code readability, especially when dealing with constructors with numerous parameters.
- Fluent Interface 🌐: Enhances code readability for step-by-step object construction.
- Separation of Construction Logic and Business Logic 🧠: Keeps client code focused on business logic by isolating object construction details.
- Different Representations 🎨: Utilizes diverse builders for various object representations without modifying client code.
- Increased Object Integrity 🔒: Ensures object validity at each step, elevating data integrity.
- Immutability 🔄: Returns immutable objects for simplicity, safety, and cleaner code.
- Increased Complexity 📈📉: Introduces abstraction layers, potentially complicating code for those unfamiliar with the pattern.
- Additional Code 📄: May result in more code, especially for smaller classes, potentially increasing codebase size.
- Runtime Errors 🚫: Lack of compile-time checks may lead to runtime errors if required fields are not set.
- Refactoring Difficulties 🛠️: Alters to the class structure might necessitate updates to the builder code, making refactoring more challenging and time-consuming.
The Factory Method Pattern is a creational design pattern that provides an interface for creating objects in a superclass, allowing subclasses to alter the type of objects created.
In Simple Terms:
It enables the delegation of object instantiation to child classes, offering a way to create objects without specifying their exact classes.
Consider a car manufacturing program with different car types (Sedan, Hatchback):
abstract class Car {
constructor(public model: string, public productionYear: number) {}
abstract displayCarInfo(): void;
}
class Sedan extends Car {
displayCarInfo() {
console.log(`This is a Sedan. Model: ${this.model}, Production Year: ${this.productionYear}`);
}
}
class Hatchback extends Car {
displayCarInfo() {
console.log(`This is a Hatchback. Model: ${this.model}, Production Year: ${this.productionYear}`);
}
}
class CarFactory {
public createCar(type: string, model: string, productionYear: number): Car {
switch (type) {
case "Sedan":
return new Sedan(model, productionYear);
case "Hatchback":
return new Hatchback(model, productionYear);
default:
throw new Error("Invalid car type");
}
}
}
// Usage:
const carFactory = new CarFactory();
const sedan = carFactory.createCar("Sedan", "Camry", 2023);
sedan.displayCarInfo(); // This is a Sedan. Model: Camry, Production Year: 2023
const hatchback = carFactory.createCar("Hatchback", "Corolla", 2019);
hatchback.displayCarInfo(); // // This is a Sedan. Model: Corolla, Production Year: 2019
- Uncertain Object Types: If your software is meant to create different objects at runtime.
- Similar Classes: When dealing with numerous classes sharing a common superclass.
- Pluggability and Flexibility: Providing users with a way to extend a library with their own classes.
- Decoupling 🚀: Reduces coupling between client code and concrete classes, enhancing maintainability.
- Flexibility 🤸: Allows easy addition of new object types without modifying existing client code.
- Encapsulation 🧳: Encapsulates object creation details, making the factory responsible for instantiation.
- Refactoring 🔄: Introducing the Factory Pattern to an existing codebase might pose challenges during refactoring.
- Increased Number of Classes 📈: The pattern can lead to a higher number of classes, potentially making the codebase more complex.
- Testing 🧪: While aiding in writing testable code, complex factories can complicate the testing process, requiring additional setup.
The Abstract Factory pattern is a creational design pattern that furnishes an interface for constructing families of objects that are related or dependent, all without explicitly specifying their concrete classes.
In Simple Terms:
A factory of factories.
interface Button {
render(): void;
onClick(f: Function): void;
}
interface Checkbox {
render(): void;
toggle(): void;
}
interface GUIFactory {
createButton(): Button;
createCheckbox(button: Button): Checkbox;
}
class WindowsButton implements Button {
render() {
console.log("Render a button in Windows style");
}
onClick(f: Function) {
console.log("Bind a Windows style button click event");
f();
}
}
class WindowsCheckbox implements Checkbox {
private button: Button;
constructor(button: Button) {
this.button = button;
}
render() {
console.log("Render a checkbox in Windows style");
}
toggle() {
this.button.onClick(() => console.log("Checkbox state toggled!"));
}
}
class MacOSButton implements Button {
render() {
console.log("Render a button in MacOS style");
}
onClick(f: Function) {
console.log("Bind a MacOS style button click event");
f();
}
}
class MacOSCheckbox implements Checkbox {
private button: Button;
constructor(button: Button) {
this.button = button;
}
render() {
console.log("Render a checkbox in MacOS style");
}
toggle() {
this.button.onClick(() => console.log("Checkbox state toggled!"));
}
}
class WindowsFactory implements GUIFactory {
createButton(): Button {
return new WindowsButton();
}
createCheckbox(button: Button): Checkbox {
return new WindowsCheckbox(button);
}
}
class MacOSFactory implements GUIFactory {
createButton(): Button {
return new MacOSButton();
}
createCheckbox(button: Button): Checkbox {
return new MacOSCheckbox(button);
}
}
function renderUI(factory: GUIFactory) {
const button = factory.createButton();
const checkbox = factory.createCheckbox(button);
button.render();
checkbox.render();
button.onClick(() => console.log("Button clicked!"));
checkbox.toggle();
}
console.log("App: Launched with the Windows factory.");
renderUI(new WindowsFactory());
console.log("App: Launched with the MacOS factory.");
renderUI(new MacOSFactory());
- Interrelated Dependencies: Ensure that a client uses objects that belong together in a family.
- Switching Product Families: Easily swap entire families of objects (e.g., different look-and-feel standards).
- Supporting Multiple Architectures: Run software in different environments requiring different implementations of related objects.
- Consistency among products 🤝: Ensure compatibility and belongingness within a family of products.
- Code Reusability 🔄: Promote reuse of code for creating related product families.
- Single Responsibility Principle 🎯: Each concrete factory has a single responsibility, leading to cleaner and more understandable code.
- Complexity 📈: Introduces complexity and abstraction into the code, which may be unnecessary for simpler applications.
- Tight Coupling And Dependency 🔗: Client code becomes dependent on the Abstract Factory interface, requiring changes if the interface changes.
- Limited Flexibility In Modifying Product Families 🚫: Adding new types of products may require changing the core factory interface, violating the Open/Closed Principle.
Structural design patterns are a type of design pattern that deal with object composition and the structure of classes/objects. They help ensure that when a change is made in one part of a system, it doesn't require changes in other parts. This makes the system more flexible and easier to maintain.
The Adapter Design Pattern is a software design pattern that allows the interface of an existing class to be used from another interface. It's often used to make existing classes work with others without modifying their source code. The Adapter Pattern is especially useful when the classes that need to communicate with each other do not have compatible interfaces.
In simple words:
Adapter allows objects with incompatible interfaces to collaborate.
// Duck class
class Duck {
quack(): void {
console.log("Quack, quack!");
}
fly(): void {
console.log("I'm flying!");
}
}
// Animal interface
interface Animal {
makeSound(): void;
move(): void;
}
// DuckAdapter class
class DuckAdapter implements Animal {
private duck: Duck;
constructor(duck: Duck) {
this.duck = duck;
}
makeSound(): void {
this.duck.quack();
}
move(): void {
this.duck.fly();
}
}
// Using the Duck and DuckAdapter
const duck = new Duck();
const adapter = new DuckAdapter(duck);
// Now, the duck can be used as an animal
adapter.makeSound(); // Output: Quack, quack!
adapter.move(); // Output: I'm flying!
- Incompatibility of Interfaces: Use when different parts can't communicate due to different interfaces.
- Alternatives to Multiple Inheritance: In languages without it, Adapter helps inherit behavior from multiple sources.
- Abstracting Volatile Classes: Shields the app from changes in frequently changing classes.
- Reusability and Flexibility: Reuse existing code without major changes.
- Decoupling: Reduces dependencies for easier maintenance.
- Interoperability: Enables different parts to work together despite interface mismatches.
- Overuse or Unnecessary Use: Be cautious to avoid unnecessary complexity.
- Performance Overhead: Involves some indirection; may impact performance in critical systems.
- Potential for Confusion: Clear documentation needed for developers unfamiliar with the codebase.
The Bridge pattern is a structural design pattern that lets you split a large class or a set of closely related classes into two separate hierarchies—abstraction and implementation—which can be developed independently of each other.
In simple words:
It's like a bridge between abstraction and implementation, enabling independent changes for flexibility.
- Implementor interface and concrete implementors:
interface Database {
connect(): void;
query(sql: string): any;
close(): void;
}
class PostgreSQLDatabase implements Database {
connect(): void {
console.log("Connecting to PostgreSQL database.");
}
query(sql: string): any {
console.log(`Executing query '${sql}' on PostgreSQL database.`);
}
close(): void {
console.log("Closing connection to PostgreSQL database.");
}
}
class MongoDBDatabase implements Database {
connect(): void {
console.log("Connecting to MongoDB database.");
}
query(sql: string): any {
console.log(`Executing query '${sql}' on MongoDB database.`);
}
close(): void {
console.log("Closing connection to MongoDB database.");
}
}
- Abstraction and refined abstractions:
abstract class DatabaseService {
protected database: Database;
constructor(database: Database) {
this.database = database;
}
abstract fetchData(query: string): any;
}
class ClientDatabaseService extends DatabaseService {
fetchData(query: string): any {
this.database.connect();
const result = this.database.query(query);
this.database.close();
return result;
}
}
- Client code:
let databaseService = new ClientDatabaseService(new PostgreSQLDatabase());
databaseService.fetchData("SELECT * FROM users;"); // use PostgreSQL database
databaseService = new ClientDatabaseService(new MongoDBDatabase());
databaseService.fetchData("db.users.find({})"); // use MongoDB database
In this example, we've created a "bridge" that decouples the high-level DatabaseService class from the specifics of the various Database implementations. By doing this, you can add a new type of database to the application without changing the DatabaseService class or the client code. Also, at runtime, the client can decide which database to use.
- Hide Implementation Details: Expose only necessary client methods for cleaner code.
- Implementation-Specific Behavior: Enable different platform implementations without altering client code.
- Prevent Monolithic Designs:** Promote modularity to avoid widespread implications of changes.
- Decoupling 🧩: Separates abstraction and implementation for independent evolution.
- Improved Readability 📚: Enhances code readability and maintainability.
- Runtime Binding 🔄: Allows changing implementations at runtime.
- Over-engineering 🛠️: Adds complexity if abstraction and implementation are stable.
- Design Difficulty 🤔: Choosing the right abstraction can be challenging.
- Development and Maintenance Costs 💸: Introducing the Bridge pattern requires refactoring, increasing complexity.
The Composite pattern is a structural design pattern that lets you compose objects into tree-like structures and then work with these structures as if they were individual objects.
In simple words:
It lets clients treat the individual objects in a uniform manner.
// Component
interface Employee {
getName(): string;
getSalary(): number;
getRole(): string;
}
// Leaf
class Developer implements Employee {
constructor(private name: string, private salary: number) {}
getName(): string {
return this.name;
}
getSalary(): number {
return this.salary;
}
getRole(): string {
return "Developer";
}
}
// Another Leaf
class Designer implements Employee {
constructor(private name: string, private salary: number) {}
getName(): string {
return this.name;
}
getSalary(): number {
return this.salary;
}
getRole(): string {
return "Designer";
}
}
// Composite
interface CompositeEmployee extends Employee {
addEmployee(employee: Employee): void;
removeEmployee(employee: Employee): void;
getEmployees(): Employee[];
}
class Manager implements CompositeEmployee {
private employees: Employee[] = [];
constructor(private name: string, private salary: number) {}
getName(): string {
return this.name;
}
getSalary(): number {
return this.salary;
}
getRole(): string {
return "Manager";
}
addEmployee(employee: Employee) {
this.employees.push(employee);
}
removeEmployee(employee: Employee) {
const index = this.employees.indexOf(employee);
if (index !== -1) {
this.employees.splice(index, 1);
}
}
getEmployees(): Employee[] {
return this.employees;
}
}
Here's how you could use these classes:
const dev1 = new Developer("John Doe", 12000);
const dev2 = new Developer("Karl Durden", 15000);
const designer = new Designer("Mark", 10000);
const manager = new Manager("Michael", 25000);
manager.addEmployee(dev1);
manager.addEmployee(dev2);
manager.addEmployee(designer);
console.log(manager); // { name : "Michael", salary: 25000, employees: [ { name: "John Doe", salary: 12000 } ...] }
- Tree-like Object Structure: Useful when objects form a tree-like pattern, such as organizational structures in companies.
- Part-Whole Hierarchies: Natural choice for part-whole hierarchies, treating parts and wholes the same way.
- Uniform Treatment by Clients: Clients treat all objects uniformly within the composite structure, simplifying client code.
- Simplifies Client Code 🎯: Uniform treatment of objects simplifies client code.
- Easily Adds New Components 🌱: New leaf or composite objects can be added effortlessly by implementing the component interface.
- Hierarchical Representation 🏰: Natural fit for systems with hierarchical structures.
- SRP Violation 🚧: May violate the Single Responsibility Principle (SRP).
- Challenges with Common Interface 🤹♂️: Providing a common interface for classes with vastly different functionalities can be difficult.
- Indirect Coupling 🔄: Changes in one object can indirectly affect another, even if not directly linked.
The Decorator design pattern is a structural design pattern that allows you to dynamically add or override behaviour in an existing object without changing its implementation. This pattern is particularly useful when you want to modify the behavior of an object without affecting other objects of the same class.
In simple words:
Dynamically enhances object behavior.
// Component
interface Coffee {
cost(): number;
description(): string;
}
// ConcreteComponent
class SimpleCoffee implements Coffee {
cost() {
return 10;
}
description() {
return "Simple coffee";
}
}
// Decorator
abstract class CoffeeDecorator implements Coffee {
protected coffee: Coffee;
constructor(coffee: Coffee) {
this.coffee = coffee;
}
abstract cost(): number;
abstract description(): string;
}
// ConcreteDecorator
class MilkDecorator extends CoffeeDecorator {
constructor(coffee: Coffee) {
super(coffee);
}
cost() {
return this.coffee.cost() + 2;
}
description() {
return `${this.coffee.description()}, with milk`;
}
}
// Usage
const plainCoffee = new SimpleCoffee();
console.log("Plain Coffee Cost: $" + plainCoffee.cost()); // Plain Coffee Cost: $10
console.log("Description: " + plainCoffee.description()); // Description: Simple coffee
const coffeeWithMilk = new MilkDecorator(plainCoffee);
console.log("Coffee with Milk Cost: $" + coffeeWithMilk.cost()); // Coffee with Milk Cost: $12
console.log("Description: " + coffeeWithMilk.description()); // Description: Simple coffee, with milk
- Add Responsibilities Dynamically: When adding responsibilities to objects without affecting others, such as adding formatting options to a text editor.
- Dynamic Addition and Removal: For adding and removing responsibilities from an object at runtime.
- Easily Extend the System: For future extension, allowing the system to be easily extended with new decorator classes.
- Flexible Alternative to Subclassing 🔄: Adds new behaviors to objects without affecting other objects of the same class.
- Runtime Addition and Removal 🕰️: Decorators can be added to and removed from an object dynamically at runtime.
- Code Reuse and Reduction 🔄: Encapsulates specific features in decorator classes, promoting code reuse and reducing redundancy.
- Many Small Objects 🧩: Can lead to situations with many small objects, complicating the design and debugging.
- Difficult Removal of Specific Wrapper 🔄: It's challenging to remove a specific wrapper from the wrappers stack.
- Complex Initial Configuration 🤯: Initial configuration code of layers might look ugly in complex systems with many decorators.
In simple words:
It provides a simplified interface to a complex subsystem.
// Subsystem 1
class AudioPlayer {
play(): string {
return "Playing audio";
}
}
// Subsystem 2
class VideoPlayer {
play(): string {
return "Playing video";
}
}
// Subsystem 3
class Projector {
display(): string {
return "Projector displaying content";
}
}
// Facade
class MultimediaFacade {
private audioPlayer: AudioPlayer;
private videoPlayer: VideoPlayer;
private projector: Projector;
constructor(audioPlayer: AudioPlayer, videoPlayer: VideoPlayer, projector: Projector) {
this.audioPlayer = audioPlayer;
this.videoPlayer = videoPlayer;
this.projector = projector;
}
startMovie(): string {
const audio = this.audioPlayer.play();
const video = this.videoPlayer.play();
const display = this.projector.display();
return `${audio}\n${video}\n${display}`;
}
stopMovie(): string {
return "Stopping multimedia playback";
}
}
// Example usage
const audioPlayer = new AudioPlayer();
const videoPlayer = new VideoPlayer();
const projector = new Projector();
const multimediaFacade = new MultimediaFacade(audioPlayer, videoPlayer, projector);
console.log(multimediaFacade.startMovie()); // Playing audio, Playing video, Projector displaying content
console.log(multimediaFacade.stopMovie()); // Stopping multimedia playback
- Rampant Dependencies: Reduces high coupling by providing a unified interface.
- Overwhelming Complexity: Wraps complex subsystems for a straightforward interaction.
- Simplified API Need: For libraries or APIs, offers only essential functionalities.
- Simplified Interface 🛡️: Shields users from subsystem complexities.
- Reduced Dependencies 🤝: Minimizes client code dependencies on subsystems.
- Decoupling 🔗: Changes in subsystems minimally impact clients.
- Ease of Use 👤: Abstracts complexities, making subsystems user-friendly.
- Over-abstraction 🤯: May introduce unnecessary complexity for simple subsystems.
- Limited Flexibility 🚫: Restricts access to full subsystem functionality.
- Hiding Information 🕵️♂️: May conceal crucial details useful in specific scenarios.
The Flyweight design pattern is a structural pattern that aims to minimize memory usage or computational expenses by sharing as much as possible with related objects; it provides a way to use objects in large numbers more efficiently. The pattern achieves this by sharing common portions of the object's state among multiple instances, rather than each instance holding its own copy.
In simple words:
Flyweight pattern is like having a shared pool of objects, where common features are stored centrally, allowing multiple instances to reuse and reference them. This significantly reduces the memory footprint and improves performance.
// Flyweight interface
interface TextStyle {
applyStyle(): void;
}
// Concrete Flyweight
class SharedTextStyle implements TextStyle {
private font: string;
private size: number;
private color: string;
constructor(font: string, size: number, color: string) {
this.font = font;
this.size = size;
this.color = color;
}
applyStyle(): void {
console.log(`Applying style - Font: ${this.font}, Size: ${this.size}, Color: ${this.color}`);
}
}
// Flyweight Factory
class TextStyleFactory {
private textStyles: { [key: string]: TextStyle } = {};
getTextStyle(font: string, size: number, color: string): TextStyle {
const key = `${font}-${size}-${color}`;
if (!this.textStyles[key]) {
this.textStyles[key] = new SharedTextStyle(font, size, color);
}
return this.textStyles[key];
}
}
// Client
class TextEditor {
private textStyles: TextStyle[] = [];
private textStyleFactory: TextStyleFactory;
constructor(factory: TextStyleFactory) {
this.textStyleFactory = factory;
}
applyStyle(font: string, size: number, color: string): void {
const style = this.textStyleFactory.getTextStyle(font, size, color);
this.textStyles.push(style);
}
printStyles(): void {
this.textStyles.forEach((style) => style.applyStyle());
}
}
// Usage
const textStyleFactory = new TextStyleFactory();
const textEditor = new TextEditor(textStyleFactory);
textEditor.applyStyle("Arial", 12, "Black");
textEditor.applyStyle("Times New Roman", 14, "Red");
textEditor.applyStyle("Arial", 12, "Black"); // Reusing existing style
textEditor.printStyles(); // print all styles...
- Large Number of Similar Objects: Useful when dealing with many similar instances.
- Shared State: When objects share a significant portion of their state.
- Performance Optimization: Optimizes performance by avoiding redundancy.
- Memory Efficiency: Reduces memory consumption by sharing common state.
- Performance Improvement: Minimizes computational costs by reusing shared portions.
- Scalability: Handles numerous instances without proportional memory increase.
- Complexity: Introduces complexity by separating intrinsic and extrinsic states.
- Potential Overhead: Managing shared state might outweigh benefits in simple scenarios.
The Proxy design pattern is a structural pattern that acts as a surrogate or placeholder for another object, controlling access to it. This pattern is useful when we want to add an extra layer of control over the functionality of an object, such as adding security checks, lazy loading, or logging.
In simple words:
A Proxy acts as a middleman, standing between a client and an object. It controls access to the real object, allowing for additional functionalities or restrictions.
// Subject interface representing the internet
interface Internet {
accessWebsite(website: string): void;
}
// RealSubject representing the actual internet
class RealInternet implements Internet {
accessWebsite(website: string): void {
console.log(`Accessing website: ${website}`);
}
}
// Proxy representing a Fortinet-like proxy internet for content filtering
class ProxyInternet implements Internet {
private realInternet: RealInternet | null = null;
private restrictedWebsites: Set<string> = new Set<string>();
addRestrictedWebsite(website: string): void {
this.restrictedWebsites.add(website);
console.log(`Website ${website} is restricted.`);
}
accessWebsite(website: string): void {
// Check if the website is restricted
if (this.restrictedWebsites.has(website)) {
console.log(`Access to ${website} is denied due to content restrictions.`);
return;
}
// Only access the real internet if the website is not restricted
if (this.realInternet === null) {
this.realInternet = new RealInternet();
}
this.realInternet.accessWebsite(website);
}
}
// Usage:
const internetUser: Internet = new ProxyInternet();
// Configuring the proxy internet to restrict access to certain websites
const proxyInternet = internetUser as ProxyInternet;
proxyInternet.addRestrictedWebsite("bad.com"); // Website bad.com is restricted.
// The user accesses the internet through the proxy
internetUser.accessWebsite("example.com"); // Accessing website: example.com
internetUser.accessWebsite("bad.com"); // Access to bad.com is denied due to content restrictions.
- Access Control: When you need to control access to an object, for example, adding authentication or authorization checks.
- Lazy Loading: To delay the creation and initialization of an object until it's actually needed.
- Logging or Monitoring: To log or monitor the interactions with the real object.
- Controlled Access: Allows for controlled access to the real object.
- Lazy Loading: Supports lazy loading for resource-intensive objects.
- Enhanced Functionality: Enables adding functionalities like logging, security checks, or caching.
- Complexity: Introduces an additional layer, potentially increasing code complexity.
- Reduced Performance: Depending on the use case, the proxy might introduce some performance overhead.
Behavioral design patterns help organize how different parts of a software system communicate and collaborate. They provide solutions for common challenges in defining algorithms and managing responsibilities, enhancing flexibility and extensibility. Essentially, these patterns guide the flow of communication and behavior in a software application.
The Chain of Responsibility is a behavioral design pattern that lets you pass requests along a chain of handlers. Upon receiving a request, each handler decides either to process the request or to pass it to the next handler in the chain.
In simple words:
Imagine you have a series of processing tasks, and each task can be handled by a different entity. The Chain of Responsibility pattern allows you to link these entities in a chain. When a task is presented, each entity in the chain has the chance to handle it. If one entity can handle it, the chain stops; otherwise, the task moves along the chain until it finds a handler.
// Handler interface
interface Approver {
setNext(nextApprover: Approver): Approver;
processRequest(amount: number): void;
}
// Concrete Handler 1
class Manager implements Approver {
private nextApprover: Approver | null = null;
setNext(nextApprover: Approver): Approver {
this.nextApprover = nextApprover;
return nextApprover;
}
processRequest(amount: number): void {
if (amount <= 1000) {
console.log(`Manager approves the purchase of $${amount}.`);
} else if (this.nextApprover) {
this.nextApprover.processRequest(amount);
}
}
}
// Concrete Handler 2
class Director implements Approver {
private nextApprover: Approver | null = null;
setNext(nextApprover: Approver): Approver {
this.nextApprover = nextApprover;
return nextApprover;
}
processRequest(amount: number): void {
if (amount <= 5000) {
console.log(`Director approves the purchase of $${amount}.`);
} else if (this.nextApprover) {
this.nextApprover.processRequest(amount);
}
}
}
// Concrete Handler 3
class VicePresident implements Approver {
private nextApprover: Approver | null = null;
setNext(nextApprover: Approver): Approver {
this.nextApprover = nextApprover;
return nextApprover;
}
processRequest(amount: number): void {
if (amount <= 10000) {
console.log(`Vice President approves the purchase of $${amount}.`);
} else if (this.nextApprover) {
this.nextApprover.processRequest(amount);
}
}
}
// Client
const manager = new Manager();
// Set up the chain of responsibility
manager
.setNext(new Director())
.setNext(new VicePresident());
// Test the chain with different purchase amounts
manager.processRequest(800); // Manager approves the purchase of $800
manager.processRequest(4500); // Director approves the purchase of $4500
manager.processRequest(10000); // Vice President approves the purchase of $10000
- Coupling: Keep things simple and scalable by using this pattern to hide details from the requester.
- Multiple Conditionals: Organize messy code with lots of "if" statements by spreading them out.
- Code Duplication: Gather scattered, similar code in one place for better organization.
- Sequential Processing: Use it when tasks must happen in a specific order, like following steps in a recipe.
- Decoupling: Separate sender and receiver for cleaner, modular code.
- Dynamic Configuration: Easily change how things work on-the-fly.
- Easy Responsibility Management: Add or remove tasks effortlessly.
- Handling Overhead: Might slow things down a bit.
- Debugging Challenges: Finding problems can be tricky.
- Responsibility Overload: Avoid giving one person too many jobs.
The Command design pattern transforms requests into standalone objects, making it easy to pass requests as method arguments, delay or queue their execution, and support undoable operations.
In simple words:
It encapsulates actions, letting clients operate independently from receivers.
// Command interface
interface Command {
execute(): void;
}
// Concrete Command 1: Light On
class LightOnCommand implements Command {
private light: Light;
constructor(light: Light) {
this.light = light;
}
execute(): void {
this.light.turnOn();
}
}
// Concrete Command 2: Light Off
class LightOffCommand implements Command {
private light: Light;
constructor(light: Light) {
this.light = light;
}
execute(): void {
this.light.turnOff();
}
}
// Receiver: Light
class Light {
turnOn(): void {
console.log("Light is ON");
}
turnOff(): void {
console.log("Light is OFF");
}
}
// Invoker: Remote Control
class RemoteControl {
private command: Command | null = null;
setCommand(command: Command): void {
this.command = command;
}
pressButton(): void {
if (this.command) {
this.command.execute();
} else {
console.log("No command assigned.");
}
}
}
// Client Code
const light = new Light();
const lightOnCommand = new LightOnCommand(light);
const lightOffCommand = new LightOffCommand(light);
const remote = new RemoteControl();
remote.setCommand(lightOnCommand);
remote.pressButton(); // Light is ON
remote.setCommand(lightOffCommand);
remote.pressButton(); // Light is OFF
NOTE: This is a basic TypeScript implementation of a Command pattern, but in reality, it can encompass additional functionalities such as performing undo, redo, and more.
- Complex Commands: For operations involving multiple methods on different objects, simplifying code by encapsulating them.
- Parameterized Operations: When an object needs to perform an operation with the specific action specified at runtime.
- Undo/Redo Support: Ideal for applications requiring undoable operations; each action is represented as a command.
- Job Queue Implementation: Useful for managing a queue of tasks executed at different times or by different threads.
- Extension: Easily add new commands without altering existing code for improved system extensibility.
- Complex Commands: Encapsulate multi-step operations for cleaner and more manageable code.
- Deferred and Asynchronous Operations: Execute operations at different times or by different threads, allowing non-blocking execution.
- Dependency Management: Concrete Command classes may require contextual initialization, adding some complexity.
- Debugging Challenges: Dynamic execution and deferred commands may make debugging less straightforward.
- Lack of Direct Feedback: Encapsulation may complicate obtaining direct results from command execution.
The Iterator pattern is a design pattern that allows sequential access to elements in a collection, without exposing its underlying representation. It provides a way to access the elements of an aggregate object sequentially without exposing the underlying details.
In simple words:
It allows accessing elements without exposing how they're stored.
class ArrayIterator<T> {
private collection: T[];
private position: number = 0;
constructor(collection: T[]) {
this.collection = collection;
}
public next(): T {
const result: T = this.collection[this.position];
this.position += 1;
return result;
}
public hasNext(): boolean {
return this.position < this.collection.length;
}
}
// Usage
const stringArray = ["Hello", "World", "!"];
const numberArray = [1, 2, 3, 4, 5];
const stringIterator = new ArrayIterator<string>(stringArray);
const numberIterator = new ArrayIterator<number>(numberArray);
console.log(numberIterator.next()); // 1
while (stringIterator.hasNext()) {
console.log(stringIterator.next()); // Logs 'Hello', 'World', '!'
}
NOTE: This was a simple TypeScript implementation of an iterator, but in reality, it can include more functionalities such as traversing in reverse and so on.
- Complex Navigation Logic: Useful when traversing complex data structures like trees or graphs gets complicated and entangled with business logic.
- Multiple Traversal Algorithms: Access elements of a collection without revealing its structure.
- Simplifies Client Code: Offers a common interface for traversing different collections, simplifying client code.
- Enables Different Traversals: Supports various traversal types - forward, backward, or random access, based on application needs.
- Uniform Interface: Provides a consistent interface for traversing diverse collections, aiding generic code.
- Increased Complexity: Introduces more classes and interfaces, potentially adding complexity to the codebase.
- Performance Considerations: Depending on implementation, performance may be impacted, especially for computationally expensive hasNext() or next() methods.
- Memory Consumption: Multiple iterator instances can increase memory consumption, particularly for large collections.
The Mediator pattern is a design pattern that defines an object to centralize communication between different components, promoting loose coupling. It allows components to interact without directly referencing each other, reducing dependencies.
In simple words:
It acts as a central hub, enabling components to communicate indirectly, minimizing direct connections.
interface IUser {
notify(message: string): void;
receive(message: string): void;
}
class Mediator {
private users: Set<IUser> = new Set();
addUser(user: IUser): void {
this.users.add(user);
}
notifyUsers(message: string, originator: IUser): void {
for (const user of this.users) {
if (user !== originator) {
user.receive(message);
}
}
}
}
class User implements IUser {
private mediator: Mediator;
private name: string;
constructor(mediator: Mediator, name: string) {
this.mediator = mediator;
this.name = name;
this.mediator.addUser(this);
}
notify(message: string): void {
console.log(`${this.name} sending message: ${message}`);
this.mediator.notifyUsers(message, this);
}
receive(message: string): void {
console.log(`${this.name} received message: ${message}`);
}
}
// Example Usage
const mediator = new Mediator();
const user1 = new User(mediator, 'User1');
const user2 = new User(mediator, 'User2');
const user3 = new User(mediator, 'User3');
user1.notify('Hello User2!');
user2.notify('Hi there!');
user3.notify('Greetings, everyone!');
// "User1" sending message: "Hello User2!"
// "User2" received message: "Hello User2!
// "User3" received message: "Hello User2!"
- Complex Communication: Useful when components need to communicate in a complex way, and direct connections become convoluted.
- Reducing Dependencies: Employed to avoid tight dependencies between components, promoting flexibility.
- Centralized Control: When a centralized control point for communication is beneficial, providing a mediator makes sense.
- Decouples Components: Reduces direct connections between components, promoting cleaner and more maintainable code.
- Centralized Communication: Streamlines communication through a central mediator, simplifying the coordination of components.
- Easier Maintenance: Changes in communication logic can be made in one place (the mediator), easing maintenance.
- Mediator Complexity: The mediator itself may become complex as more components are added, potentially introducing its own challenges.
- Single Point of Failure: The mediator becomes a critical point; if it fails, the entire communication system may be affected.
- Learning Curve: Developers may need time to understand and adapt to the mediator pattern, potentially slowing down initial development.
The Memento pattern is a behavioral design pattern that allows an object's state to be captured and restored at a later time without exposing its internal structure. It enables the ability to undo or rollback changes and is particularly useful when dealing with the history or snapshots of an object's state.
In simple words:
Lets you save and restore the previous state of an object
// Memento
class EditorMemento {
private state: string;
constructor(state: string) {
this.state = state;
}
getState(): string {
return this.state;
}
}
// Originator
class TextDocument {
private text!: string;
createMemento(): EditorMemento {
return new EditorMemento(this.text);
}
restoreMemento(memento: EditorMemento): void {
this.text = memento.getState();
}
setText(text: string): void {
this.text = text;
}
getText(): string {
return this.text;
}
}
// Caretaker
class DocumentHistory {
private mementos: EditorMemento[] = [];
addMemento(memento: EditorMemento): void {
this.mementos.push(memento);
}
getMemento(index: number): EditorMemento {
return this.mementos[index];
}
}
// Client Code
const editor = new TextDocument();
const documentHistory = new DocumentHistory();
editor.setText("Hello World!");
documentHistory.addMemento(editor.createMemento());
editor.setText("Good Bye World!");
documentHistory.addMemento(editor.createMemento());
console.log(editor.getText()); // Good Bye World!
editor.restoreMemento(documentHistory.getMemento(0));
console.log(editor.getText()); // Hello World!
- Undo Mechanism: Ideal when an application needs an undo mechanism to revert changes made to an object's state.
- Version Control: Useful for maintaining different versions or snapshots of an object's state, providing a form of version control.
- Transaction Management: When managing transactions, it can help to save and restore states in case of failures.
- State Preservation: Captures an object's state, allowing it to be restored to a previous state.
- Encapsulation: Keeps the internal details of the object's state encapsulated within the memento, preventing direct access.
- Undo/Redo Support: Enables undo and redo functionalities by maintaining a history of states.
- Overhead: For objects with large or complex states, storing and managing multiple snapshots may introduce overhead.
- Memory Usage: Maintaining a history of states can consume memory, especially if not managed efficiently.
- Performance Impact: Frequent state capturing and restoring may impact performance, depending on the complexity of the object.
The Observer pattern is a behavioral design pattern where an object, known as the subject, maintains a list of dependents, known as observers, that are notified of any changes in the subject's state. This pattern establishes a one-to-many relationship between the subject and its observers, allowing multiple objects to react to changes in another object.
In Simple Words:
Defines a subscription mechanism to notify multiple objects about changes in an object's state.
// Subject interface
interface Subject {
addObserver(observer: Observer): void;
removeObserver(observer: Observer): void;
notifyObservers(): void;
}
// Concrete Subject: WeatherStation
class WeatherStation implements Subject {
private temperature: number = 0;
private observers: Observer[] = [];
addObserver(observer: Observer): void {
this.observers.push(observer);
}
removeObserver(observer: Observer): void {
const index = this.observers.indexOf(observer);
if (index !== -1) {
this.observers.splice(index, 1);
}
}
notifyObservers(): void {
for (const observer of this.observers) {
observer.update(this.temperature);
}
}
setTemperature(temperature: number): void {
this.temperature = temperature;
this.notifyObservers();
}
}
// Observer interface
interface Observer {
update(temperature: number): void;
}
// Concrete Observer: TemperatureDisplay
class TemperatureDisplay implements Observer {
private temperature: number = 0;
update(temperature: number): void {
this.temperature = temperature;
this.display();
}
display(): void {
console.log(`Temperature Display: ${this.temperature}°C`);
}
}
// Client Code
const weatherStation = new WeatherStation();
const display1 = new TemperatureDisplay();
const display2 = new TemperatureDisplay();
weatherStation.addObserver(display1);
weatherStation.addObserver(display2);
weatherStation.setTemperature(25);
// Output:
// Temperature Display: 25°C
// Temperature Display: 25°C
- Decoupling Components: Ideal when you want to decouple the sender (subject) and the receivers (observers), allowing them to operate independently.
- Event Handling: Useful for implementing event handling systems where one object's state changes should trigger actions in other objects.
- Dynamic Dependencies: When you have a scenario where the number and types of observers can change dynamically.
- Loose Coupling: Promotes loose coupling between the subject and observers, allowing changes in one to not directly affect the other.
- Extensibility: New observers can be added easily without modifying the subject, making the system more extensible.
- Notification Flexibility: Observers are notified only when relevant changes occur, providing flexibility in handling different types of notifications.
- Unintended Updates: Observers may receive updates that are not relevant to their current state, leading to unnecessary updates.
- Ordering Issues: The order in which observers are notified may be important, and managing this order can be challenging.
- Potential Memory Leaks: If observers are not properly removed when they are no longer needed, it may lead to memory leaks.
The State pattern is a behavioral design pattern that allows an object to change its behavior when its internal state changes. The pattern represents states as separate classes and allows the context (the object whose behavior changes) to switch between these states dynamically.
In Simple Words:
Enables an object to alter its behavior when its internal state changes by encapsulating states in separate classes.
// State interface
interface EditingState {
write(text: string): void;
save(): void;
}
// Concrete State 1: DraftState
class DraftState implements EditingState {
write(text: string): void {
console.log(`Drafting: ${text}`);
}
save(): void {
console.log("Draft saved");
}
}
// Concrete State 2: ReviewState
class ReviewState implements EditingState {
write(text: string): void {
console.log(`Reviewing: ${text}`);
}
save(): void {
console.log("Cannot save in review mode");
}
}
// Context: DocumentEditor
class DocumentEditor {
private editingState: EditingState;
constructor(initialState: EditingState) {
this.editingState = initialState;
}
setEditingState(state: EditingState): void {
this.editingState = state;
}
write(text: string): void {
this.editingState.write(text);
}
save(): void {
this.editingState.save();
}
}
// Usage
const documentEditor = new DocumentEditor(new DraftState());
documentEditor.write("Hello World");
documentEditor.save(); // Draft saved
documentEditor.setEditingState(new ReviewState());
documentEditor.write("Review comments");
documentEditor.save(); // Cannot save in review mode
- Object Behavior Depends on State: Useful when the behavior of an object changes based on its internal state.
- Avoiding Long Switch Statements: When there are multiple conditional statements (if/else or switch) based on the state, and you want to avoid long and complex switch statements.
- Dynamic State Transitions: When state transitions need to be dynamic, allowing for different transitions based on the current state.
- Clean Code Structure: Separates state-specific behaviors into individual classes, leading to a cleaner and more maintainable code structure.
- Encapsulation of States: Encapsulates states in separate classes, reducing the need for conditional statements and promoting encapsulation.
- Easy to Add/Modify States: Adding or modifying states is easier as each state is represented by its own class.
- Increased Number of Classes: The pattern introduces multiple state classes, potentially increasing the overall number of classes in the system.
- Complexity for Simple State Machines: For simple state machines, using the State pattern may introduce unnecessary complexity.
- Global Access to Context: State classes may need access to the context, which can lead to a global context or dependency injection.
The Strategy pattern is a behavioral design pattern that defines a family of algorithms, encapsulates each algorithm, and makes them interchangeable. It allows the client to choose an appropriate algorithm at runtime without altering the context (the object that uses the algorithm). This pattern enables a class to vary its behavior dynamically by having multiple algorithms and selecting one of them.
In Simple Words:
Defines a set of algorithms, encapsulates each one, and makes them interchangeable. Allows a client to choose an algorithm at runtime.
// Strategy interface
interface SortingStrategy {
sort(data: number[]): number[];
}
// Concrete Strategy 1: BubbleSort
class BubbleSort implements SortingStrategy {
sort(data: number[]): number[] {
console.log("Using Bubble Sort");
// Implementation of Bubble Sort algorithm
return data.slice().sort((a, b) => a - b);
}
}
// Concrete Strategy 2: QuickSort
class QuickSort implements SortingStrategy {
sort(data: number[]): number[] {
console.log("Using Quick Sort");
// Implementation of Quick Sort algorithm
return data.slice().sort((a, b) => a - b);
}
}
// Context: Sorter
class Sorter {
private strategy: SortingStrategy;
constructor(strategy: SortingStrategy) {
this.strategy = strategy;
}
setStrategy(strategy: SortingStrategy) {
this.strategy = strategy;
}
performSort(data: number[]): number[] {
console.log('SortingContext: Sorting data using the strategy.');
return this.strategy.sort(data);
}
}
// Usage
const dataset = [1, 9, 100, 7, 77, 0, 3];
const sorter = new Sorter(new BubbleSort());
sorter.performSort(dataset); // Using Bubble Sort ; [0, 1, 3, 7, 9, 77, 100]
sorter.setStrategy(new QuickSort());
sorter.performSort(dataset);// // Using Quick Sort ; [0, 1, 3, 7, 9, 77, 100]
- Multiple Algorithms: When you have multiple algorithms for performing a task, and you want to make them interchangeable.
- Dynamic Behavior: When you need to vary an object's behavior dynamically without altering its class.
- Encapsulation: Encapsulates algorithms in separate classes, promoting better code organization and maintainability.
- Flexibility: Provides flexibility by allowing clients to choose different algorithms at runtime.
- No Modification of Context: The context class remains unchanged even when switching between algorithms.
- Increased Number of Classes: Introducing multiple strategy classes may increase the overall number of classes in the system.
- Clients Must Be Aware: Clients need to be aware of different strategies and choose the appropriate one, which may add complexity.
The Template Method pattern is a behavioral design pattern that defines the skeleton of an algorithm in the superclass but lets subclasses override specific steps of the algorithm without changing its structure. It allows a class to delegate certain steps of an algorithm to its subclasses, providing a framework for creating a family of related algorithms.
In Simple Words:
Defines the structure of an algorithm in a superclass but allows subclasses to customize specific steps of the algorithm without changing its overall structure.
// Template Method: DocumentGenerator
abstract class DocumentGenerator {
generateDocument(): string {
const header = this.createHeader();
const content = this.createContent();
const footer = this.createFooter();
return `${header} - ${content} - ${footer}`;
}
abstract createHeader(): string;
abstract createContent(): string;
abstract createFooter(): string;
}
// Concrete Template Method 1: PDFDocumentGenerator
class PDFDocumentGenerator extends DocumentGenerator {
createHeader(): string {
return "PDF Header";
}
createContent(): string {
return "PDF Content";
}
createFooter(): string {
return "PDF Footer";
}
}
// Concrete Template Method 2: WordDocumentGenerator
class WordDocumentGenerator extends DocumentGenerator {
createHeader(): string {
return "Word Header";
}
createContent(): string {
return "Word Content";
}
createFooter(): string {
return "Word Footer";
}
}
// Usage
const pdfGenerator = new PDFDocumentGenerator();
console.log(pdfGenerator.generateDocument()); // PDF Header - PDF Content - PDF Footer
const wordGenerator = new WordDocumentGenerator();
console.log(wordGenerator.generateDocument()); // Word Header - Word Content - Word Footer
- Common Algorithm Structure: When multiple classes share a common algorithm structure, but some steps need to be implemented differently.
- Avoiding Code Duplication: When you want to avoid code duplication by encapsulating the common parts of algorithms in a base class.
- Providing Hooks: When you want to provide hooks (methods) that subclasses can override to customize behavior.
- Code Reusability: Encourages code reusability by defining a common algorithm structure in a base class.
- Flexibility: Allows subclasses to customize certain steps of the algorithm without altering its overall structure.
- Encapsulation: Encapsulates the common algorithm in one place, making it easier to maintain and understand.
- Rigidity: May lead to a rigid structure, and changes in the overall algorithm structure can impact all subclasses.
- Limited Runtime Changes: The algorithm structure is determined at compile-time, limiting the ability to make runtime changes easily.
The Visitor pattern is a behavioral design pattern that allows you to define a new operation without changing the classes of the elements on which it operates. It separates the algorithms from the objects on which they operate by encapsulating these algorithms in visitor objects. This pattern enables you to add new behaviors to a set of classes without modifying their structure.
In Simple Words:
Defines a way to perform operations on elements of a structure without changing the classes of those elements.
// Element interface
interface Shape {
accept(visitor: ShapeVisitor): void;
}
// Concrete Element 1: Circle
class Circle implements Shape {
radius: number;
constructor(radius: number) {
this.radius = radius;
}
accept(visitor: ShapeVisitor): void {
visitor.visitCircle(this);
}
}
// Concrete Element 2: Square
class Square implements Shape {
side: number;
constructor(side: number) {
this.side = side;
}
accept(visitor: ShapeVisitor): void {
visitor.visitSquare(this);
}
}
// Visitor interface
interface ShapeVisitor {
visitCircle(circle: Circle): void;
visitSquare(square: Square): void;
}
// Concrete Visitor 1: DrawingVisitor
class DrawingVisitor implements ShapeVisitor {
visitCircle(circle: Circle): void {
console.log(`Drawing Circle with radius ${circle.radius}`);
}
visitSquare(square: Square): void {
console.log(`Drawing Square with side ${square.side}`);
}
}
// Concrete Visitor 2: AreaCalculatorVisitor
class AreaCalculatorVisitor implements ShapeVisitor {
visitCircle(circle: Circle): void {
const area = Math.PI * circle.radius * circle.radius;
console.log(`Area of Circle: ${area.toFixed(2)}`);
}
visitSquare(square: Square): void {
const area = square.side * square.side;
console.log(`Area of Square: ${area}`);
}
}
// Usage
const circle = new Circle(5);
const square = new Square(4);
const drawingVisitor = new DrawingVisitor();
const areaCalculatorVisitor = new AreaCalculatorVisitor();
circle.accept(drawingVisitor); // Drawing Circle with radius 5
circle.accept(areaCalculatorVisitor); // Area of Circle: 78.54
square.accept(drawingVisitor); // Drawing Square with side 4
square.accept(areaCalculatorVisitor); // Area of Square: 16
- Adding Operations to Classes: When you want to add new operations to classes without modifying their code.
- Decoupling Operations: When you want to decouple the algorithm from the objects on which it operates.
- Complex Object Structures: When dealing with complex object structures and you want to keep related behaviors together.
- Open-Closed Principle: Supports the open-closed principle by allowing the addition of new operations without modifying existing classes.
- Modular and Extensible: Makes it easy to add new functionalities by introducing new visitor classes.
- Separation of Concerns: Separates concerns by moving the behavior into visitor classes, keeping the object structure clean.
- Increased Number of Classes: Introducing visitor classes may increase the overall number of classes in the system.
- Access to Private Members: Visitors might need access to private members of elements, leading to potential encapsulation violations.
SOLID is an acronym that represents a set of five design principles for writing maintainable and scalable software. These principles were introduced by Robert C. Martin and are considered foundational concepts in object-oriented programming and design. The SOLID principles aim to create robust, flexible, and easily maintainable software by promoting clean and efficient code organization.
- Maintainability: SOLID principles enhance code maintainability by providing a clear structure, reducing code smells, and making it easier to add or modify features.
- Scalability: As the codebase grows, adhering to SOLID principles helps manage complexity and ensures that the system remains scalable and adaptable to changes.
- Collaboration: A codebase following SOLID principles is more accessible and understandable, facilitating collaboration among developers and making it easier for new team members to grasp the code.
- Gradual Adoption: Implement SOLID principles gradually to existing codebases. Refactoring all code at once might not be practical.
- Real-world Applicability: Apply the principles judiciously. There are scenarios where breaking a principle is a better choice for specific reasons.
- Balance and Context: Achieving a balance between SOLID principles may require trade-offs. Consider the specific context of your application and team.
- S - Single Responsibility Principle (SRP) 🕺
- O - Open/Closed Principle (OCP) 🚪🔒
- L - Liskov Substitution Principle (LSP) 🧩
- I - Interface Segregation Principle (ISP) 🧍♂️🤝🧍♀️
- D - Dependency Inversion Principle (DIP) 🔄
The Single Responsibility Principle is one of the SOLID principles of object-oriented design. It states that a class should have only one reason to change, meaning it should have only one responsibility. Each class should focus on doing one thing and doing it well. This principle aims to enhance maintainability, readability, and flexibility in software development.
"A class should have only one reason to change." -- Robert C. Martin
class UserManager {
getUsers(): User[] { /* ... */ }
saveUser(user: User): void { /* ... */ }
deleteUser(userId: string): void { /* ... */ }
renderUser(user: User): void { /* ... */ } // Rendering logic in UserManager
}
In this bad example, the
UserManager
class is responsible for both managing user data (get, save, delete) and rendering users. This violates theSingle Responsibility Principle
because a class should have only one reason to change, and mixing data management with rendering introduces multiple responsibilities.
class UserManager {
getUsers(): User[] { /* ... */ }
saveUser(user: User): void { /* ... */ }
deleteUser(userId: string): void { /* ... */ }
}
class UserRenderer {
renderUser(user: User): void { /* ... */ }
}
class User {
constructor(public id: string, public name: string, public email: string) {}
}
const userManager = new UserManager();
const userRenderer = new UserRenderer();
const users = userManager.getUsers();
users.forEach(userRenderer.renderUser);
In this good example, responsibilities are separated into distinct classes.
UserManager
is responsible for managing user data, andUserRenderer
is responsible for rendering users. This adheres to theSingle Responsibility Principle
, making each class focused on a single task and improving maintainability.
- Readability: Clear responsibilities in each class improve code readability.
- Maintainability: Changes in one area don't affect unrelated parts, enhancing maintainability.
- Testing: Single responsibilities make classes easier to test.
- Reusability: Modular classes with single responsibilities are more reusable and adaptable.
The Open/Closed Principle is a SOLID design principle that suggests a class should be open for extension but closed for modification. This means that a class's behavior can be extended without altering its source code, promoting the addition of new features or functionalities without changing existing ones.
"The Open-Closed Principle states that "software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification." -- Robert C. Martin
class Discount {
giveDiscount(customerType: string): number {
if (customerType === "Regular") {
return 10;
} else if (customerType === "Premium") {
return 20;
}
}
}
In this bad example, if we want to introduce a new type of customer, let's say a "Gold" customer with a different discount, we would have to modify the
giveDiscount
method in theDiscount
class.
interface Customer {
giveDiscount(): number;
}
class RegularCustomer implements Customer {
giveDiscount(): number {
return 10;
}
}
class PremiumCustomer implements Customer {
giveDiscount(): number {
return 20;
}
}
class GoldCustomer implements Customer {
giveDiscount(): number {
return 30;
}
}
class Discount {
giveDiscount(customer: Customer): number {
return customer.giveDiscount();
}
}
In this good example, the
Open-Closed Principle
is adhered to. New customer types, likeGoldCustomer
, can be added without modifying existing code. Each customer type implements theCustomer
interface, and theDiscount
class is open for extension but closed for modification.
- Flexible Extension: Easily add new features or functionalities without modifying existing code.
- Reduced Bug Risk: Minimize the risk of introducing bugs in already functional code.
- Enhanced Code Stability: Existing code remains stable and reliable as new components are added.
The Liskov Substitution Principle is a SOLID design principle that states objects of a superclass should be replaceable with objects of its subclass without affecting the correctness of the program. In simpler terms, a derived class should be able to substitute its base class without causing errors.
A
subtype
should behave like asupertype
as far as you can tell by using thesupertype
methods.
abstract class Bird {
abstract fly(): void;
}
class Penguin extends Bird {
public fly(): void {
// ??? (Penguins cannot fly)
}
}
This example violates the Liskov Substitution Principle. The
Bird
abstract class declares an abstract methodfly()
, indicating that all birds should be able to fly. However, the Penguin class, being a bird, does not fulfill this contract appropriately.
abstract class Bird {
abstract move(): void;
}
class Sparrow extends Bird {
public move(): void {
console.log("Chirp chirp")
}
}
class Penguin extends Bird {
public move(): void {
console.log("Swimming")
}
}
// Function utilizing LSP
function performMove(bird: Bird): void {
bird.move();
}
const sparrow = new Sparrow();
const penguin = new Penguin();
performMove(sparrow); // Chirp chirp
performMove(penguin); // Swimming
In this good example, the
Sparrow
andPenguin
classes correctly adhere to the Liskov Substitution Principle. When substituting instances of these classes for theBird
abstract class, theperformMove
function behaves as expected, demonstrating consistency in the behavior of all bird subclasses.
- Consistent Behavior: Substituting derived classes ensures consistent behavior, promoting predictability.
- Maintenance Ease: Adding new subclasses is possible without modifying existing code, enhancing maintainability.
- Interchangeability: Base and derived class objects can be used interchangeably, providing flexibility.
- Extensibility Support: Facilitates the creation of new subclasses without disrupting existing code, supporting extensibility.
The Interface Segregation Principle is a SOLID design principle that suggests a class should not be forced to implement interfaces it does not use. In simpler terms, it encourages breaking large interfaces into smaller, more specific ones, preventing classes from being burdened with methods they don't need.
"No client should be forced to depend on interfaces they do not use." - Robert C. Martin
// Interface: Worker
interface Worker {
work(): void;
eat(): void;
}
// Class 1: Robot (implements Worker)
class Robot implements Worker {
work(): void {
console.log("Robot is working");
}
eat(): void {
// Robots don't eat.
console.log("Robot does not eat");
}
}
// Class 2: Human (implements Worker)
class Human implements Worker {
work(): void {
console.log("Human is working");
}
eat(): void {
console.log("Human is eating");
}
}
In this bad example, the
Worker
interface is broad and includes theeat
method, which is irrelevant for theRobot
class. This violates theInterface Segregation Principle
because theRobot
class is forced to implement a method it doesn't use.
// Interface: Worker
interface Worker {
work(): void;
}
// Interface: Eater
interface Eater {
eat(): void;
}
// Class 1: Robot (implements only Worker)
class Robot implements Worker {
work(): void {
console.log("Robot is working");
}
}
// Class 2: Human (implements Worker and Eater)
class Human implements Worker, Eater {
work(): void {
console.log("Human is working");
}
eat(): void {
console.log("Human is eating");
}
}
// Class 3: Dog (implements only Eater)
class Dog implements Eater {
eat(): void {
console.log("Dog is eating");
}
}
In this good example, the
Worker
interface is focused only on thework
method. Additionally, a separateEater
interface is introduced for classes that need aneat
method. This adheres to theInterface Segregation Principle
, as classes now implement only the interfaces relevant to their functionalities.
- Reduced Dependency: Smaller interfaces reduce dependencies, preventing classes from implementing unnecessary methods.
- Enhanced Readability: Classes only implement interfaces relevant to their functionality, improving code readability.
- Flexibility: Classes can evolve independently, allowing the addition of new interfaces without affecting unrelated classes.
- Avoid "Fat" Interfaces: Prevents creation of bloated interfaces, keeping them specific to their use cases.
The Dependency Inversion Principle is a SOLID design principle that emphasizes high-level modules should not depend on low-level modules, but both should depend on abstractions. It promotes the use of abstractions (interfaces or abstract classes) to decouple higher-level and lower-level modules, fostering flexibility and maintainability.
"High-level modules should not depend on low-level modules. Both should depend on abstractions. Abstractions should not depend on details. Details should depend on abstractions." - Robert C. Martin
// Abstraction (interface)
interface Switchable {
turnOn(): void;
turnOff(): void;
}
// Low-level module 1: Bulb
class Bulb implements Switchable {
turnOn(): void {
console.log("Bulb is on");
}
turnOff(): void {
console.log("Bulb is off");
}
}
// Low-level module 2: Fan
class Fan implements Switchable {
turnOn(): void {
console.log("Fan is on");
}
turnOff(): void {
console.log("Fan is off");
}
}
// High-level module utilizing DIP
class Switch {
constructor(private device: Switchable) {}
operate(): void {
this.device.turnOn();
// Additional operations if needed
this.device.turnOff();
}
}
// Usage
const bulb = new Bulb();
const fan = new Fan();
const switchForBulb = new Switch(bulb);
const switchForFan = new Switch(fan);
switchForBulb.operate(); // Bulb is on, Bulb is off
switchForFan.operate(); // Fan is on, Fan is off
Switchable
is the abstraction (interface) representing switchable devices.Bulb
andFan
are low-level modules implementing theSwitchable
interface.Switch
is the high-level module that depends on the abstraction (Switchable), adhering toDIP
.
- Flexibility: Abstractions allow easy substitution of low-level modules without affecting high-level modules.
- Reduced Coupling: High-level and low-level modules are decoupled through abstractions, promoting a more maintainable and adaptable codebase.
- Easier Testing: Dependency inversion simplifies testing by allowing the use of mock objects for abstraction, facilitating unit testing.