Capsule 2: Design Patterns

Understand and apply common design patterns to solve software design problems effectively.

Author: Sami Belhadj

Connect on LinkedIn

Hear the audio version :

1/3 :

2/3 :

3/3 :

Capsule Overview

Capsule 2 focuses on Design Patterns, which are tried-and-tested solutions to common software design problems. By understanding and applying these patterns, you can create more flexible, reusable, and maintainable code. This capsule covers:

Session 6: Design Patterns (2 hours)

Introduction

Design patterns are typical solutions to common problems in software design. They are proven, reusable, and adaptable solutions that can be used in various situations. Understanding design patterns helps developers create more maintainable, flexible, and scalable software architectures.

1. Creational Patterns

Creational patterns deal with object creation mechanisms, trying to create objects in a manner suitable to the situation. They help make a system independent of how its objects are created, composed, and represented.

1.1 Singleton Pattern

The Singleton Pattern ensures that a class has only one instance and provides a global point of access to it.

Example of Singleton Pattern
      class Singleton {
        private static instance: Singleton;

        private constructor() {}

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

      // Usage
      const singleton1 = Singleton.getInstance();
      const singleton2 = Singleton.getInstance();

      console.log(singleton1 === singleton2);  // true
            

In this example, the Singleton class ensures that only one instance of itself can be created. The getInstance method provides a way to access this instance globally.

1.2 Factory Method Pattern

The Factory Method Pattern defines an interface for creating an object, but allows subclasses to alter the type of objects that will be created.

Example of Factory Method Pattern
      interface Product {
        operation(): string;
      }

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

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

      abstract class Creator {
        public abstract factoryMethod(): Product;

        public someOperation(): string {
            const product = this.factoryMethod();
            return `Creator: Working with ${product.operation()}`;
        }
      }

      class ConcreteCreatorA extends Creator {
        public factoryMethod(): Product {
            return new ConcreteProductA();
        }
      }

      class ConcreteCreatorB extends Creator {
        public factoryMethod(): Product {
            return new ConcreteProductB();
        }
      }

      // Usage
      const creatorA = new ConcreteCreatorA();
      console.log(creatorA.someOperation());

      const creatorB = new ConcreteCreatorB();
      console.log(creatorB.someOperation());
            

In this example, the Creator class uses the Factory Method to delegate the instantiation of Product objects to its subclasses.

2. Structural Patterns

Structural patterns deal with object composition, forming larger structures from individual objects. They help ensure that if one part of a system changes, the entire system doesn't need to change.

2.1 Adapter Pattern

The Adapter Pattern allows objects with incompatible interfaces to collaborate.

Example of Adapter Pattern
      class Target {
        public request(): string {
            return "Target: The default target's behavior.";
        }
      }

      class Adaptee {
        public specificRequest(): string {
            return ".eetpadA eht fo roivaheb laicepS";
        }
      }

      class Adapter extends Target {
        private adaptee: Adaptee;

        constructor(adaptee: Adaptee) {
            super();
            this.adaptee = adaptee;
        }

        public request(): string {
            const result = this.adaptee.specificRequest().split('').reverse().join('');
            return `Adapter: (TRANSLATED) ${result}`;
        }
      }

      // Usage
      const adaptee = new Adaptee();
      const adapter = new Adapter(adaptee);
      console.log(adapter.request());
            

In this example, the Adapter class allows the Adaptee (with an incompatible interface) to work with the Target class.

2.2 Composite Pattern

The Composite Pattern allows you to compose objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions of objects uniformly.

Example of Composite Pattern
      interface Component {
        operation(): string;
      }

      class Leaf implements Component {
        public operation(): string {
            return 'Leaf';
        }
      }

      class Composite implements Component {
        protected children: Component[] = [];

        public add(component: Component): void {
            this.children.push(component);
        }

        public remove(component: Component): void {
            const componentIndex = this.children.indexOf(component);
            this.children.splice(componentIndex, 1);
        }

        public operation(): string {
            return `Branch(${this.children.map(child => child.operation()).join('+')})`;
        }
      }

      // Usage
      const leaf = new Leaf();
      const tree = new Composite();

      const branch1 = new Composite();
      branch1.add(new Leaf());
      branch1.add(new Leaf());

      const branch2 = new Composite();
      branch2.add(new Leaf());

      tree.add(branch1);
      tree.add(branch2);
      tree.add(leaf);

      console.log(tree.operation());
            

In this example, the Composite class allows individual Leaf objects and groups of Leaf objects (branches) to be treated uniformly.

3. Behavioral Patterns

Behavioral patterns are concerned with algorithms and the assignment of responsibilities between objects. They describe not just the patterns of objects or classes but also the patterns of communication between them.

3.1 Strategy Pattern

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

Example of Strategy Pattern
      interface Strategy {
        execute(a: number, b: number): number;
      }

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

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

      class Context {
        private strategy: Strategy;

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

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

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

      // Usage
      const context = new Context(new ConcreteStrategyAdd());
      console.log('Result:', context.executeStrategy(3, 4));

      context.setStrategy(new ConcreteStrategyMultiply());
      console.log('Result:', context.executeStrategy(3, 4));
            

In this example, the Context class uses different strategies (e.g., ConcreteStrategyAdd, ConcreteStrategyMultiply) to execute the algorithm, allowing the strategy to change at runtime.

3.2 Observer Pattern

The Observer Pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically.

Example of Observer Pattern
      interface Observer {
        update(subject: Subject): void;
      }

      interface Subject {
        attach(observer: Observer): void;
        detach(observer: Observer): void;
        notify(): void;
      }

      class ConcreteSubject implements Subject {
        public state: number;
        private observers: Observer[] = [];

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

        public detach(observer: Observer): void {
            const observerIndex = this.observers.indexOf(observer);
            this.observers.splice(observerIndex, 1);
        }

        public notify(): void {
            for (const observer of this.observers) {
                observer.update(this);
            }
        }

        public setState(state: number): void {
            this.state = state;
            this.notify();
        }
      }

      class ConcreteObserver implements Observer {
        public update(subject: Subject): void {
            console.log(`Observer: Reacted to state change to ${subject.state}`);
        }
      }

      // Usage
      const subject = new ConcreteSubject();
      const observer1 = new ConcreteObserver();
      const observer2 = new ConcreteObserver();

      subject.attach(observer1);
      subject.attach(observer2);

      subject.setState(1);
      subject.setState(2);
            

In this example, the ConcreteObserver objects are notified automatically when the state of ConcreteSubject changes.

Conclusion

Design patterns are powerful tools that help solve common design problems in software development. By understanding and applying these patterns, senior developers can create more modular, flexible, and maintainable software systems.

Session 7: Creational Design Patterns (2 hours)

Introduction

Creational design patterns deal with object creation mechanisms, aiming to create objects in a manner suitable to the situation. These patterns help to make the system independent of how its objects are created, composed, and represented. By abstracting the instantiation process, they help make the code more flexible and reusable.

1. Singleton Pattern

The Singleton Pattern ensures that a class has only one instance and provides a global point of access to that instance. This is particularly useful when exactly one object is needed to coordinate actions across the system.

Example of Singleton Pattern
      class Singleton {
        private static instance: Singleton;

        private constructor() {
            // Private constructor prevents direct instantiation
        }

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

        public someBusinessLogic() {
            // Implement some business logic
        }
      }

      // Usage
      const singleton1 = Singleton.getInstance();
      const singleton2 = Singleton.getInstance();

      console.log(singleton1 === singleton2);  // true, both variables contain the same instance
            

In this example, the Singleton class ensures that only one instance of itself can be created. The getInstance method checks if the instance already exists, and if not, it creates one. Subsequent calls to getInstance return the existing instance.

2. Factory Method Pattern

The Factory Method Pattern defines an interface for creating an object, but allows subclasses to alter the type of objects that will be created. It is useful when the exact type of object cannot be determined until runtime.

Example of Factory Method Pattern
      interface Product {
        operation(): string;
      }

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

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

      abstract class Creator {
        public abstract factoryMethod(): Product;

        public someOperation(): string {
            const product = this.factoryMethod();
            return `Creator: Working with ${product.operation()}`;
        }
      }

      class ConcreteCreatorA extends Creator {
        public factoryMethod(): Product {
            return new ConcreteProductA();
        }
      }

      class ConcreteCreatorB extends Creator {
        public factoryMethod(): Product {
            return new ConcreteProductB();
        }
      }

      // Usage
      const creatorA = new ConcreteCreatorA();
      console.log(creatorA.someOperation()); // Creator: Working with Result of ConcreteProductA

      const creatorB = new ConcreteCreatorB();
      console.log(creatorB.someOperation()); // Creator: Working with Result of ConcreteProductB
            

In this example, the Creator class declares the factory method, which must be implemented by subclasses like ConcreteCreatorA and ConcreteCreatorB. These subclasses return different types of Product objects.

3. Abstract Factory Pattern

The Abstract Factory Pattern provides an interface for creating families of related or dependent objects without specifying their concrete classes. It is useful when a system needs to be independent of the way its products are created.

Example of Abstract Factory Pattern
      interface Button {
        paint(): void;
      }

      class WindowsButton implements Button {
        public paint(): void {
            console.log('Rendering a button in Windows style');
        }
      }

      class MacOSButton implements Button {
        public paint(): void {
            console.log('Rendering a button in MacOS style');
        }
      }

      interface GUIFactory {
        createButton(): Button;
      }

      class WindowsFactory implements GUIFactory {
        public createButton(): Button {
            return new WindowsButton();
        }
      }

      class MacOSFactory implements GUIFactory {
        public createButton(): Button {
            return new MacOSButton();
        }
      }

      class Application {
        private factory: GUIFactory;
        private button: Button;

        constructor(factory: GUIFactory) {
            this.factory = factory;
        }

        public createUI(): void {
            this.button = this.factory.createButton();
        }

        public paint(): void {
            this.button.paint();
        }
      }

      // Usage
      let app: Application;
      let factory: GUIFactory;

      const os = 'Windows'; // This could be dynamically determined

      if (os === 'Windows') {
        factory = new WindowsFactory();
      } else {
        factory = new MacOSFactory();
      }

      app = new Application(factory);
      app.createUI();
      app.paint(); // Output will be based on the selected factory
            

In this example, the Abstract Factory pattern allows the creation of related objects (e.g., buttons) without specifying their exact classes. The Application class can work with any factory (Windows or MacOS) without modifying its code.

4. Builder Pattern

The Builder Pattern separates the construction of a complex object from its representation, allowing the same construction process to create different representations. This is useful when an object needs to be created with many optional parameters.

Example of Builder Pattern
      class Product {
        public parts: string[] = [];

        public listParts(): void {
            console.log(`Product parts: ${this.parts.join(', ')}`);
        }
      }

      interface Builder {
        buildPartA(): void;
        buildPartB(): void;
        buildPartC(): void;
        getProduct(): Product;
      }

      class ConcreteBuilder implements Builder {
        private product: Product;

        constructor() {
            this.reset();
        }

        public reset(): void {
            this.product = new Product();
        }

        public buildPartA(): void {
            this.product.parts.push('PartA');
        }

        public buildPartB(): void {
            this.product.parts.push('PartB');
        }

        public buildPartC(): void {
            this.product.parts.push('PartC');
        }

        public getProduct(): Product {
            const result = this.product;
            this.reset();
            return result;
        }
      }

      class Director {
        private builder: Builder;

        public setBuilder(builder: Builder): void {
            this.builder = builder;
        }

        public buildMinimalViableProduct(): void {
            this.builder.buildPartA();
        }

        public buildFullFeaturedProduct(): void {
            this.builder.buildPartA();
            this.builder.buildPartB();
            this.builder.buildPartC();
        }
      }

      // Usage
      const director = new Director();
      const builder = new ConcreteBuilder();
      director.setBuilder(builder);

      console.log('Standard basic product:');
      director.buildMinimalViableProduct();
      builder.getProduct().listParts();

      console.log('Standard full featured product:');
      director.buildFullFeaturedProduct();
      builder.getProduct().listParts();

      console.log('Custom product:');
      builder.buildPartA();
      builder.buildPartC();
      builder.getProduct().listParts();
            

In this example, the Builder pattern constructs complex objects step by step. The Director class defines the order in which to execute the building steps, while the ConcreteBuilder class implements the building steps to create the final Product.

5. Prototype Pattern

The Prototype Pattern allows creating new objects by copying an existing object, known as the prototype. This is useful when the cost of creating a new object is more expensive than copying an existing one.

Example of Prototype Pattern
      class Prototype {
        public primitive: any;
        public component: Object;
        public circularReference: ComponentWithBackReference;

        public clone(): this {
            const clone = Object.create(this);

            clone.component = Object.assign({}, this.component);

            // Cloning an object that has a back reference requires special treatment
            clone.circularReference = {
                ...this.circularReference,
                prototype: { ...this },
            };

            return clone;
        }
      }

      class ComponentWithBackReference {
        public prototype;

        constructor(prototype: Prototype) {
            this.prototype = prototype;
        }
      }

      // Usage
      const original = new Prototype();
      original.primitive = 245;
      original.component = new Date();
      original.circularReference = new ComponentWithBackReference(original);

      const clone = original.clone();

      console.log(original);
      console.log(clone);
      console.log(original.circularReference.prototype !== clone.circularReference.prototype); // true
            

In this example, the Prototype pattern is used to create a new object by cloning an existing object. This is particularly useful when the new object needs to be similar to an existing object but with slight modifications.

Conclusion

Creational design patterns provide various ways to create objects while keeping your code flexible, scalable, and easy to maintain. By abstracting the instantiation process, they help you avoid tight coupling and repetitive code, leading to a more elegant and robust software design.

Session 8: Structural Design Patterns (2 hours)

Introduction

Structural design patterns are concerned with object composition. These patterns help ensure that if one part of a system changes, the entire system doesn’t need to change. They describe how objects and classes can be combined to form larger structures while keeping these structures flexible and efficient.

1. Adapter Pattern

The Adapter Pattern allows objects with incompatible interfaces to work together. It acts as a bridge between two incompatible interfaces by wrapping one interface and exposing it as another.

Example of Adapter Pattern
      class Target {
        public request(): string {
            return "Target: The default target's behavior.";
        }
      }

      class Adaptee {
        public specificRequest(): string {
            return ".eetpadA eht fo roivaheb laicepS";
        }
      }

      class Adapter extends Target {
        private adaptee: Adaptee;

        constructor(adaptee: Adaptee) {
            super();
            this.adaptee = adaptee;
        }

        public request(): string {
            const result = this.adaptee.specificRequest().split('').reverse().join('');
            return `Adapter: (TRANSLATED) ${result}`;
        }
      }

      // Usage
      const adaptee = new Adaptee();
      const adapter = new Adapter(adaptee);
      console.log(adapter.request());
            

In this example, the Adapter class allows the Adaptee class to be used where a Target interface is expected, despite their incompatible interfaces.

2. Bridge Pattern

The Bridge Pattern decouples an abstraction from its implementation so that the two can vary independently. This pattern is useful when you want to avoid a permanent binding between an abstraction and its implementation.

Example of Bridge Pattern
      interface Implementor {
        operationImpl(): string;
      }

      class ConcreteImplementorA implements Implementor {
        public operationImpl(): string {
            return 'ConcreteImplementorA: Operation Implementation';
        }
      }

      class ConcreteImplementorB implements Implementor {
        public operationImpl(): string {
            return 'ConcreteImplementorB: Operation Implementation';
        }
      }

      abstract class Abstraction {
        protected implementor: Implementor;

        constructor(implementor: Implementor) {
            this.implementor = implementor;
        }

        public abstract operation(): string;
      }

      class RefinedAbstraction extends Abstraction {
        public operation(): string {
            return `RefinedAbstraction: ${this.implementor.operationImpl()}`;
        }
      }

      // Usage
      const implementorA = new ConcreteImplementorA();
      const implementorB = new ConcreteImplementorB();

      const abstractionA = new RefinedAbstraction(implementorA);
      const abstractionB = new RefinedAbstraction(implementorB);

      console.log(abstractionA.operation());
      console.log(abstractionB.operation());
            

In this example, the Abstraction and Implementor hierarchies can evolve independently. The Bridge pattern allows changing the implementation dynamically without altering the abstraction.

3. Composite Pattern

The Composite Pattern allows you to compose objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions of objects uniformly.

Example of Composite Pattern
      interface Component {
        operation(): string;
      }

      class Leaf implements Component {
        public operation(): string {
            return 'Leaf';
        }
      }

      class Composite implements Component {
        protected children: Component[] = [];

        public add(component: Component): void {
            this.children.push(component);
        }

        public remove(component: Component): void {
            const componentIndex = this.children.indexOf(component);
            this.children.splice(componentIndex, 1);
        }

        public operation(): string {
            return `Branch(${this.children.map(child => child.operation()).join('+')})`;
        }
      }

      // Usage
      const leaf = new Leaf();
      const tree = new Composite();

      const branch1 = new Composite();
      branch1.add(new Leaf());
      branch1.add(new Leaf());

      const branch2 = new Composite();
      branch2.add(new Leaf());

      tree.add(branch1);
      tree.add(branch2);
      tree.add(leaf);

      console.log(tree.operation());
            

In this example, the Composite pattern allows individual Leaf objects and groups of Leaf objects (branches) to be treated uniformly.

4. Decorator Pattern

The Decorator Pattern allows behavior to be added to individual objects, dynamically, without affecting the behavior of other objects from the same class. It’s a flexible alternative to subclassing for extending functionality.

Example of Decorator Pattern
      interface Component {
        operation(): string;
      }

      class ConcreteComponent implements Component {
        public operation(): string {
            return 'ConcreteComponent';
        }
      }

      class Decorator implements Component {
        protected component: Component;

        constructor(component: Component) {
            this.component = component;
        }

        public operation(): string {
            return this.component.operation();
        }
      }

      class ConcreteDecoratorA extends Decorator {
        public operation(): string {
            return `ConcreteDecoratorA(${super.operation()})`;
        }
      }

      class ConcreteDecoratorB extends Decorator {
        public operation(): string {
            return `ConcreteDecoratorB(${super.operation()})`;
        }
      }

      // Usage
      const simple = new ConcreteComponent();
      console.log('Client: Simple component:', simple.operation());

      const decorator1 = new ConcreteDecoratorA(simple);
      const decorator2 = new ConcreteDecoratorB(decorator1);
      console.log('Client: Decorated component:', decorator2.operation());
            

In this example, the Decorator pattern is used to add additional behavior to a component at runtime, without modifying the original object.

5. Facade Pattern

The Facade Pattern provides a simplified interface to a complex subsystem. It hides the complexities of the system and provides an interface that is easier to use.

Example of Facade Pattern
      class Subsystem1 {
        public operation1(): string {
            return 'Subsystem1: Ready!';
        }

        public operationN(): string {
            return 'Subsystem1: Go!';
        }
      }

      class Subsystem2 {
        public operation1(): string {
            return 'Subsystem2: Get ready!';
        }

        public operationZ(): string {
            return 'Subsystem2: Fire!';
        }
      }

      class Facade {
        protected subsystem1: Subsystem1;
        protected subsystem2: Subsystem2;

        constructor(subsystem1: Subsystem1, subsystem2: Subsystem2) {
            this.subsystem1 = subsystem1;
            this.subsystem2 = subsystem2;
        }

        public operation(): string {
            let result = 'Facade initializes subsystems:\n';
            result += this.subsystem1.operation1();
            result += '\n';
            result += this.subsystem2.operation1();
            result += '\n';
            result += 'Facade orders subsystems to perform the action:\n';
            result += this.subsystem1.operationN();
            result += '\n';
            result += this.subsystem2.operationZ();
            return result;
        }
      }

      // Usage
      const subsystem1 = new Subsystem1();
      const subsystem2 = new Subsystem2();
      const facade = new Facade(subsystem1, subsystem2);
      console.log(facade.operation());
            

In this example, the Facade class simplifies the interactions between the client and the complex subsystems by providing a unified interface.

6. Proxy Pattern

The Proxy Pattern provides a surrogate or placeholder for another object to control access to it. This pattern is useful for implementing lazy initialization, access control, logging, and more.

Example of Proxy Pattern
      interface Subject {
        request(): void;
      }

      class RealSubject implements Subject {
        public request(): void {
            console.log('RealSubject: Handling request.');
        }
      }

      class Proxy implements Subject {
        private realSubject: RealSubject;

        constructor(realSubject: RealSubject) {
            this.realSubject = realSubject;
        }

        public request(): void {
            if (this.checkAccess()) {
                this.realSubject.request();
                this.logAccess();
            }
        }

        private checkAccess(): boolean {
            console.log('Proxy: Checking access prior to firing a real request.');
            return true;
        }

        private logAccess(): void {
            console.log('Proxy: Logging the time of request.');
        }
      }

      // Usage
      const realSubject = new RealSubject();
      const proxy = new Proxy(realSubject);
      proxy.request();
            

In this example, the Proxy class controls access to the RealSubject object, allowing additional functionality such as access control and logging.

Practical Exercises

Now that you have learned about the various structural design patterns, it's time to apply them in practical scenarios. Below are some exercises designed to reinforce your understanding:

  1. Adapter Pattern Exercise:

    Identify a scenario in your current project where two classes with incompatible interfaces need to work together. Implement an Adapter to bridge the gap between these interfaces.

  2. Bridge Pattern Exercise:

    Refactor a class hierarchy in your project that has multiple variations of an abstraction and its implementations. Use the Bridge Pattern to decouple the abstraction from its implementation, allowing both to evolve independently.

  3. Composite Pattern Exercise:

    Design a system that represents a part-whole hierarchy, such as a company structure with employees and departments. Implement the Composite Pattern to allow uniform treatment of individual employees and departments.

  4. Decorator Pattern Exercise:

    Extend the functionality of a class in your project dynamically without using inheritance. Use the Decorator Pattern to add additional behavior to the class at runtime.

  5. Facade Pattern Exercise:

    Identify a subsystem in your project that has a complex interface. Implement a Facade to provide a simpler interface to the subsystem, making it easier to use.

  6. Proxy Pattern Exercise:

    Implement a Proxy in your project to control access to a resource-intensive object. Use the Proxy to implement lazy initialization, access control, or logging for the object.

Conclusion

Structural design patterns play a critical role in creating flexible and maintainable software architectures. By understanding and applying these patterns, you can ensure that your software is robust, scalable, and easy to manage.

Session 9: Behavioral Design Patterns (2 hours)

Introduction

Behavioral design patterns are concerned with algorithms and the assignment of responsibilities between objects. These patterns describe not just the patterns of objects or classes but also the patterns of communication between them. They help make interactions between objects more flexible and efficient.

1. Strategy Pattern

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

Example of Strategy Pattern
      interface Strategy {
        execute(a: number, b: number): number;
      }

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

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

      class Context {
        private strategy: Strategy;

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

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

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

      // Usage
      const context = new Context(new ConcreteStrategyAdd());
      console.log('Add:', context.executeStrategy(3, 4));  // Output: Add: 7

      context.setStrategy(new ConcreteStrategyMultiply());
      console.log('Multiply:', context.executeStrategy(3, 4));  // Output: Multiply: 12
            

In this example, the Context class can change its behavior by switching strategies at runtime, such as switching between addition and multiplication strategies.

2. Observer Pattern

The Observer Pattern defines a one-to-many dependency between objects so that when one object changes state, all its dependents are notified and updated automatically. This pattern is commonly used in event-driven systems.

Example of Observer Pattern
      interface Observer {
        update(subject: Subject): void;
      }

      interface Subject {
        attach(observer: Observer): void;
        detach(observer: Observer): void;
        notify(): void;
      }

      class ConcreteSubject implements Subject {
        public state: number;
        private observers: Observer[] = [];

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

        public detach(observer: Observer): void {
            const observerIndex = this.observers.indexOf(observer);
            this.observers.splice(observerIndex, 1);
        }

        public notify(): void {
            for (const observer of this.observers) {
                observer.update(this);
            }
        }

        public setState(state: number): void {
            this.state = state;
            this.notify();
        }
      }

      class ConcreteObserver implements Observer {
        public update(subject: Subject): void {
            console.log(`Observer: Reacted to state change to ${subject['state']}`);
        }
      }

      // Usage
      const subject = new ConcreteSubject();
      const observer1 = new ConcreteObserver();
      const observer2 = new ConcreteObserver();

      subject.attach(observer1);
      subject.attach(observer2);

      subject.setState(1);  // Both observers will be notified
      subject.setState(2);  // Both observers will be notified again
            

In this example, the ConcreteObserver objects are automatically notified whenever the state of the ConcreteSubject changes, demonstrating the one-to-many relationship.

3. Command Pattern

The Command Pattern turns a request into a stand-alone object that contains all information about the request. This transformation lets you parameterize methods with different requests, delay or queue requests, and support undoable operations.

Example of Command Pattern
      interface Command {
        execute(): void;
      }

      class SimpleCommand implements Command {
        private payload: string;

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

        public execute(): void {
            console.log(`SimpleCommand: Printing (${this.payload})`);
        }
      }

      class ComplexCommand implements Command {
        private receiver: Receiver;
        private a: string;
        private b: string;

        constructor(receiver: Receiver, a: string, b: string) {
            this.receiver = receiver;
            this.a = a;
            this.b = b;
        }

        public execute(): void {
            console.log('ComplexCommand: Delegating complex tasks to receiver.');
            this.receiver.doSomething(this.a);
            this.receiver.doSomethingElse(this.b);
        }
      }

      class Receiver {
        public doSomething(a: string): void {
            console.log(`Receiver: Working on (${a})`);
        }

        public doSomethingElse(b: string): void {
            console.log(`Receiver: Also working on (${b})`);
        }
      }

      class Invoker {
        private onStart: Command;
        private onFinish: Command;

        public setOnStart(command: Command): void {
            this.onStart = command;
        }

        public setOnFinish(command: Command): void {
            this.onFinish = command;
        }

        public doSomethingImportant(): void {
            console.log('Invoker: Does anybody want something done before I begin?');
            if (this.onStart) {
                this.onStart.execute();
            }

            console.log('Invoker: ...doing something really important...');

            console.log('Invoker: Does anybody want something done after I finish?');
            if (this.onFinish) {
                this.onFinish.execute();
            }
        }
      }

      // Usage
      const invoker = new Invoker();
      invoker.setOnStart(new SimpleCommand('Hello!'));
      const receiver = new Receiver();
      invoker.setOnFinish(new ComplexCommand(receiver, 'Send email', 'Save report'));

      invoker.doSomethingImportant();
            

In this example, the Invoker holds commands that are executed before and after an important operation, allowing for greater flexibility and the ability to queue or delay execution.

4. Chain of Responsibility Pattern

The Chain of Responsibility Pattern 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.

Example of Chain of Responsibility Pattern
      abstract class Handler {
        private nextHandler: Handler;

        public setNext(handler: Handler): Handler {
            this.nextHandler = handler;
            return handler;
        }

        public handle(request: string): string {
            if (this.nextHandler) {
                return this.nextHandler.handle(request);
            }

            return null;
        }
      }

      class MonkeyHandler extends Handler {
        public handle(request: string): string {
            if (request === 'Banana') {
                return `Monkey: I'll eat the ${request}.`;
            }
            return super.handle(request);
        }
      }

      class SquirrelHandler extends Handler {
        public handle(request: string): string {
            if (request === 'Nut') {
                return `Squirrel: I'll eat the ${request}.`;
            }
            return super.handle(request);
        }
      }

      class DogHandler extends Handler {
        public handle(request: string): string {
            if (request === 'Bone') {
                return `Dog: I'll eat the ${request}.`;
            }
            return super.handle(request);
        }
      }

      // Usage
      const monkey = new MonkeyHandler();
      const squirrel = new SquirrelHandler();
      const dog = new DogHandler();

      monkey.setNext(squirrel).setNext(dog);

      const requests = ['Nut', 'Banana', 'Cup of coffee'];

      for (const request of requests) {
        const result = monkey.handle(request);
        if (result) {
            console.log(result);
        } else {
            console.log(`${request} was left untouched.`);
        }
      }
            

In this example, the request is passed along the chain of handlers until one of the handlers processes it. If no handler processes the request, it remains unhandled.

5. Mediator Pattern

The Mediator Pattern defines an object that encapsulates how a set of objects interact. The mediator promotes loose coupling by keeping objects from referring to each other explicitly and it lets you vary their interaction independently.

Example of Mediator Pattern
      interface Mediator {
        notify(sender: object, event: string): void;
      }

      class ConcreteMediator implements Mediator {
        private component1: Component1;
        private component2: Component2;

        constructor(c1: Component1, c2: Component2) {
            this.component1 = c1;
            this.component1.setMediator(this);
            this.component2 = c2;
            this.component2.setMediator(this);
        }

        public notify(sender: object, event: string): void {
            if (event === 'A') {
                console.log('Mediator reacts on A and triggers following operations:');
                this.component2.doC();
            }

            if (event === 'D') {
                console.log('Mediator reacts on D and triggers following operations:');
                this.component1.doB();
                this.component2.doC();
            }
        }
      }

      class BaseComponent {
        protected mediator: Mediator;

        constructor(mediator?: Mediator) {
            this.mediator = mediator;
        }

        public setMediator(mediator: Mediator): void {
            this.mediator = mediator;
        }
      }

      class Component1 extends BaseComponent {
        public doA(): void {
            console.log('Component 1 does A.');
            this.mediator.notify(this, 'A');
        }

        public doB(): void {
            console.log('Component 1 does B.');
            this.mediator.notify(this, 'B');
        }
      }

      class Component2 extends BaseComponent {
        public doC(): void {
            console.log('Component 2 does C.');
            this.mediator.notify(this, 'C');
        }

        public doD(): void {
            console.log('Component 2 does D.');
            this.mediator.notify(this, 'D');
        }
      }

      // Usage
      const c1 = new Component1();
      const c2 = new Component2();
      const mediator = new ConcreteMediator(c1, c2);

      console.log('Client triggers operation A.');
      c1.doA();

      console.log('\nClient triggers operation D.');
      c2.doD();
            

In this example, the Mediator controls the interactions between different components, preventing them from directly communicating with each other and thus promoting loose coupling.

6. Memento Pattern

The Memento Pattern provides the ability to restore an object to its previous state. It’s useful for implementing undo mechanisms.

Example of Memento Pattern
      class Memento {
        private state: string;

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

        public getState(): string {
            return this.state;
        }
      }

      class Originator {
        private state: string;

        public setState(state: string): void {
            this.state = state;
            console.log(`Originator: Setting state to ${state}`);
        }

        public save(): Memento {
            console.log('Originator: Saving to Memento.');
            return new Memento(this.state);
        }

        public restore(memento: Memento): void {
            this.state = memento.getState();
            console.log(`Originator: State after restoring from Memento: ${this.state}`);
        }
      }

      class Caretaker {
        private mementos: Memento[] = [];
        private originator: Originator;

        constructor(originator: Originator) {
            this.originator = originator;
        }

        public backup(): void {
            console.log('Caretaker: Saving Originator\'s state...');
            this.mementos.push(this.originator.save());
        }

        public undo(): void {
            if (!this.mementos.length) {
                return;
            }
            const memento = this.mementos.pop();
            console.log('Caretaker: Restoring state to:', memento.getState());
            this.originator.restore(memento);
        }
      }

      // Usage
      const originator = new Originator();
      const caretaker = new Caretaker(originator);

      originator.setState('State #1');
      caretaker.backup();

      originator.setState('State #2');
      caretaker.backup();

      originator.setState('State #3');
      caretaker.undo();

      originator.setState('State #4');
      caretaker.undo();
            

In this example, the Memento pattern allows the Originator to be restored to its previous state using a saved memento. The Caretaker manages the history of mementos.

Conclusion

Behavioral design patterns are crucial for managing object interactions and responsibilities. By understanding and applying these patterns, senior developers can create more flexible and efficient software architectures, allowing for easier maintenance and scalability.

Session 10: Clean Architecture Principles (2 hours)

Introduction

Clean Architecture is a software design philosophy that emphasizes the separation of concerns, making it easier to develop, maintain, and scale complex applications. It organizes the code in a way that the core business logic is isolated from external concerns like UI, databases, and frameworks, ensuring that the system is flexible and adaptable to changes.

1. The Core Principles of Clean Architecture

Clean Architecture is centered around several key principles that help maintain a clean, scalable, and maintainable codebase:

2. The Layers of Clean Architecture

Clean Architecture is often represented as concentric circles with the core business logic at the center and various layers of abstraction radiating outwards:

The dependencies in this architecture always point inwards. The inner layers should not know anything about the outer layers.

3. Example of Clean Architecture in Action

Let's take a look at an example of a simple blog application following Clean Architecture principles:

3.1 Entities (Core Business Logic)

Entity: Post
      class Post {
        private id: string;
        private title: string;
        private content: string;
        private authorId: string;

        constructor(id: string, title: string, content: string, authorId: string) {
            this.id = id;
            this.title = title;
            this.content = content;
            this.authorId = authorId;
        }

        public editTitle(newTitle: string) {
            this.title = newTitle;
        }

        public editContent(newContent: string) {
            this.content = newContent;
        }

        // Other business logic methods can be added here
      }
            

This Post entity encapsulates the business logic related to a blog post, such as editing the title or content. It does not know anything about the database, UI, or any other external system.

3.2 Use Cases (Application Business Rules)

Use Case: CreatePost
      class CreatePostUseCase {
        private postRepository: PostRepository;

        constructor(postRepository: PostRepository) {
            this.postRepository = postRepository;
        }

        public execute(requestModel: CreatePostRequestModel): void {
            const post = new Post(
                requestModel.id,
                requestModel.title,
                requestModel.content,
                requestModel.authorId
            );

            this.postRepository.save(post);
        }
      }

      interface PostRepository {
        save(post: Post): void;
      }

      class CreatePostRequestModel {
        constructor(
            public id: string,
            public title: string,
            public content: string,
            public authorId: string
        ) {}
      }
            

The CreatePostUseCase class represents a use case for creating a new blog post. It orchestrates the creation of a Post entity and its persistence via the PostRepository interface, which abstracts away the actual storage mechanism.

3.3 Interface Adapters (Converting Data Formats)

Adapter: PostRepositoryImpl
      class PostRepositoryImpl implements PostRepository {
        private db: any; // This would be an instance of your database connection

        constructor(db: any) {
            this.db = db;
        }

        public save(post: Post): void {
            const postRecord = {
                id: post.getId(),
                title: post.getTitle(),
                content: post.getContent(),
                authorId: post.getAuthorId()
            };

            this.db.collection('posts').insertOne(postRecord);
        }
      }
            

The PostRepositoryImpl class implements the PostRepository interface, handling the conversion of a Post entity into a format suitable for storage in a database. This layer allows the application to change the database technology without affecting the core business logic.

3.4 Frameworks and Drivers (External Systems)

Example: Express.js Controller
      import express, { Request, Response } from 'express';

      const app = express();

      app.post('/posts', (req: Request, res: Response) => {
        const createPostUseCase = new CreatePostUseCase(new PostRepositoryImpl(dbConnection));

        const requestModel = new CreatePostRequestModel(
            req.body.id,
            req.body.title,
            req.body.content,
            req.body.authorId
        );

        createPostUseCase.execute(requestModel);

        res.status(201).send('Post created successfully');
      });

      // Start the server
      app.listen(3000, () => {
        console.log('Server is running on port 3000');
      });
            

This Express.js controller receives an HTTP request, converts the request data into a CreatePostRequestModel, and invokes the use case to handle the business logic. The controller is part of the outermost layer, which interacts with the web framework.

4. Benefits of Clean Architecture

Clean Architecture offers several advantages:

5. Practical Tips for Implementing Clean Architecture

Conclusion

Clean Architecture provides a robust framework for building scalable, maintainable, and testable software systems. By adhering to its principles, senior software developers can create applications that are resilient to change and easy to extend, ensuring long-term success for their projects.

Capsule 2: Practical Lab Exercise (2 hours)

Introduction

This lab exercise is designed to help you apply the concepts covered in Capsule 2, which includes Creational, Structural, and Behavioral Design Patterns. You will be required to design, refactor, and implement a small software application using these design patterns. The exercise will reinforce your understanding of how to use design patterns to solve common software design problems.

Exercise Overview

You are tasked with designing a simple library management system that handles book inventory, user registrations, and book borrowing. You need to apply the appropriate design patterns to ensure the system is flexible, maintainable, and scalable.

Step 1: Apply Creational Design Patterns

Implement the following tasks using Creational Design Patterns:

Step 2: Apply Structural Design Patterns

Implement the following tasks using Structural Design Patterns:

Step 3: Apply Behavioral Design Patterns

Implement the following tasks using Behavioral Design Patterns:

Step 4: Refactor and Extend

Refactor the codebase to ensure it follows SOLID principles and Clean Code practices:

Submission

Submit your refactored code along with a short document explaining the design patterns you used, why you chose them, and how they contribute to the maintainability and scalability of the system.

Facebook Facebook Twitter Twitter LinkedIn LinkedIn Email Email Reddit Reddit Pinterest Pinterest WhatsApp WhatsApp Telegram Telegram VK VK



Consent Preferences