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:
Introduction to Design Patterns: An overview of what design patterns are and why they are important in software development.
Creational Design Patterns: Patterns that deal with object creation mechanisms, trying to create objects in a manner suitable to the situation.
Structural Design Patterns: Patterns that ease the design by identifying a simple way to realize relationships among entities.
Behavioral Design Patterns: Patterns that are concerned with algorithms and the assignment of responsibilities between objects.
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.
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:
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.
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.
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.
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.
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.
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.
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:
Independence from Frameworks: The architecture should not depend on the existence of some library of feature-laden software. This allows for the flexibility to replace frameworks without affecting the business logic.
Testable: The business rules can be tested without the user interface, database, web server, or any other external element.
Independence from UI: The UI can change easily, without changing the rest of the system.
Independence from Database: You can swap out Oracle or SQL Server, for MongoDB, or something else, without changing the business rules.
Independence from External Agencies: Business rules don't know anything about the external world, making it easy to adapt to external changes.
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:
Entities: The innermost circle represents the core business logic or domain entities. These are the fundamental building blocks of your application, independent of any external framework or technology.
Use Cases: The next layer encapsulates the application-specific business rules. This layer defines the interactions between the entities and the outside world, organizing the core business logic into use cases or application services.
Interface Adapters: This layer contains the interfaces and adapters that convert data from the outer layers (e.g., user interfaces, databases, or external APIs) into a format that can be used by the inner layers.
Frameworks and Drivers: The outermost circle consists of external frameworks and tools such as UI frameworks, databases, and web servers. These are the most replaceable parts of the architecture and should be isolated from the core business logic.
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:
Separation of Concerns: By isolating the business logic from external systems, each layer focuses on a single responsibility, making the codebase easier to understand and modify.
Testability: Since the core business logic is independent of frameworks and external systems, it can be tested in isolation, improving the reliability of the software.
Scalability: The modularity of Clean Architecture allows you to easily scale the application by swapping out or upgrading different layers without affecting the core business logic.
Maintainability: By organizing the code into layers, Clean Architecture makes it easier to locate and fix bugs, implement new features, and manage the code over time.
5. Practical Tips for Implementing Clean Architecture
Start Small: Begin by applying Clean Architecture principles to a small feature or service in your application. Gradually refactor other parts of the codebase as you gain confidence.
Interface-Driven Development: Define clear interfaces for each layer, allowing the implementation details to be swapped out without affecting other parts of the system.
Dependency Injection: Use dependency injection to pass dependencies into your classes, making it easier to replace them with mock implementations during testing.
Continuous Refactoring: Regularly refactor your code to maintain the separation of concerns and to keep the architecture clean and flexible.
Documentation: Document the architecture and design decisions, especially the boundaries between layers, to help future developers understand and maintain the system.
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:
Singleton Pattern:
Create a Library class that follows the Singleton pattern to ensure that there is only one instance of the library throughout the application. This class will manage the collection of books and registered users.
class Library {
private static instance: Library;
private books: Book[] = [];
private users: User[] = [];
private constructor() {}
public static getInstance(): Library {
if (!Library.instance) {
Library.instance = new Library();
}
return Library.instance;
}
public addBook(book: Book): void {
this.books.push(book);
}
public registerUser(user: User): void {
this.users.push(user);
}
// Additional methods for managing the library
}
Factory Method Pattern:
Create a BookFactory class that implements the Factory Method pattern to instantiate different types of books (e.g., Fiction, Non-Fiction, EBook).
interface Book {
title: string;
author: string;
isbn: string;
getType(): string;
}
class Fiction implements Book {
constructor(public title: string, public author: string, public isbn: string) {}
public getType(): string {
return 'Fiction';
}
}
class NonFiction implements Book {
constructor(public title: string, public author: string, public isbn: string) {}
public getType(): string {
return 'Non-Fiction';
}
}
class EBook implements Book {
constructor(public title: string, public author: string, public isbn: string) {}
public getType(): string {
return 'EBook';
}
}
abstract class BookFactory {
public abstract createBook(title: string, author: string, isbn: string): Book;
}
class FictionBookFactory extends BookFactory {
public createBook(title: string, author: string, isbn: string): Book {
return new Fiction(title, author, isbn);
}
}
class NonFictionBookFactory extends BookFactory {
public createBook(title: string, author: string, isbn: string): Book {
return new NonFiction(title, author, isbn);
}
}
class EBookFactory extends BookFactory {
public createBook(title: string, author: string, isbn: string): Book {
return new EBook(title, author, isbn);
}
}
Step 2: Apply Structural Design Patterns
Implement the following tasks using Structural Design Patterns:
Adapter Pattern:
Implement an EBookAdapter class that allows eBooks (which might have a different interface) to be managed alongside physical books in the library.
class PhysicalBook {
constructor(public title: string, public author: string, public isbn: string) {}
public getDetails(): string {
return `${this.title} by ${this.author} (ISBN: ${this.isbn})`;
}
}
class ExternalEBook {
constructor(public name: string, public writer: string, public code: string) {}
public fetchDetails(): string {
return `${this.name} by ${this.writer} (Code: ${this.code})`;
}
}
class EBookAdapter extends PhysicalBook {
private ebook: ExternalEBook;
constructor(ebook: ExternalEBook) {
super(ebook.name, ebook.writer, ebook.code);
this.ebook = ebook;
}
public getDetails(): string {
return this.ebook.fetchDetails();
}
}
// Usage
const externalEBook = new ExternalEBook('Digital Fortress', 'Dan Brown', 'DF123');
const ebookAdapter = new EBookAdapter(externalEBook);
console.log(ebookAdapter.getDetails());
Composite Pattern:
Create a composite structure for managing library departments (e.g., Fiction Department, Non-Fiction Department). Each department can hold either individual books or sub-departments, forming a tree structure.
interface LibraryComponent {
getName(): string;
getDetails(): string;
}
class BookLeaf implements LibraryComponent {
constructor(private book: Book) {}
public getName(): string {
return this.book.title;
}
public getDetails(): string {
return this.book.getType() + ' Book: ' + this.book.title + ' by ' + this.book.author;
}
}
class DepartmentComposite implements LibraryComponent {
private components: LibraryComponent[] = [];
constructor(private name: string) {}
public add(component: LibraryComponent): void {
this.components.push(component);
}
public remove(component: LibraryComponent): void {
const index = this.components.indexOf(component);
if (index > -1) {
this.components.splice(index, 1);
}
}
public getName(): string {
return this.name;
}
public getDetails(): string {
return `${this.name} Department:\n` + this.components.map(c => ` - ${c.getDetails()}`).join('\n');
}
}
// Usage
const fictionDepartment = new DepartmentComposite('Fiction');
fictionDepartment.add(new BookLeaf(new Fiction('1984', 'George Orwell', '9780451524935')));
fictionDepartment.add(new BookLeaf(new Fiction('To Kill a Mockingbird', 'Harper Lee', '9780061120084')));
const libraryComposite = new DepartmentComposite('Library');
libraryComposite.add(fictionDepartment);
console.log(libraryComposite.getDetails());
Facade Pattern:
Implement a LibraryFacade that provides a simplified interface for users to search for books, borrow books, and return books, hiding the complexity of the underlying operations.
class LibraryFacade {
private library: Library;
constructor() {
this.library = Library.getInstance();
}
public searchBook(title: string): Book | null {
// Assume we have a method in Library to search books
return this.library.findBookByTitle(title);
}
public borrowBook(userId: string, bookTitle: string): void {
const book = this.searchBook(bookTitle);
if (book) {
// Assume we have methods to borrow a book
this.library.borrowBook(userId, book);
} else {
console.log(`Book titled "${bookTitle}" not found.`);
}
}
public returnBook(userId: string, bookTitle: string): void {
const book = this.searchBook(bookTitle);
if (book) {
// Assume we have methods to return a book
this.library.returnBook(userId, book);
} else {
console.log(`Book titled "${bookTitle}" not found.`);
}
}
}
// Usage
const libraryFacade = new LibraryFacade();
libraryFacade.borrowBook('user123', '1984');
libraryFacade.returnBook('user123', '1984');
Step 3: Apply Behavioral Design Patterns
Implement the following tasks using Behavioral Design Patterns:
Observer Pattern:
Implement an Observer pattern that notifies users when a book they are interested in becomes available.
interface Observer {
update(bookTitle: string): void;
}
class User implements Observer {
constructor(public name: string) {}
public update(bookTitle: string): void {
console.log(`${this.name} has been notified: The book "${bookTitle}" is now available.`);
}
}
class BookSubject {
private observers: Observer[] = [];
private availableBooks: Set = new Set();
public addObserver(observer: Observer): void {
this.observers.push(observer);
}
public removeObserver(observer: Observer): void {
const index = this.observers.indexOf(observer);
if (index > -1) {
this.observers.splice(index, 1);
}
}
public setBookAvailable(title: string): void {
this.availableBooks.add(title);
this.notifyObservers(title);
}
private notifyObservers(title: string): void {
for (const observer of this.observers) {
observer.update(title);
}
}
}
// Usage
const bookSubject = new BookSubject();
const user1 = new User('Alice');
const user2 = new User('Bob');
bookSubject.addObserver(user1);
bookSubject.addObserver(user2);
bookSubject.setBookAvailable('1984');
Command Pattern:
Implement a Command pattern that handles user actions like borrowing and returning books as commands that can be queued, logged, or undone.
interface Command {
execute(): void;
undo(): void;
}
class BorrowBookCommand implements Command {
constructor(private library: Library, private user: User, private book: Book) {}
public execute(): void {
this.library.borrowBook(this.user, this.book);
console.log(`${this.user.name} borrowed "${this.book.title}"`);
}
public undo(): void {
this.library.returnBook(this.user, this.book);
console.log(`${this.user.name} returned "${this.book.title}"`);
}
}
class ReturnBookCommand implements Command {
constructor(private library: Library, private user: User, private book: Book) {}
public execute(): void {
this.library.returnBook(this.user, this.book);
console.log(`${this.user.name} returned "${this.book.title}"`);
}
public undo(): void {
this.library.borrowBook(this.user, this.book);
console.log(`${this.user.name} borrowed "${this.book.title}" again`);
}
}
class CommandInvoker {
private history: Command[] = [];
public executeCommand(command: Command): void {
command.execute();
this.history.push(command);
}
public undoLastCommand(): void {
const command = this.history.pop();
if (command) {
command.undo();
}
}
}
// Usage
const commandInvoker = new CommandInvoker();
const library = Library.getInstance();
const book = new Fiction('1984', 'George Orwell', '9780451524935');
const user = new User('Alice');
const borrowCommand = new BorrowBookCommand(library, user, book);
commandInvoker.executeCommand(borrowCommand);
const returnCommand = new ReturnBookCommand(library, user, book);
commandInvoker.executeCommand(returnCommand);
commandInvoker.undoLastCommand(); // Undo return, re-borrow the book
Step 4: Refactor and Extend
Refactor the codebase to ensure it follows SOLID principles and Clean Code practices:
Ensure that classes have a single responsibility and that code is easy to read and maintain.
Extend the functionality to support new features like adding multiple copies of a book and managing due dates for borrowed books.
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.