DEV Community

Dionis
Dionis

Posted on

7 Design Patterns EVERY Developer Should Know

Today, you'll explore seven different software design patterns—many of which you may already use without even realizing it. These patterns provide proven solutions to recurring programming challenges, regardless of the language or platform. In essence, a design pattern is a reusable template for solving common software engineering problems.

In 1994, four developers, now known as the Gang of Four, documented, categorized, and formalized 23 widely used design patterns in their influential book. This book remains highly relevant today, and I strongly recommend reading it. All these patterns are grouped into three main categories.

Creational patterns | Structural patterns | Behavioral patterns


Creational patterns are all about how objects are created. Instead of just instantiating objects directly, these patterns give you more flexibility and control over the process. Think of it like ordering a pizza—you don’t just throw ingredients together. You might pick a preset option from the menu (Factory pattern) or customize it step by step (Builder pattern).

Structural patterns focus on how objects are organized and connected. They help you build larger, more complex structures out of smaller, simpler pieces. Imagine building something out of LEGO. A massive Death Star set has thousands of pieces, but the instruction manual breaks it down into small, manageable steps, making it easier to put together.

Behavioral patterns deal with how objects communicate and work together. They define how responsibilities are distributed and how different parts of your code interact. One of the most useful is the Strategy pattern. Think of it like switching between navigation apps—Google Maps for driving, AllTrails for hiking, and Transit for public transportation. They all help you get from point A to point B, but they use different strategies to do it.


🔂 The Singleton Pattern

At its core, the Singleton Pattern is all about ensuring that there is only ONE instance of a class and providing a global point of access to it. That’s it. Instead of allowing multiple objects of a class to be created, the Singleton pattern makes sure that only one exists throughout the program’s lifecycle.

Why Would You Need This?

Imagine you’re building an application that has a logging system. You don’t want multiple instances of the logger floating around because that could lead to weird issues like duplicate log entries or inconsistent logging states. Instead, you want just one logger instance that the entire application shares.

Or think about a database connection manager—you wouldn’t want your app to create a new database connection every time it needs to query something. That would be a nightmare for performance. A Singleton ensures that only one connection manager exists, which everything else in your app can use.

How Does It Work?

To enforce the Singleton behavior, we usually do three things in our class:

  1. Make the constructor private → So no one outside the class can create a new instance.
  2. Create a static instance of the class → This is the single instance that will be shared.
  3. Provide a public static method to get the instance → This method checks if the instance exists; if not, it creates one.

The Singleton Pattern in Code (TypeScript Example)

class Singleton {
  private static instance: Singleton;

  // Step 2: Private constructor so it can't be instantiated outside
  private constructor() {}

  // Step 3: Public method to return the single instance
  public static getInstance(): Singleton {
    if (!Singleton.instance) { // If no instance exists, create one
      Singleton.instance = new Singleton();
    }
    return Singleton.instance;
  }

  public sayHello() {
    console.log("Hello from Singleton!");
  }
}

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

console.log(singleton1 === singleton2); // true (same instance)
singleton1.sayHello(); // Outputs: Hello from Singleton!
Enter fullscreen mode Exit fullscreen mode

With this setup, every time you call Singleton.getInstance(), you’ll always get the same instance of the class.

But Wait… What About Multithreading?

JavaScript (and TypeScript) are single-threaded, so we don’t have to worry about race conditions like we do in Java. However, if your Singleton is handling asynchronous operations (like database connections), you might still need to ensure thread safety using something like locks or promises.

The Eager Initialization Approach (Safer but Less Efficient)

Another way to make the Singleton safer is to create the instance at the very beginning, instead of waiting for getInstance() to be called.

class Singleton {
  private static instance: Singleton = new Singleton(); // Eager initialization

  private constructor() {}

  public static getInstance(): Singleton {
    return Singleton.instance; // Just return the already-created instance
  }
}
Enter fullscreen mode Exit fullscreen mode

This method is thread-safe and doesn’t require any condition checks, but the downside is that the instance is created even if it’s never used.

Real-world Examples of the Singleton Pattern

So where is this actually used?

