From Framework Consumer to Framework Creator: Mastering Design Patterns
Tags: softwareengineering, designpatterns, cleancode, typescript
Published: 2024-07-04 13:29:52

When we discuss career progression in the tech industry, the typical path involves moving from junior developer to senior, then to lead, architect, and finally principal. However, this linear progression often overlooks a vital aspect of technical proficiency - the journey from a framework consumer to a framework creator. This transition involves moving from merely using frameworks like Spring or React to developing frameworks that other engineers rely on. Achieving this requires mastering various design patterns essential for building robust, reusable, and scalable software architectures.

## Developing Your Technical Skills
To become a framework creator, you need a solid technical foundation. This starts with mastering the basics of your chosen programming language, such as conditionals, loops, functions, and classes. Next, you need to understand data structures and algorithms. Finally, you must master design patterns, which provide reusable solutions to common software design problems and help create maintainable and scalable systems.

Here are five essential design patterns that can aid your transition from a framework consumer to a framework creator.

## 1. Singleton Pattern
The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. This is particularly useful for objects like database drivers or configuration settings, where multiple instances could lead to inconsistencies.

**Pros:**
- Simplifies instance access.
- Ensures uniformity across the application.

**Cons:**
- Overuse can complicate refactoring.
- Challenging to test due to global state.

**Example: Database Driver:**

``` ts
class DatabaseDriver {
  private static instance: DatabaseDriver;
  private connectionString: string;

  private constructor(connectionString: string) {
    this.connectionString = connectionString;
    console.log("Connecting to database..."); // Simulate connection logic
  }

  public static getInstance(connectionString?: string): DatabaseDriver {
    if (!DatabaseDriver.instance) {
      if (!connectionString) {
        throw new Error("Connection string is required for the first instance.");
      }
      DatabaseDriver.instance = new DatabaseDriver(connectionString);
    } else if (connectionString && connectionString !== DatabaseDriver.instance.connectionString) {
      console.warn("Singleton already initialized with a different connection string. Ignoring new connection string.");
    }
    return DatabaseDriver.instance;
  }

  // Database operations (connect, query, etc.)
}

// Usage
const db1 = DatabaseDriver.getInstance("connection_string_1");
const db2 = DatabaseDriver.getInstance(); // Throws error if not called first with connection string
const db3 = DatabaseDriver.getInstance("connection_string_2"); // Warns about different connection string being ignored

// Ensures only one instance exists throughout the application
// and provides a centralized access point
```

## 2. Facade Pattern
The Facade pattern provides a simplified interface to a complex subsystem, similar to the front of a building hiding its intricate details. For instance, a compiler has many internal components (parser, lexical analyzer, tokenizer), but a facade can offer a simple interface for compiling code.

**Pros:**
- Simplifies usage for consumers.
- Reduces subsystem dependencies.

**Cons:**
- Risk of oversimplification leading to leaky abstractions.
- Can become overly specific, reducing general usability.

**Example: Compiler Interface:**

``` ts
class Compiler {
    parse(code: string) {  }
    analyze(code: string) {  }
    generate(code: string) {  }
}

class CompilerFacade {
    private compiler: Compiler;

    constructor() {
        this.compiler = new Compiler();
    }

    compileCode(code: string) {
        this.compiler.parse(code);
        this.compiler.analyze(code);
        this.compiler.generate(code);
    }
}
```
## 3. Bridge Pattern
The Bridge pattern decouples an abstraction from its implementation, allowing them to vary independently. Think of a camera body with interchangeable lenses, where the body represents the abstraction and the lenses the implementations.

**Pros:**
- Promotes flexibility and extensibility.
- Separates interface and implementation.

**Cons:**
- Can lead to complexity if overused.
- Requires careful planning to implement effectively.

**Example: Camera System:**

``` ts
//Abstraction
abstract class CameraBody {
    protected lens: Lens;

    constructor(lens: Lens) {
        this.lens = lens;
    }

    abstract takePhoto(): void;
}
//Interface
interface Lens {
    capture(): string;
}

//Concrete Implementations
class PrimeLens implements Lens {
    capture(): string {
        return 'Photo taken with a prime lens.';
    }
}


class ZoomLens implements Lens {
    capture(): string {
        return 'Photo taken with a zoom lens.';
    }
}

//Extend the Abstraction
class DSLRCamera extends CameraBody {
    takePhoto(): void {
        console.log('DSLR Camera: ' + this.lens.capture());
    }
}

class MirrorlessCamera extends CameraBody {
    takePhoto(): void {
        console.log('Mirrorless Camera: ' + this.lens.capture());
    }
}

//usage
const primeLens: Lens = new PrimeLens();
const zoomLens: Lens = new ZoomLens();

const dslrWithPrime: CameraBody = new DSLRCamera(primeLens);
dslrWithPrime.takePhoto(); // Output: DSLR Camera: Photo taken with a prime lens.

const dslrWithZoom: CameraBody = new DSLRCamera(zoomLens);
dslrWithZoom.takePhoto(); // Output: DSLR Camera: Photo taken with a zoom lens.

const mirrorlessWithPrime: CameraBody = new MirrorlessCamera(primeLens);
mirrorlessWithPrime.takePhoto(); // Output: Mirrorless Camera: Photo taken with a prime lens.

const mirrorlessWithZoom: CameraBody = new MirrorlessCamera(zoomLens);
mirrorlessWithZoom.takePhoto(); // Output: Mirrorless Camera: Photo taken with a zoom lens.

```

