Design Patterns
solutions to problems that every developer faces. know the names, think at a higher level.
what are design patterns
design patterns are reusable solutions to commonly occurring problems in software design. they are not code you copy-paste — they are blueprints you adapt to your situation.
coined by the “Gang of Four” in 1994 in the book “Design Patterns”. the 23 patterns they described still apply today, though the language has changed.
why learn them: - you are already using some without knowing their names - naming a pattern lets you communicate complex ideas in one word - knowing patterns helps you spot the right solution faster - code becomes easier to reason about and maintain
three categories: - creational — how objects are created - structural — how objects are composed together - behavioral — how objects communicate
creational patterns
singleton
ensures a class has only ONE instance. that instance is shared everywhere.
use when: database connection, config object, logger, app-wide state.
// bad — creates new connection every time
function getDatabase() {
return new DatabaseConnection(config)
}
// singleton — one connection shared everywhere
class Database {
static instance = null
static getInstance() {
if (!Database.instance) {
Database.instance = new Database()
}
return Database.instance
}
constructor() {
this.connection = new DatabaseConnection(config)
}
query(sql) {
return this.connection.query(sql)
}
}
// usage — always returns same instance
const db1 = Database.getInstance()
const db2 = Database.getInstance()
db1 === db2 // true — same object
// in JavaScript modules — module system is already singleton
// lib/supabase.js
import { createClient } from '@supabase/supabase-js'
export const supabase = createClient(URL, KEY)
// every file that imports supabase gets the SAME instance — module singleton# Python singleton using module (simplest)
# config.py
import os
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL")
JWT_SECRET = os.getenv("JWT_SECRET")
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
# importing this module always gives same object — singleton
from config import DATABASE_URL # same value everywherefactory
creates objects without specifying the exact class. a function or method that decides what to create.
use when: creating different types of objects based on condition, hiding complex creation logic.
// without factory — creation logic scattered everywhere
if (type === "admin") {
user = new AdminUser(name, email, permissions)
} else if (type === "student") {
user = new StudentUser(name, email, college)
} else {
user = new GuestUser(name)
}
// factory — creation in one place
class UserFactory {
static create(type, data) {
switch (type) {
case "admin":
return new AdminUser(data.name, data.email, data.permissions)
case "student":
return new StudentUser(data.name, data.email, data.college)
case "guest":
return new GuestUser(data.name)
default:
throw new Error(`Unknown user type: ${type}`)
}
}
}
// usage
const user = UserFactory.create("student", { name: "Abhishek", college: "IIT" })# Python factory
def create_storage(storage_type: str):
if storage_type == "local":
return LocalStorage("/uploads")
elif storage_type == "s3":
return S3Storage(bucket=os.getenv("S3_BUCKET"))
elif storage_type == "supabase":
return SupabaseStorage(client=supabase)
else:
raise ValueError(f"Unknown storage type: {storage_type}")
# usage — changes based on environment
storage = create_storage(os.getenv("STORAGE_TYPE", "local"))builder
constructs complex objects step by step. lets you create different variations using the same construction process.
use when: object has many optional fields, creating SQL queries, building HTTP requests.
// without builder — constructor hell
const query = new DatabaseQuery(
"users", // table
["id", "name"], // fields
{ age: 20 }, // where
"name", // order by
"ASC", // direction
10, // limit
0 // offset
)
// with builder — readable, flexible
class QueryBuilder {
constructor(table) {
this.table = table
this._fields = ["*"]
this._where = {}
this._orderBy = null
this._limit = null
this._offset = 0
}
select(...fields) {
this._fields = fields
return this // return this for chaining
}
where(conditions) {
this._where = { ...this._where, ...conditions }
return this
}
orderBy(field, direction = "ASC") {
this._orderBy = `${field} ${direction}`
return this
}
limit(n) {
this._limit = n
return this
}
offset(n) {
this._offset = n
return this
}
build() {
let sql = `SELECT ${this._fields.join(", ")} FROM ${this.table}`
if (Object.keys(this._where).length) {
const conditions = Object.entries(this._where)
.map(([k, v]) => `${k} = '${v}'`)
.join(" AND ")
sql += ` WHERE ${conditions}`
}
if (this._orderBy) sql += ` ORDER BY ${this._orderBy}`
if (this._limit) sql += ` LIMIT ${this._limit}`
if (this._offset) sql += ` OFFSET ${this._offset}`
return sql
}
}
// usage — reads like English
const query = new QueryBuilder("users")
.select("id", "name", "email")
.where({ city: "Lucknow", active: true })
.orderBy("name", "ASC")
.limit(20)
.offset(40)
.build()structural patterns
repository
separates data access logic from business logic. your components/services never talk to the database directly — they talk to the repository.
use when: always. this is the most important pattern for apps with a database.
// without repository — data access mixed with business logic
async function Dashboard() {
// direct database call in component — bad
const { data } = await supabase
.from('goals')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
return <GoalList goals={data} />
}
// with repository — clean separation
// lib/repositories/goalsRepository.js
class GoalsRepository {
async findByUser(userId, date = null) {
let query = supabase
.from('goals')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
if (date) query = query.eq('date', date)
const { data, error } = await query
if (error) throw error
return data
}
async create(goal) {
const { data, error } = await supabase
.from('goals')
.insert(goal)
.select()
.single()
if (error) throw error
return data
}
async update(id, updates) {
const { data, error } = await supabase
.from('goals')
.update(updates)
.eq('id', id)
.select()
.single()
if (error) throw error
return data
}
async delete(id) {
const { error } = await supabase
.from('goals')
.delete()
.eq('id', id)
if (error) throw error
}
}
export const goalsRepository = new GoalsRepository()
// component — clean, no database knowledge
async function Dashboard() {
const goals = await goalsRepository.findByUser(user.id, today)
return <GoalList goals={goals} />
}
// benefit: swap Supabase for any database → change only the repository# Python repository pattern
class GoalsRepository:
def __init__(self, db):
self.db = db
async def find_by_user(self, user_id: str, date: str = None):
query = "SELECT * FROM goals WHERE user_id = $1"
params = [user_id]
if date:
query += " AND date = $2"
params.append(date)
return await self.db.fetch(query, *params)
async def create(self, goal: dict):
return await self.db.fetchrow(
"INSERT INTO goals (user_id, title, date) VALUES ($1, $2, $3) RETURNING *",
goal["user_id"], goal["title"], goal["date"]
)
async def update(self, id: str, updates: dict):
set_clause = ", ".join(f"{k} = ${i+2}" for i, k in enumerate(updates.keys()))
values = list(updates.values())
return await self.db.fetchrow(
f"UPDATE goals SET {set_clause} WHERE id = $1 RETURNING *",
id, *values
)
# inject repository into routes
goals_repo = GoalsRepository(db)
@router.get("/api/goals")
async def get_goals(user_id = Depends(get_current_user)):
return await goals_repo.find_by_user(user_id)adapter
makes incompatible interfaces work together. wraps an object with a different interface.
use when: integrating third-party libraries, switching between services.
// you have code that expects this interface
interface StorageService {
save(key: string, data: any): Promise<void>
load(key: string): Promise<any>
delete(key: string): Promise<void>
}
// but you are using Supabase Storage (different API)
class SupabaseStorageAdapter implements StorageService {
constructor(private supabase, private bucket: string) {}
async save(key: string, data: any): Promise<void> {
await this.supabase.storage
.from(this.bucket)
.upload(key, JSON.stringify(data))
}
async load(key: string): Promise<any> {
const { data } = await this.supabase.storage
.from(this.bucket)
.download(key)
return JSON.parse(await data.text())
}
async delete(key: string): Promise<void> {
await this.supabase.storage
.from(this.bucket)
.remove([key])
}
}
// your code works with any storage — swap Supabase for S3 anytime
const storage: StorageService = new SupabaseStorageAdapter(supabase, "uploads")
storage.save("user/avatar", imageData)decorator
adds behavior to an object without modifying it. wraps it with extra functionality.
use when: logging, caching, rate limiting, authentication — add to any function.
// basic function
async function fetchUser(id) {
const res = await fetch(`/api/users/${id}`)
return res.json()
}
// decorator adds caching
function withCache(fn, ttl = 60000) {
const cache = new Map()
return async function(...args) {
const key = JSON.stringify(args)
if (cache.has(key)) {
const { value, expiry } = cache.get(key)
if (Date.now() < expiry) {
console.log("cache hit:", key)
return value
}
}
const result = await fn(...args)
cache.set(key, { value: result, expiry: Date.now() + ttl })
return result
}
}
// decorator adds logging
function withLogging(fn) {
return async function(...args) {
console.log(`calling ${fn.name} with`, args)
const start = Date.now()
const result = await fn(...args)
console.log(`${fn.name} took ${Date.now() - start}ms`)
return result
}
}
// decorate the function
const cachedFetchUser = withCache(fetchUser, 5 * 60 * 1000) // 5 min cache
const loggedFetchUser = withLogging(cachedFetchUser)
loggedFetchUser(1) // logged + cached# Python decorators (built into language)
import functools
import time
# timing decorator
def timer(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
start = time.time()
result = await func(*args, **kwargs)
print(f"{func.__name__} took {time.time() - start:.3f}s")
return result
return wrapper
# cache decorator
def cache(ttl=60):
def decorator(func):
cached = {}
@functools.wraps(func)
async def wrapper(*args):
key = str(args)
if key in cached:
value, expiry = cached[key]
if time.time() < expiry:
return value
result = await func(*args)
cached[key] = (result, time.time() + ttl)
return result
return wrapper
return decorator
# use decorators
@timer
@cache(ttl=300)
async def get_user(user_id: str):
return await db.fetch_one("SELECT * FROM users WHERE id = $1", user_id)
# FastAPI dependency decorators
@app.get("/api/goals")
@limiter.limit("100/minute")
async def get_goals(user = Depends(get_current_user)):
...behavioral patterns
observer (pub/sub)
one object (publisher) notifies many objects (subscribers) when something happens. subscribers don’t know about each other.
use when: event systems, real-time updates, decoupling components.
// event emitter — observer pattern
class EventEmitter {
constructor() {
this.listeners = {}
}
on(event, callback) {
if (!this.listeners[event]) {
this.listeners[event] = []
}
this.listeners[event].push(callback)
// return unsubscribe function
return () => this.off(event, callback)
}
off(event, callback) {
this.listeners[event] = this.listeners[event]
?.filter(cb => cb !== callback)
}
emit(event, data) {
this.listeners[event]?.forEach(cb => cb(data))
}
}
const events = new EventEmitter()
// subscribers
const unsubGoal = events.on("goal:created", (goal) => {
console.log("new goal:", goal.title)
updateDashboard(goal)
})
events.on("goal:created", (goal) => {
sendNotification(`Goal created: ${goal.title}`)
})
events.on("user:login", (user) => {
analytics.track("login", { userId: user.id })
})
// publisher
async function createGoal(data) {
const goal = await db.create(data)
events.emit("goal:created", goal) // all subscribers notified
return goal
}
// unsubscribe when done
unsubGoal()// React — useState and useEffect are observer pattern
// state is the publisher, components are subscribers
const [goals, setGoals] = useState([])
// every component that uses 'goals' is subscribed to it
// setGoals notifies all subscribers (re-renders)strategy
define a family of algorithms, put each in its own class, make them interchangeable.
use when: multiple ways to do the same thing, want to swap algorithms at runtime.
// sorting strategies
const strategies = {
byDate: (a, b) => new Date(b.createdAt) - new Date(a.createdAt),
byTitle: (a, b) => a.title.localeCompare(b.title),
byCompletion: (a, b) => b.completed - a.completed,
byPriority: (a, b) => b.priority - a.priority
}
function sortGoals(goals, strategy) {
return [...goals].sort(strategies[strategy])
}
// usage — swap strategy at runtime
const sorted = sortGoals(goals, "byDate")
const byName = sortGoals(goals, "byTitle")
// React — strategy in UI
function GoalList({ goals }) {
const [sortBy, setSortBy] = useState("byDate")
const sorted = sortGoals(goals, sortBy)
return (
<div>
<select onChange={e => setSortBy(e.target.value)}>
<option value="byDate">Newest first</option>
<option value="byTitle">Alphabetical</option>
<option value="byCompletion">Completion</option>
</select>
{sorted.map(goal => <GoalItem key={goal.id} goal={goal} />)}
</div>
)
}# payment strategy pattern
from abc import ABC, abstractmethod
class PaymentStrategy(ABC):
@abstractmethod
async def process(self, amount: float) -> bool:
pass
class RazorpayStrategy(PaymentStrategy):
async def process(self, amount: float) -> bool:
# razorpay API call
return True
class StripeStrategy(PaymentStrategy):
async def process(self, amount: float) -> bool:
# stripe API call
return True
class UPIStrategy(PaymentStrategy):
async def process(self, amount: float) -> bool:
# UPI API call
return True
# use any strategy
async def checkout(amount: float, payment_method: str):
strategies = {
"razorpay": RazorpayStrategy(),
"stripe": StripeStrategy(),
"upi": UPIStrategy()
}
strategy = strategies.get(payment_method)
if not strategy:
raise ValueError(f"Unknown payment method: {payment_method}")
return await strategy.process(amount)middleware / chain of responsibility
pass a request through a chain of handlers. each handler decides to process it or pass it along.
use when: HTTP middleware, validation pipelines, event processing.
// Express/FastAPI middleware IS chain of responsibility
// request passes through each middleware in order
// custom middleware pipeline
class Pipeline {
constructor() {
this.steps = []
}
use(step) {
this.steps.push(step)
return this
}
async execute(context) {
let index = 0
const next = async () => {
if (index < this.steps.length) {
const step = this.steps[index++]
await step(context, next)
}
}
await next()
return context
}
}
// define pipeline
const pipeline = new Pipeline()
.use(async (ctx, next) => {
console.log("step 1: validate input")
if (!ctx.data.title) throw new Error("Title required")
await next()
})
.use(async (ctx, next) => {
console.log("step 2: sanitize")
ctx.data.title = ctx.data.title.trim()
await next()
})
.use(async (ctx, next) => {
console.log("step 3: save to database")
ctx.result = await db.create(ctx.data)
await next()
})
// run pipeline
const context = { data: { title: " Study React " } }
await pipeline.execute(context)
console.log(context.result) // saved goalcommand
encapsulate a request as an object. lets you parameterize, queue, log, and undo operations.
use when: undo/redo functionality, queuing operations, logging actions.
// command pattern for undo/redo
class GoalManager {
constructor() {
this.goals = []
this.history = [] // stack of commands
this.redoStack = []
}
execute(command) {
command.execute(this.goals)
this.history.push(command)
this.redoStack = [] // clear redo on new action
}
undo() {
const command = this.history.pop()
if (command) {
command.undo(this.goals)
this.redoStack.push(command)
}
}
redo() {
const command = this.redoStack.pop()
if (command) {
command.execute(this.goals)
this.history.push(command)
}
}
}
// commands
class AddGoalCommand {
constructor(goal) { this.goal = goal }
execute(goals) { goals.push(this.goal) }
undo(goals) { goals.splice(goals.indexOf(this.goal), 1) }
}
class DeleteGoalCommand {
constructor(goal) {
this.goal = goal
this.index = null
}
execute(goals) {
this.index = goals.indexOf(this.goal)
goals.splice(this.index, 1)
}
undo(goals) {
goals.splice(this.index, 0, this.goal)
}
}
// usage
const manager = new GoalManager()
manager.execute(new AddGoalCommand({ id: 1, title: "Study React" }))
manager.execute(new AddGoalCommand({ id: 2, title: "Study SQL" }))
manager.undo() // removes "Study SQL"
manager.redo() // adds "Study SQL" backMVC — Model View Controller
not one of the original Gang of Four patterns, but the most widely used architectural pattern. separates application into three parts.
Model → data and business logic (database, rules)
View → what user sees (HTML, components)
Controller → handles input, connects model and view
React + FastAPI:
Model: PostgreSQL database + Pydantic models + business logic
View: React components (JSX)
Controller: FastAPI routes + React event handlers
# FastAPI as MVC
# Model (models.py)
class Goal(BaseModel):
id: str
title: str
completed: bool
user_id: str
# Controller (routers/goals.py)
@router.post("/api/goals")
async def create_goal(goal: GoalCreate, user = Depends(get_current_user)):
# controller validates, calls model, returns to view
if not goal.title.strip():
raise HTTPException(400, "Title cannot be empty")
created = await goals_repo.create({ **goal.dict(), "user_id": user.id })
return { "data": created }
# View (React components) — separate codebasepatterns you already use
useState + context → observer pattern
React Router → front controller
API layer (lib/api.js)→ facade pattern
useCallback + useMemo → flyweight pattern
Supabase client → singleton
try/catch everywhere → chain of responsibility
.map() .filter() → iterator pattern
when NOT to use patterns
patterns are tools, not rules. adding a pattern when you do not need it makes code more complex, not better.
do not use a pattern if: - it adds more code than it saves - the problem it solves does not exist yet - simpler code achieves the same thing
start simple. reach for patterns when you feel the pain the pattern solves.
=^._.^= patterns are vocabulary, not rules