Database connection managers – Only one connection pool should exist.

Logging frameworks – A single logger instance handles all logs.

Configuration managers – You don’t want multiple versions of your app’s settings.

Thread pools – A single pool manages threads efficiently.

When NOT to Use Singleton

While Singleton is useful, it’s not always the best choice. Some developers even avoid it because:

❌ It introduces global state, which can make debugging harder.

❌ It can lead to tight coupling, making your code less flexible.

❌ It’s hard to unit test since you can’t easily mock it.


🏗️ The Builder Pattern

The Builder Pattern is a creational design pattern that helps you construct complex objects step by step. Instead of having one giant constructor with tons of parameters, you use a builder class to gradually build an object by calling methods that set properties one by one.

Think of it like ordering a custom burger at a restaurant:

🍔 Instead of choosing from a few predefined options, you build your burger step by step:

  • Add cheese 🧀
  • Add lettuce 🥬
  • Add bacon 🥓
  • Choose your sauce 🥫
  • Done! ✅

The Builder Pattern works the same way—you construct an object by chaining methods until you're happy with the final result.


Why Would You Need This?

Let’s say you're creating a Car object. Without the Builder Pattern, you might end up with a huge constructor like this:

class Car {
  constructor(
    public brand: string,
    public model: string,
    public color: string,
    public engine: string,
    public wheels: number
  ) {}
}

const myCar = new Car("Toyota", "Auris", "Red", "Hybrid", 4);
Enter fullscreen mode Exit fullscreen mode

🤯 That’s a lot of parameters in one place, and if you ever need to add more, it quickly becomes hard to manage.

Instead, the Builder Pattern lets you gradually create the object in a readable and flexible way.


The Builder Pattern in Code (TypeScript Example)

Step 1: Create the Car class (The Product)

This is the object we want to build step by step.

class Car {
  brand?: string;
  model?: string;
  color?: string;
  engine?: string;
  wheels?: number;