## 4. Strategy Pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. For instance, a notification system can use different strategies for filtering customers and sending notifications.

**Pros:**
- Promotes cleaner code with well-defined responsibilities.
- Allows easy extension and modification of behaviors.

**Cons:**
- Can increase the number of classes.
- Requires careful management of strategy objects.

**Example: Notification System:**

```ts
interface NotificationStrategy {
    sendNotification(customer: Customer, message: string): void;
}

class EmailNotification implements NotificationStrategy {
    sendNotification(customer: Customer, message: string): void {
        console.log(`Sending email to ${customer.email}: ${message}`);
    }
}

class SMSNotification implements NotificationStrategy {
    sendNotification(customer: Customer, message: string): void {
        console.log(`Sending SMS to ${customer.phoneNumber}: ${message}`);
    }
}

class PushNotification implements NotificationStrategy {
    sendNotification(customer: Customer, message: string): void {
        console.log(`Sending push notification to ${customer.name}: ${message}`);
    }
}

class Customer {
    constructor(public name: string, public email: string, public phoneNumber: string, public preference: string) {}
}

class NotificationContext {
    private strategy: NotificationStrategy;

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

    setStrategy(strategy: NotificationStrategy) {
        this.strategy = strategy;
    }

    sendNotification(customer: Customer, message: string) {
        this.strategy.sendNotification(customer, message);
    }
}

// Usage
const customers: Customer[] = [
    new Customer("John Doe", "john@example.com", "1234567890", "email"),
    new Customer("Jane Smith", "jane@example.com", "0987654321", "sms"),
    new Customer("Bob Johnson", "bob@example.com", "1112223333", "push")
];

const emailNotification = new EmailNotification();
const smsNotification = new SMSNotification();
const pushNotification = new PushNotification();

const notificationContext = new NotificationContext(emailNotification);

customers.forEach(customer => {
    switch (customer.preference) {
        case "email":
            notificationContext.setStrategy(emailNotification);
            break;
        case "sms":
            notificationContext.setStrategy(smsNotification);
            break;
        case "push":
            notificationContext.setStrategy(pushNotification);
            break;
        default:
            console.log(`Unknown preference for ${customer.name}`);
            return;
    }
    notificationContext.sendNotification(customer, "Hello! This is a test notification.");
});

```

![Logs Screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/5c0vy9mevrtzq6wgfg06.png)

## 5. Observer Pattern
The Observer pattern, also known as Publish-Subscribe (Pub-Sub), allows an object to notify other objects about state changes. This pattern is prevalent in event-driven systems.

**Pros:**

- Enables loose coupling between objects.
- Supports dynamic relationships between objects.

**Cons:**

- Can lead to complex event chains.
- Hard to debug if overused.

**Example: Event System:**

``` ts
// Observer Interface
interface Observer {
  update(eventData: string): void;
}

// Concrete Observer
class EventListener implements Observer {
  update(eventData: string): void {
    console.log("Event received: " + eventData);
  }
}

// Subject
class EventPublisher {
  private observers: Observer[] = [];

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

  removeObserver(observer: Observer): void {
    this.observers = this.observers.filter((obs) => obs !== observer);
  }

  notifyObservers(eventData: string): void {
    for (const observer of this.observers) {
      observer.update(eventData);
    }
  }
}

// Usage
const eventPublisher = new EventPublisher();

const listener1 = new EventListener();
const listener2 = new EventListener();

eventPublisher.addObserver(listener1);
eventPublisher.addObserver(listener2);

eventPublisher.notifyObservers("Event 1 triggered!"); // Output: Event received: Event 1 triggered! (twice)

eventPublisher.removeObserver(listener2);

eventPublisher.notifyObservers("Event 2 triggered!"); // Output: Event received: Event 2 triggered! (once)
```

## Conclusion
Understanding and applying design patterns is essential for any developer aiming to enhance their software architecture skills. These patterns provide proven solutions to common design challenges, making your code more modular, maintainable, and scalable. By incorporating these patterns into your development practice, you can create more efficient and flexible systems that are easier to manage and extend. Whether you're working on client-side applications or server-side infrastructure, mastering these design patterns will significantly boost your proficiency and set you apart in the tech industry.



If you have any questions or comments, feel free to share them below. 

Happy coding!