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:
- Make the constructor private → So no one outside the class can create a new instance.
- Create a static instance of the class → This is the single instance that will be shared.
- 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!
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
}
}
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);
🤯 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}`);
}
}
✅ 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
}
}
✅ 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
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?
-
Simplifies object creation – No need to use
new
all over the place. - Encapsulates logic – If the way objects are created changes, you only update the factory, not the entire codebase.
- Improves maintainability – Makes the code cleaner and easier to manage.
- 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;
}
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 🚴");
}
}
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");
}
}
}
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 🚴
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} 🎬`);
}
}
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);
}
}
✅ Step 3: Use the Facade
const homeTheater = new HomeTheaterFacade();
homeTheater.startMovie("Inception");
Output:
Starting movie night... 🍿
Audio system is ON 🎵
Volume set to 10
Projector is ON 📽️
Projector input set to HDMI
Streaming movie: Inception 🎬
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 ⚡");
}
}
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;
}
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();
}
}
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();
Output:
Adapter converts round plug to flat plug...
Charging with a flat plug ⚡
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;
}
✅ 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 ₿`);
}
}
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);
}
}
✅ 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 ₿
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;
}
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;
}
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}`);
}
}
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}`);
}
}
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
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
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)