  showDetails(): void {
    console.log(`Car Details: ${this.brand} ${this.model}, Color: ${this.color}, Engine: ${this.engine}, Wheels: ${this.wheels}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create the CarBuilder class (The Builder)

This class will provide chained methods to set values one by one.

class CarBuilder {
  private car: Car;

  constructor() {
    this.car = new Car(); // Start with an empty Car object
  }

  setBrand(brand: string): CarBuilder {
    this.car.brand = brand;
    return this; // Return `this` to allow method chaining
  }

  setModel(model: string): CarBuilder {
    this.car.model = model;
    return this;
  }

  setColor(color: string): CarBuilder {
    this.car.color = color;
    return this;
  }

  setEngine(engine: string): CarBuilder {
    this.car.engine = engine;
    return this;
  }

  setWheels(wheels: number): CarBuilder {
    this.car.wheels = wheels;
    return this;
  }

  build(): Car {
    return this.car; // Return the fully built Car object
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Use the Builder to Create Objects Easily

const myCar = new CarBuilder()
  .setBrand("Toyota")
  .setModel("Auris")
  .setColor("Red")
  .setEngine("Hybrid")
  .setWheels(4)
  .build();

myCar.showDetails(); // Outputs: Car Details: Toyota Auris, Color: Red, Engine: Hybrid, Wheels: 4
Enter fullscreen mode Exit fullscreen mode

Now, instead of using a huge constructor, we can gradually set values in a clear and structured way.


Advantages of the Builder Pattern

Improves readability – No more long constructors with 10+ parameters.

More flexibility – You can skip optional properties without overloading constructors.

Encapsulates object creation – Keeps your code cleaner and easier to maintain.

Method chaining – Lets you build an object step by step in a natural way.


Real-World Examples of the Builder Pattern

Where do we actually see this pattern in use?

Configuring HTTP Requests (fetch API with chaining methods)

Building complex UI components (Fluent UI in React, Builder pattern in Angular)

Game development (Character or level builders)

Creating SQL queries (QueryBuilder in TypeORM)


When NOT to Use the Builder Pattern

❌ If your object is simple, using a builder adds unnecessary complexity.

❌ If the object's properties are fixed and don't change often, a normal constructor is fine.

❌ If performance is a concern, builders create extra objects before the final one is returned.


🏭 The Factory Pattern

Imagine you run a pizza shop. Instead of letting customers randomly mix ingredients and create messy orders, you provide a menu where they can choose specific types of pizza.

In programming, the Factory Pattern works the same way:

  • Instead of letting users manually create objects, we provide a factory method that returns the correct object based on some input.
  • This improves flexibility and decouples object creation from the main code.

Why Use the Factory Pattern?

  1. Simplifies object creation – No need to use new all over the place.
  2. Encapsulates logic – If the way objects are created changes, you only update the factory, not the entire codebase.
  3. Improves maintainability – Makes the code cleaner and easier to manage.
  4. Supports polymorphism – A single factory can create different types of related objects.

Factory Pattern in Code (TypeScript Example)

Step 1: Create an Interface for Products

interface Vehicle {
  drive(): void;
}
Enter fullscreen mode Exit fullscreen mode

This ensures that all vehicles (cars, bikes, etc.) have a drive() method.

Step 2: Create Concrete Classes (Products)

class Car implements Vehicle {
  drive(): void {
    console.log("Driving a car 🚗");
  }
}

class Bike implements Vehicle {
  drive(): void {
    console.log("Riding a bike 🚴");
  }
}
Enter fullscreen mode Exit fullscreen mode

Each vehicle implements the Vehicle interface.

Step 3: Create the Factory Class

class VehicleFactory {
  static createVehicle(type: string): Vehicle {
    if (type === "car") {
      return new Car();
    } else if (type === "bike") {
      return new Bike();
    } else {
      throw new Error("Unknown vehicle type");
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Instead of using new Car() or new Bike(), we call VehicleFactory.createVehicle(type), and it decides which object to return.

Step 4: Use the Factory to Create Objects

const myCar = VehicleFactory.createVehicle("car");
myCar.drive(); // Outputs: Driving a car 🚗

const myBike = VehicleFactory.createVehicle("bike");
myBike.drive(); // Outputs: Riding a bike 🚴
Enter fullscreen mode Exit fullscreen mode

Advantages of the Factory Pattern

Encapsulates object creation – No need to worry about how objects are created.

Easier maintenance – If a new vehicle type is added, we only modify the factory.

Supports polymorphism – The calling code doesn’t need to know the exact class.

Removes code duplication – Avoids repeated new object creation across the app.

Real-World Examples of the Factory Pattern

Database connections – A factory selects the correct database driver (MySQL, PostgreSQL, etc.).

UI component creation – A factory generates buttons, dropdowns, or checkboxes dynamically.

Logging systems – A factory determines whether to log to a file, database, or console.

Payment processing – A factory creates instances of different payment gateways (PayPal, Stripe, etc.).

When NOT to Use the Factory Pattern

❌ If object creation is simple, the factory adds unnecessary complexity.

❌ If there are no plans for multiple object types, a factory is overkill.

❌ If performance is critical, a factory may slow things down due to extra function calls.


🎭 The Facade Pattern

Imagine you’re in a hotel. Instead of calling the chef, housekeeping, and maintenance separately, you just call the reception desk, and they handle everything for you.

The Facade Pattern works the same way:

  • It hides complexity by exposing a simple API.
  • It delegates tasks to underlying components without exposing their details.

Why Use the Facade Pattern?

Simplifies usage – Clients don’t need to understand the entire system.

Reduces dependencies – Clients interact with only one interface.

Improves maintainability – Changes in subsystems don’t affect the client.

Encapsulates complexity – Internal details remain hidden.

Facade Pattern in Code (TypeScript Example)

Step 1: Create Complex Subsystems

These are the classes that perform different tasks, but they are not directly accessed by the client.

class AudioSystem {
  turnOn(): void {
    console.log("Audio system is ON 🎵");
  }

  setVolume(level: number): void {
    console.log(`Volume set to ${level}`);
  }
}

class Projector {
  turnOn(): void {
    console.log("Projector is ON 📽️");
  }

  setInput(source: string): void {
    console.log(`Projector input set to ${source}`);
  }
}

class StreamingService {
  playMovie(movie: string): void {
    console.log(`Streaming movie: ${movie} 🎬`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Each of these subsystems has its own methods, which makes it complicated to use them all together.

Step 2: Create the Facade Class

The Facade simplifies interaction by exposing a single method that hides the complexity.

class HomeTheaterFacade {
  private audio: AudioSystem;
  private projector: Projector;
  private streaming: StreamingService;

  constructor() {
    this.audio = new AudioSystem();
    this.projector = new Projector();
    this.streaming = new StreamingService();
  }

  startMovie(movie: string): void {
    console.log("Starting movie night... 🍿");
    this.audio.turnOn();
    this.audio.setVolume(10);
    this.projector.turnOn();
    this.projector.setInput("HDMI");
    this.streaming.playMovie(movie);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Use the Facade

const homeTheater = new HomeTheaterFacade();
homeTheater.startMovie("Inception");
Enter fullscreen mode Exit fullscreen mode

Output:

Starting movie night... 🍿
Audio system is ON 🎵
Volume set to 10
Projector is ON 📽️
Projector input set to HDMI
Streaming movie: Inception 🎬
Enter fullscreen mode Exit fullscreen mode

Instead of manually controlling each system, we only call one method (startMovie()), and the facade takes care of the rest.

Advantages of the Facade Pattern

Simplifies the interface – Clients don’t need to deal with multiple subsystems.

Encapsulates complexity – Changes inside subsystems don’t affect clients.

Reduces dependencies – Clients only depend on the Facade, not individual classes.

Improves maintainability – Easier to update a single Facade than multiple classes.

Real-World Examples of the Facade Pattern

Operating Systems – Instead of calling low-level system APIs, we use a simple UI.

Database Access – ORM libraries like TypeORM simplify raw SQL queries.

Media Players – Clicking "Play" hides the complexity of codecs and rendering.

Third-Party API Wrappers – Stripe SDK hides complex payment APIs.

When NOT to Use the Facade Pattern

❌ If the system is already simple, adding a Facade creates unnecessary overhead.

❌ If direct access to subsystems is required for flexibility.

❌ If the Facade starts growing too large, it can become a God Object (doing too much).


🔌 The Adapter Pattern - Structural patterns

Imagine you buy a new laptop charger in the U.S., but your power outlet is European. Instead of throwing away the charger, you buy a power adapter that lets it fit into the European socket.

The Adapter Pattern works the same way:

  • It converts one interface into another that a client expects.
  • It allows two incompatible classes to work together.

Why Use the Adapter Pattern?

Allows code reuse – You don’t have to modify existing classes.

Improves flexibility – Works with third-party or legacy systems.

Encapsulates changes – Any required adjustments stay within the adapter.

Promotes maintainability – No need to rewrite entire systems for compatibility.

Adapter Pattern in Code (TypeScript Example)

Step 1: Create an Incompatible Class

This is an existing class that does not match the interface we need.

class OldCharger {
  chargeWithFlatPlug(): void {
    console.log("Charging with a flat plug ⚡");
  }
}
Enter fullscreen mode Exit fullscreen mode

The problem? It only supports flat plugs, but we need a round plug interface.

Step 2: Define the Expected Interface

This is what the client expects to use.

interface RoundPlug {
  chargeWithRoundPlug(): void;
}
Enter fullscreen mode Exit fullscreen mode

The OldCharger doesn’t support this interface, so we need an adapter.

Step 3: Create the Adapter

The adapter class converts the old system (flat plug) into the expected interface (round plug).

class ChargerAdapter implements RoundPlug {
  private oldCharger: OldCharger;

  constructor(oldCharger: OldCharger) {
    this.oldCharger = oldCharger;
  }

  chargeWithRoundPlug(): void {
    console.log("Adapter converts round plug to flat plug...");
    this.oldCharger.chargeWithFlatPlug();
  }
}
Enter fullscreen mode Exit fullscreen mode

Now, ChargerAdapter acts as a middleman between the incompatible plug types.

Step 4: Use the Adapter

const oldCharger = new OldCharger();
const adapter = new ChargerAdapter(oldCharger);

adapter.chargeWithRoundPlug();
Enter fullscreen mode Exit fullscreen mode

Output:

Adapter converts round plug to flat plug...
Charging with a flat plug ⚡
Enter fullscreen mode Exit fullscreen mode

Even though the original charger only supports flat plugs, the adapter makes it work with round plugs!

Advantages of the Adapter Pattern

Enables compatibility – Allows different systems to communicate.

Works with third-party code – Adapts external libraries without modifying them.

Preserves existing code – No need to rewrite or extend original classes.

Encapsulates complexity – Keeps changes isolated in the adapter.

Real-World Examples of the Adapter Pattern

Power Adapters – Convert plugs from one type to another.

Legacy System Integration – Connects old APIs to modern interfaces.

Media Converters – Adapts file formats (e.g., MP3 to WAV).

Database Drivers – Translates queries between different databases.

When NOT to Use the Adapter Pattern

❌ If you can modify the original class, it's better than creating an adapter.

❌ If systems are already compatible, the adapter adds unnecessary complexity.

❌ If using the Facade Pattern makes more sense (when simplifying complex APIs).


🎯 The Strategy Pattern

Imagine you're using a navigation app like Google Maps. Depending on your needs, you can choose:

  • Driving mode 🚗 – Optimized for car routes.
  • Cycling mode 🚴 – Finds bike-friendly paths.
  • Walking mode 🚶 – Shows pedestrian shortcuts.

The destination remains the same, but the strategy (the route calculation method) changes. The Strategy Pattern works the same way:

  • It allows a program to swap between different algorithms dynamically.
  • The core logic (finding the route) remains unchanged, but the method used varies.

Why Use the Strategy Pattern?

Encapsulates different behaviors – No need for long if-else statements.

Supports dynamic changes – The strategy can be switched at runtime.

Encourages code reusability – Different algorithms are kept in separate classes.

Improves maintainability – Adding new strategies doesn’t require modifying existing code.


Strategy Pattern in Code (TypeScript Example)

Step 1: Define a Common Interface

Each strategy (algorithm) will implement this interface.

interface PaymentStrategy {
  pay(amount: number): void;
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Create Different Strategy Implementations

class CreditCardPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`Paid $${amount} using Credit Card 💳`);
  }
}

class PayPalPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`Paid $${amount} using PayPal 🏦`);
  }
}

class CryptoPayment implements PaymentStrategy {
  pay(amount: number): void {
    console.log(`Paid $${amount} using Cryptocurrency ₿`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Each class implements a different payment method.

Step 3: Create a Context Class to Use Strategies

class ShoppingCart {
  private paymentMethod: PaymentStrategy;

  constructor(paymentMethod: PaymentStrategy) {
    this.paymentMethod = paymentMethod;
  }

  setPaymentMethod(paymentMethod: PaymentStrategy): void {
    this.paymentMethod = paymentMethod;
  }

  checkout(amount: number): void {
    this.paymentMethod.pay(amount);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Use the Strategy Pattern

const cart = new ShoppingCart(new CreditCardPayment());
cart.checkout(100); // Outputs: Paid  using Credit Card 💳

cart.setPaymentMethod(new PayPalPayment());
cart.checkout(50); // Outputs: Paid  using PayPal 🏦

cart.setPaymentMethod(new CryptoPayment());
cart.checkout(200); // Outputs: Paid  using Cryptocurrency ₿
Enter fullscreen mode Exit fullscreen mode

Advantages of the Strategy Pattern

Flexible and Extensible – New strategies can be added without modifying existing code.

Eliminates Large if-else Statements – Keeps code clean and readable.

Promotes Open/Closed Principle – The system is open for extension but closed for modification.

Improves Testability – Each strategy can be tested independently.

Real-World Examples of the Strategy Pattern

Sorting Algorithms – Switching between QuickSort, MergeSort, BubbleSort, etc.

Payment Methods – Credit Card, PayPal, Cryptocurrency, etc.

Compression Algorithms – ZIP, RAR, GZIP, etc.

Authentication Mechanisms – OAuth, JWT, Basic Auth, etc.

When NOT to Use the Strategy Pattern

❌ If there’s only one algorithm, using the Strategy Pattern is unnecessary.

❌ If switching between strategies is never needed in the application.

❌ If the extra complexity of multiple classes doesn’t justify the benefits.


👀 The Observer Pattern

Think of a YouTube channel:

  • When you subscribe to a channel, you get notified of new videos.
  • If you unsubscribe, you stop receiving updates.
  • The channel doesn’t know or care who its subscribers are—it just notifies everyone when something changes.

The Observer Pattern works the same way:

  • A subject maintains a list of observers.
  • Observers can subscribe or unsubscribe.
  • When the subject’s state changes, it notifies all observers automatically.

Why Use the Observer Pattern?

Loosely coupled – The subject and observers are independent.

Automatic updates – Observers don’t need to constantly check for changes.

Extensible – New observers can be added without modifying the subject.

Supports event-driven systems – Useful for UI updates, notifications, etc.


Observer Pattern in Code (TypeScript Example)

Step 1: Create the Observer Interface

interface Observer {
  update(message: string): void;
}
Enter fullscreen mode Exit fullscreen mode

All observers must implement the update() method to receive notifications.

Step 2: Create the Subject (Observable) Interface

interface Subject {
  subscribe(observer: Observer): void;
  unsubscribe(observer: Observer): void;
  notify(message: string): void;
}
Enter fullscreen mode Exit fullscreen mode

The subject maintains a list of observers and notifies them of updates.

Step 3: Implement the Concrete Subject

class YouTubeChannel implements Subject {
  private observers: Observer[] = [];

  subscribe(observer: Observer): void {
    this.observers.push(observer);
    console.log("Subscriber added! 🎉");
  }

  unsubscribe(observer: Observer): void {
    this.observers = this.observers.filter(sub => sub !== observer);
    console.log("Subscriber removed! ❌");
  }

  notify(message: string): void {
    console.log(`Notifying subscribers: ${message}`);
    this.observers.forEach(observer => observer.update(message));
  }

  uploadVideo(title: string): void {
    console.log(`New video uploaded: ${title} 📹`);
    this.notify(`New video: ${title}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

The YouTubeChannel maintains a list of subscribers and notifies them when a new video is uploaded.

Step 4: Implement Concrete Observers

class User implements Observer {
  constructor(private name: string) {}

  update(message: string): void {
    console.log(`${this.name} received notification: ${message}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Each User represents a subscriber who receives notifications.

Step 5: Use the Observer Pattern

const channel = new YouTubeChannel();

const user1 = new User("Alice");
const user2 = new User("Bob");

channel.subscribe(user1);
channel.subscribe(user2);

channel.uploadVideo("Observer Pattern Tutorial"); // Notifies all subscribers

channel.unsubscribe(user1);

channel.uploadVideo("Strategy Pattern Tutorial"); // Only Bob gets notified
Enter fullscreen mode Exit fullscreen mode

Output:

Subscriber added! 🎉
Subscriber added! 🎉
New video uploaded: Observer Pattern Tutorial 📹
Notifying subscribers: New video: Observer Pattern Tutorial
Alice received notification: New video: Observer Pattern Tutorial
Bob received notification: New video: Observer Pattern Tutorial
Subscriber removed! ❌
New video uploaded: Strategy Pattern Tutorial 📹
Notifying subscribers: New video: Strategy Pattern Tutorial
Bob received notification: New video: Strategy Pattern Tutorial
Enter fullscreen mode Exit fullscreen mode

Advantages of the Observer Pattern

Decouples subjects from observers – Subjects don’t need to know their observers.

Supports dynamic relationships – Observers can subscribe or unsubscribe at runtime.

Ideal for event-driven systems – Used in UI frameworks, messaging, and notifications.

Real-World Examples

Event Listeners in JavaScript

Stock Market Trackers

Weather Monitoring Systems

Notification Systems

Top comments (0)