I had some free time recently and decided to explore design patterns that are commonly used in production. Here’s my simple attempt to make these concepts easy to understand.
What is a Design Pattern ?
A design pattern is like a simple, reusable recipe that helps us solve common problems when building software, so we don’t have to figure it out from scratch every time.
We use design patterns because they are proven, reusable solutions that help us solve common software problems more quickly and efficiently, making the code easier to understand, maintain, scale, and work on collaboratively with others. They save time by avoiding reinventing the wheel and ensure consistent, reliable, and flexible software designs.
1. Factory Pattern
A way to make different kinds of things (objects) without telling exactly how to make them every time. It decides the type for you based on what you need.
Example: Creating different types of notifications (Email, SMS) based on user preference.
interface Notification {
send(message: string): void;
}
// Concrete implementations
class EmailNotification implements Notification {
send(message: string){
// Send email using nodemailer or resend...
console.log("Email Sent")
}
}
class SMSNotification implements Notification {
send(message: string){
// Send SMS using Twilio or MessageBird or Plivo
console.log("SMS Sent")
}
}
// Factory class to create notification objects
class NotificationFactory {
static create(type: "email" | "sms"): Notification {
switch (type) {
case "email":
return new EmailNotification();
break;
case "sms":
return new SMSNotification();
break;
default:
throw new Error("Unknown notification type")
}
}
}
// Usage
const notification = NotificationFactory.create("email");
notification.send("Welcome to your favourite blog.")
When to use Factory Pattern
- When the process to create an object is complex or depends on runtime conditions (e.g., user preferences, environment).
- When you expect new types or variants of objects to be added in the future but want to avoid changing existing client code.
- If object construction involves shared steps or repeated code, factories help centralize and reuse that logic.
2. Singleton Pattern
A pattern to make sure there is only one copy of something in a whole app, so everyone uses the same one.
Example: Database connection pool. Used a lot in Nextjs + Prisma
class Database {
private static instance: Database;
// Private constructor prevents external instantiation
private constructor() { /* initialize connection */ }
static getInstance() {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
}
// Usage
const db1 = Database.getInstance();
const db2 = Database.getInstance();
console.log(db1 === db2); // true
Prisma Example
import { PrismaClient } from '@prisma/client';
declare global {
// Adding to the NodeJS global type
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') global.prisma = prisma;
export default prisma;
When to use Singleton Pattern
- When your application requires only one shared instance of a class throughout its lifecycle, such as a database connection or configuration manager.
- When that single instance must be easily and globally accessible by different parts of the system.
- When creating the object is costly in terms of time or resources, so you want to reuse the same instance to optimize performance.
- You want to prevent accidental creation of multiple instances to maintain consistent system behavior.
3. Prototype Pattern
A way to make a new thing by copying an existing one, so you don’t have to build from zero every time.
Example: Creating multiple configurations from a base template.
interface UserProfile {
name: string;
preferences: any;
clone(): UserProfile;
}
class User implements UserProfile {
constructor(public name: string, public preferences: any) {}
clone(): User {
// Shallow copy of preferences; deep copy if necessary
return new User(this.name, { ...this.preferences });
}
}
// Usage
const defaultProfile = new User("Default", { theme: "dark", fontSize: 14 });
const newUser = defaultProfile.clone();
newUser.name = "Alice";
When to use Prototyping Pattern
- When creating a new object from scratch is expensive or complex and cloning an existing object saves time.
- When your system creates many objects that share the same or similar state/configuration but need slight modifications.
- When cloned objects should be independent copies, modifiable without affecting the original prototype.
4. Builder Pattern
A method to build something complicated step-by-step so it’s easier to create and change.
Example: Constructing complex SQL queries step-by-step.
class SqlQueryBuilder {
private query: string = "";
select(fields: string[]): SqlQueryBuilder {
this.query += `SELECT ${fields.join(", ")} `;
return this;
}
from(table: string): SqlQueryBuilder {
this.query += `FROM ${table} `;
return this;
}
where(condition: string): SqlQueryBuilder {
this.query += `WHERE ${condition} `;
return this;
}
build(): string {
return this.query.trim() + ";";
}
}
// Usage
const query = new SqlQueryBuilder()
.select(["id", "name"])
.from("users")
.where("age > 18")
.build();
console.log(query); // SELECT id, name FROM users WHERE age > 18;
When to use Builder Pattern
- When the object to be created is complex and requires multiple steps or parts to be constructed incrementally.
- When a class has many optional parameters and multiple constructors become unwieldy or confusing (the “telescoping constructor” problem).
- When you want to control the construction process step-by-step, possibly allowing clients to omit some steps or defer them.
- When you anticipate adding new variants or configurations of the object without changing the client code.
In this post, I’ve covered some of the fundamental design patterns like Factory, Singleton, Prototype, and Builder. These patterns form the backbone of many scalable and maintainable applications in production.
I'll be diving deeper into other useful design patterns in upcoming blogs, exploring how they can help solve complex problems and make your code cleaner and more efficient.
