Design Patterns Won’t Save You If You Don’t Know When to Use Them (Part 1 of 2)
Part 1 of 2 — Covers 12 structural patterns: The Builders, Architects, and Connectors.
Design Patterns Won’t Save You If You Don’t Know When to Use Them (Part 1 of 2)
Part 1 of 2 — Covers 12 structural patterns: The Builders, Architects, and Connectors.

Three weeks before we shipped a fintech platform to 200,000 users, our notification system started dropping messages under load.
The root cause: 14 separate modules had each independently reimplemented an event subscription system, with different cleanup logic, some listeners never removed. Memory leaked. The event loop choked.
The fix took four hours. The real fix — the architectural one — was understanding the Observer pattern deeply enough to recognize we’d built it 14 times, badly. That’s the thing about design patterns: you’re already using them. You’re just doing it inconsistently.
What design patterns actually are:
Not solutions to memorize. A shared vocabulary for structural problems that, once named, become vastly easier to communicate and solve. The GoF catalogued 23 of them in 1994, for C++. Half exist because of language limitations TypeScript simply doesn’t have. That context matters.
One warning: The most dangerous thing you can do with patterns is treat them as solutions looking for problems. No pain, no pattern.

The Builders
How objects come into existence when construction gets complex.
Singleton
Intent: Ensure a class has exactly one instance and provide a global access point to it.
Problem: Your database connection pool is being instantiated 47 times because four different modules all believe they’re responsible for creating it.
Solution: Make the constructor private. Expose a static method that creates the instance on first call and returns the same one on every subsequent call.
Analogy: A country’s central bank — one exists, everyone accesses the same one, by deliberate design.

class DatabasePool {
private static instance: DatabasePool | null = null;
private constructor(private config: DBConfig) { // private: `new DatabasePool()` is now a compile error
this.connect();
}
static getInstance(config: DBConfig): DatabasePool {
if (!DatabasePool.instance) { // first call: create
DatabasePool.instance = new DatabasePool(config);
}
return DatabasePool.instance; // every subsequent call: same object
}
async query(sql: string): Promise<QueryResult> {
return this.getAvailableConnection().execute(sql);
}
}
const pool = DatabasePool.getInstance(dbConfig); // same instance everywhere
Use when
- One instance of a resource-intensive object is required across the entire app (connection pools, config, logger)
- You need global accessibility without prop-drilling
Strengths
- Guarantees a single instance
- Lazy initialization
- Global access point
Tradeoffs
- Breaks unit tests — hard to mock
- Hidden global state
- Violates Single Responsibility
Related: Dependency Injection solves the same problem with better testability. Prefer DI in new services.
Factory Method
Intent: Define an interface for creating an object, but let subclasses decide which class to instantiate.
Problem: Your notification service is coupled to new EmailNotification(). Next quarter: push notifications. The quarter after: SMS. Every new channel means modifying business logic that should never need to change.
Solution: Replace new with an abstract "factory method" that subclasses override. Business logic stays constant; only the created type changes.
Analogy: A job posting — you define the role requirements; the hired person fills the role. HR doesn’t pick the candidate.

abstract class NotificationService {
abstract createNotification(): Notification; // hook: subclass decides what to build
async notify(userId: string, message: string) {
const prefs = await getUserPrefs(userId);
const notification = this.createNotification(); // calls the hook — no `new` hardcoded here
await notification.send(prefs.contact, message); // works regardless of which type was created
}
}
class EmailNotificationService extends NotificationService {
createNotification(): Notification { return new EmailNotification(); } // concrete decision lives here
}
class PushNotificationService extends NotificationService {
createNotification(): Notification { return new PushNotification(); } // swap type — nothing else changes
}
Use when
- A class can’t anticipate the exact type of objects it must create
- Building plugin systems with open-ended product families
Strengths
- Decouples creator from concrete types
- Open/Closed Principle
- Easy to extend with new types
Tradeoffs
- More files, more classes
- Can over-engineer simple creation
Related: Factory Method creates one product via a hook. Abstract Factory creates families of coordinated products.
Abstract Factory
Intent: Create families of related objects without specifying their concrete classes.
Problem: You’re building a cross-platform UI library. Web and mobile each have their own Button, Modal, and Input — but they must always be used together. You can never mix a web Button with a mobile Modal.
Solution: Define a factory interface that produces a complete product family. Platform decision happens once, at the boundary.
Analogy: An IKEA furniture collection — each collection has matching pieces designed to work together. You choose the collection once.

interface UIFactory {
createButton(): Button;
createModal(): Modal;
}
class WebUIFactory implements UIFactory {
createButton(): Button { return new WebButton(); }
createModal(): Modal { return new WebModal(); }
}
class MobileUIFactory implements UIFactory {
createButton(): Button { return new MobileButton(); }
createModal(): Modal { return new MobileBottomSheet(); } // different type, same contract
}
class Screen {
private button: Button;
private modal: Modal;
constructor(factory: UIFactory) { // Screen never knows which factory it received
this.button = factory.createButton();
this.modal = factory.createModal(); // guaranteed matched pair — can't mix WebButton + MobileBottomSheet
}
}
const factory = isMobile ? new MobileUIFactory() : new WebUIFactory(); // one decision, one place
const screen = new Screen(factory);
Use when
- Code must work with multiple families of related objects
- Building theme systems, cross-platform UI kits, or DB driver abstractions
Strengths
- Guarantees product family consistency
- Decoupled from concrete types
Tradeoffs
- Adding a new product type requires changing every factory
- Lots of classes even for small families
Related: Often implemented with Factory Methods internally. Can use Singleton to ensure one factory per environment.
Builder
Intent: Construct complex objects step by step, separating construction from representation.
Problem: A UserAccount constructor with 8+ parameters — name, email, password, optional 2FA, optional billing, optional team — produces an argument list nobody can write or read without mistakes. This is the telescoping constructor problem.
Solution: A builder with one method per field, each returning this for chaining. Validate at each step, not in one buried constructor call.
Analogy: A custom PC configurator — build incrementally: processor, then RAM, then storage. Each step is validated and meaningful on its own.

class UserAccountBuilder {
private account: Partial<UserAccount> = { twoFactorEnabled: false };
withEmail(email: string): this { // returns `this` so calls can chain: .withEmail(...).withPassword(...)
if (!email.includes('@')) throw new Error(`Invalid email: ${email}`); // validate at the step, not buried in build()
this.account.email = email;
return this;
}
withPassword(plaintext: string): this {
this.account.passwordHash = bcrypt.hashSync(plaintext, 12); // transform happens here, not in the domain object
return this;
}
withTeam(teamId: string): this {
this.account.teamId = teamId; // optional — omitting this is fine
return this;
}
build(): UserAccount {
if (!this.account.email || !this.account.passwordHash) {
throw new Error('Email and password are required'); // final gate: catch anything still missing
}
return this.account as UserAccount;
}
}
// Each method name documents what it sets — no positional argument guessing
const account = new UserAccountBuilder()
.withEmail('user@company.com')
.withPassword('securepassword')
.withTeam('team_abc')
.build();
Use when
- Constructor has 5+ parameters, especially optional ones
- You need per-step validation during construction
- The same construction process should produce different representations
Strengths
- Readable, self-documenting call sites
- Validates at each step
- Supports optional params cleanly
Tradeoffs
- Overkill for simple objects
- Requires a separate Builder class
Related: For simple objects with 2–3 fields, use TypeScript’s destructured params instead: createUser({ email, teamId? }).
Prototype
Intent
Create new objects by cloning an existing instance rather than building from scratch.
Problem: A Document object requires a database round-trip, AST parsing, and style computation to create. A user wants "duplicate this document" — 90% identical to an existing one.
Solution: Define a clone() method. New objects copy an existing instance and specialize the copy.
Analogy: Biological cell division — copy what already works, then specialize.

class DocumentTemplate {
constructor(
public baseStyles: StyleSheet,
public meta: DocumentMeta,
private sections: Section[],
) {}
clone(): DocumentTemplate {
return new DocumentTemplate(
structuredClone(this.baseStyles), // deep copy — mutations to the clone won't touch the original
structuredClone(this.meta),
this.sections.map(s => s.clone()), // each section clones itself recursively
);
}
withTitle(title: string): DocumentTemplate {
const copy = this.clone(); // always start from a clean copy of the template
copy.meta.title = title;
return copy; // return the modified copy; original is untouched
}
}
const blogTemplate = new DocumentTemplate(styles, meta, sections); // expensive once: DB + parsing
const post = blogTemplate.withTitle('Design Patterns 2025'); // cheap: clone + one field change
const draft = blogTemplate.withTitle('Untitled Draft'); // blogTemplate is still unchanged
Use when
- Object creation is expensive (DB reads, network calls, heavy parsing)
- You need many similar objects with small variations
- Implementing undo systems that snapshot state before each change
Strengths
- Avoids expensive re-initialization
- Decoupled from concrete classes
Tradeoffs
- Deep-cloning circular refs is hard
- Custom clone logic becomes a maintenance burden
Related: Objects produced by Prototype can be stored in a Prototype Registry (a Flyweight-like factory).
The Architects
How objects wrap each other to change their interface, behavior, or access.
Adapter
Intent: Allow incompatible interfaces to work together without modifying either side.
Problem : Your codebase calls analytics.trackEvent(name, props). The new third-party SDK expects sdk.record({ type, metadata, timestamp }). You can't change the SDK. Coupling to it in 40 files means a full-codebase change every time you swap providers.
Solution: Write an Adapter class that implements your interface and translates calls to the SDK internally.
Analogy: A universal power adapter — your laptop and the wall socket don’t change; the adapter bridges them.

interface Analytics { // your codebase's contract — you own and control this
trackEvent(name: string, props: Record<string, unknown>): void;
}
class SegmentAdapter implements Analytics {
constructor(private sdk: SegmentSDK) {}
trackEvent(name: string, props: Record<string, unknown>): void {
// translate your interface into what the SDK actually expects
this.sdk.record({ type: name, metadata: props, timestamp: Date.now() });
}
}
// Swap Segment for Mixpanel → write MixpanelAdapter. Every call site stays the same.
const analytics: Analytics = new SegmentAdapter(new SegmentSDK());
analytics.trackEvent('signup', { plan: 'pro' }); // callers never import or touch the SDK directly
Use when
- A class interface doesn’t match what your code expects and you can’t change it
- Migrating between libraries gradually
Strengths
- Zero changes to existing code
- Swap providers in one place
Tradeoffs
- Adds an indirection layer
- Adapters accumulate with each SDK version
Related: Decorator also wraps an object — but to add behavior, not translate an interface.
Decorator
Intent
Add behavior to an object dynamically without modifying its class or interface.
Problem: An API client needs logging in dev, retry logic in prod, and request signing for auth. Building LoggingRetrySignedClient as a single class is a combinatorial explosion — 8 combinations for 3 concerns.
Solution: Each concern is its own wrapper that implements the same interface and delegates to an inner object.
Analogy: A coffee order — start with espresso, wrap it with milk, wrap that with vanilla. The cup still fits in the same holder.

interface APIClient {
request<T>(endpoint: string, opts: RequestOptions): Promise<T>;
}
class RetryDecorator implements APIClient {
constructor(private inner: APIClient, private maxAttempts = 3) {}
async request<T>(endpoint: string, opts: RequestOptions): Promise<T> {
for (let i = 1; i <= this.maxAttempts; i++) {
try { return await this.inner.request<T>(endpoint, opts); } // delegate to the next layer
catch (e) { if (i === this.maxAttempts) throw e; await delay(i * 200); } // backoff, then retry
}
throw new Error('unreachable');
}
}
class SigningDecorator implements APIClient {
constructor(private inner: APIClient, private secret: string) {}
async request<T>(endpoint: string, opts: RequestOptions): Promise<T> {
const sig = computeHMAC(endpoint, opts.body, this.secret);
return this.inner.request<T>(endpoint, { // add the header, then pass through
...opts, headers: { ...opts.headers, 'X-Signature': sig },
});
}
}
// Each layer wraps the one inside it — outermost runs first
// Call order: RetryDecorator → SigningDecorator → BaseAPIClient
const client: APIClient = new RetryDecorator(
new SigningDecorator(new BaseAPIClient(), process.env.API_SECRET!), 3
);
Use when
- Adding cross-cutting concerns (logging, caching, auth, metrics) without modifying core logic
- Concerns need to be mixed and matched at runtime
Strengths
- Compose behaviors independently
- Follows Open/Closed Principle
Tradeoffs
- Stack traces become harder to read
- Order of decorators matters and isn’t obvious
Related: Decorator adds behavior; Proxy controls access. TypeScript’s @decorator syntax is a language feature — not the same thing.
Facade
Intent: Provide a simple, unified interface to a complex subsystem.
Problem: Your app uses AWS S3 and DynamoDB together for document storage. Every developer using it must understand both SDKs, the correct initialization sequence, and idempotency patterns. That’s a tax paid every time someone new joins the team.
Solution: A Facade exposes only the 8% of the surface area you actually use. The SDKs are an implementation detail.
Analogy: A hotel concierge — behind that desk is a network of vendors and services. You just say “book me a taxi.” You don’t see the machinery.

class StorageService {
private s3 = new AWS.S3({ region: config.region }); // SDKs are private —
private dynamo = new AWS.DynamoDB.DocumentClient({ region: config.region }); // callers never see them
async uploadDocument(userId: string, file: Buffer, meta: DocumentMeta): Promise<string> {
const key = `users/${userId}/${crypto.randomUUID()}`;
// Step 1: store the file in S3
await this.s3.putObject({ Bucket: BUCKET, Key: key, Body: file }).promise();
// Step 2: write metadata with an idempotency guard (prevents duplicate entries on retry)
await this.dynamo.put({
TableName: TABLE,
Item: { userId, key, ...meta },
ConditionExpression: 'attribute_not_exists(#k)',
ExpressionAttributeNames: { '#k': 'key' },
}).promise();
// Step 3: return a time-limited URL — callers never touch S3 directly
return this.s3.getSignedUrlPromise('getObject', { Bucket: BUCKET, Key: key, Expires: 3600 });
}
}
// Callers: uploadDocument(userId, buffer, meta) → presigned URL. That's it.
Use when
- Wrapping a complex SDK or legacy system
- You want a single, documented entry point for a subsystem
- Migrating legacy systems gradually (Facade = the new contract)
Strengths
- Simplifies the interface for callers
- Isolates SDK changes to one place
Tradeoffs
- Can hide complexity you actually need to understand
- Becomes a leaky abstraction if callers need different behaviors
Related: Facade provides a new interface to a subsystem. Adapter makes an existing interface compatible.
Proxy
Intent: Provide a substitute that controls access to another object.
Problem: Your data layer needs authorization checks, but putting if (user.canAccess...) inside every repository method couples security concerns to business logic. Changes to auth rules ripple everywhere.
Solution: A Proxy implements the same interface as the real object and intercepts calls to enforce rules, caching, lazy init, or logging.
Analogy: A credit card — same interface as cash (pay for things), but it controls access: verifies funds, can decline.

interface UserRepository {
findUser(id: string): Promise<User>;
updateUser(id: string, data: Partial<User>): Promise<User>;
}
class AuthorizedUserRepository implements UserRepository {
constructor(private inner: UserRepository, private currentUser: AuthUser) {}
async findUser(id: string): Promise<User> {
if (id !== this.currentUser.id && !this.currentUser.isAdmin) {
throw new ForbiddenError(`Cannot access user ${id}`); // stop here — never reaches inner
}
return this.inner.findUser(id); // gate passed: delegate to the real repository
}
async updateUser(id: string, data: Partial<User>): Promise<User> {
if (id !== this.currentUser.id && !this.currentUser.isAdmin) {
throw new ForbiddenError(`Cannot modify user ${id}`);
}
const { role, ...safeData } = data; // strip fields a regular user cannot self-assign
return this.inner.updateUser(id, safeData);
}
}
// Callers use AuthorizedUserRepository exactly like UserRepository — auth is invisible to them
Use when
- Access control without polluting the object’s core logic (authorization proxy)
- Expensive objects created only when first accessed (virtual/lazy proxy)
Strengths
- Separates security from business logic
- Transparent to callers
Tradeoffs
- Adds an extra layer of indirection
- Responses may be delayed (lazy init)
Related: Proxy and Decorator look similar. Key distinction: Proxy manages the lifecycle and access of its subject; Decorator simply adds behavior without controlling access.
The Connectors
How objects connect structurally at a deeper level.
Bridge
Intent: Separate an abstraction from its implementation so both can evolve independently.
Problem: You have chart types (Bar, Line, Pie) and rendering backends (SVG, Canvas, WebGL). Naive approach: BarChartSVG, BarChartCanvas... Nine classes. Add one chart or one renderer and it triples.
Solution: Chart types and renderers become separate hierarchies connected by composition, not inheritance.
Analogy: A universal TV remote — the remote’s interface and the TV brand vary independently. Any remote pairs with any TV.

interface ChartRenderer { // implementation side — grows independently (SVG, Canvas, WebGL…)
drawBar(x: number, y: number, w: number, h: number): void;
drawLine(points: Point[]): void;
}
abstract class Chart { // abstraction side — grows independently (Bar, Line, Pie…)
constructor(protected data: ChartData, protected renderer: ChartRenderer) {}
abstract render(): void;
}
class BarChart extends Chart {
render() {
const max = Math.max(...this.data.values);
this.data.values.forEach((v, i) => {
// BarChart knows layout math; renderer knows how to draw — neither knows the other's internals
this.renderer.drawBar(i * 50, 300 - (v / max) * 300, 40, (v / max) * 300);
});
}
}
// 3 chart types + 3 renderers = 6 classes covering 9 combinations — no class explosion
const chart = new BarChart(salesData, new SVGRenderer());
const chart2 = new BarChart(salesData, new WebGLRenderer()); // swap renderer, chart type unchanged
Use when
- Two independent dimensions of variation would produce a class explosion
- You want to switch implementations at runtime
- Building platform-independent abstractions
Strengths
- Eliminates class explosion
- Both dimensions evolve independently
Tradeoffs
- Adds indirection even when it isn’t needed yet
- Can be over-engineering if you only have one dimension
Composite
Intent: Compose objects into tree structures and treat individual items and collections identically.
Problem: File systems, UI component trees, org charts. Anywhere things contain other things of the same type. Code that traverses them shouldn’t care whether it’s dealing with a leaf or a container.
Solution: Both leaf nodes and composite nodes implement the same interface. Composites delegate to their children recursively.
Analogy: A folder on your filesystem — contains files and other folders. Code listing contents treats both uniformly.

interface UIComponent {
render(): string;
getHeight(): number;
}
class Button implements UIComponent { // leaf — has no children
constructor(private label: string, private height: number) {}
render() { return `<button>${this.label}</button>`; }
getHeight() { return this.height; }
}
class Panel implements UIComponent { // composite — contains other UIComponents (including other Panels)
private children: UIComponent[] = [];
add(c: UIComponent): this { this.children.push(c); return this; }
render() { return `<div>${this.children.map(c => c.render()).join('')}</div>`; }
getHeight() { return this.children.reduce((h, c) => h + c.getHeight(), 0) + 32; } // sum children + own padding
}
// measure() works on a single Button, a Panel, or a Panel containing Panels — same call either way
function measure(c: UIComponent) { return c.getHeight(); }
const nav = new Panel()
.add(new Button('Home', 40))
.add(new Panel() // nested Panel — Panel doesn't care, it just recurses
.add(new Button('Profile', 40))
.add(new Button('Settings', 40)));
Use when
- Your domain has part-whole hierarchies (trees within trees)
- Client code should treat leaves and composites the same way
- Recursive operations over tree-structured data
Strengths
- Uniform interface across the tree
- Easy to add new component types
Tradeoffs
- Makes it hard to restrict what can be added where
Flyweight
Intent: Reduce memory by sharing common state across large numbers of similar objects.
Problem: A map rendering engine needs 50,000 trees. Each tree has species, texture, and a 3D mesh. If each object stores all this, you’re holding gigabytes of duplicated data — most trees of the same species share the exact same texture and mesh.
Solution: Split state into intrinsic (shared, immutable — stored once) and extrinsic (unique per instance — passed in at use time).
Analogy: A chess set — the white king piece is shared; its position on the board changes. You don’t carve 64 boards into each piece.

interface TreeType {
species: string;
texture: WebGLTexture; // intrinsic: same for every oak — loaded once, shared across all oak instances
mesh: Float32Array; // intrinsic: same for every oak
}
class TreeTypeFactory {
private cache = new Map<string, TreeType>();
get(species: string, textureUrl: string): TreeType {
const key = `${species}:${textureUrl}`;
if (!this.cache.has(key)) {
// cache miss: load GPU texture + mesh (expensive — only happens once per species)
this.cache.set(key, { species, texture: loadGPUTexture(textureUrl), mesh: loadMesh(species) });
}
return this.cache.get(key)!; // cache hit: all subsequent oaks share this exact object
}
}
class Tree {
constructor(
public x: number, public y: number, // extrinsic: unique per tree instance
private type: TreeType, // intrinsic: shared reference — NOT a per-tree copy
) {}
draw(renderer: Renderer) {
renderer.draw(this.type.mesh, this.type.texture, this.x, this.y);
}
}
// 50,000 trees, 5 species → 5 TreeType objects in GPU memory instead of 50,000
Use when
- Creating massive numbers of similar objects (thousands to millions)
- Memory consumption is a measured, real constraint
Strengths
- Dramatic memory savings when applied correctly
Tradeoffs
- Significantly complicates code
- Only worthwhile when you’ve measured the memory problem
Related: Flyweight is similar to Singleton in spirit (shared instance), but Flyweight manages many shared types, not one.
What’s in Part 2
Twelve patterns down, the structural half. You’ve seen how objects are made, wrapped, and connected.
Part 2 is about how objects talk to each other: Observer (the 14-module story from the opening), Chain of Responsibility (the middleware pipeline you write every day), Command (undo/redo, Redux), and six more. Plus: what GoF gets wrong in 2026, which patterns are obsolete in TypeScript, and the 3-question framework for knowing when to reach for any of them.
Part 2: Communicators & Strategists →
For more updates on the latest tools and technologies, follow the Simform Engineering blog.
메타데이터
- post_id
- 97f70afba43e
- slug
- design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-1-of-2-97f70afba43e
- url
- https://medium.com/simform-engineering/design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-1-of-2-97f70afba43e
- canonical_url
- https://medium.com/simform-engineering/design-patterns-wont-save-you-if-you-don-t-know-when-to-use-them-part-1-of-2-97f70afba43e
- author_url
- https://medium.com/@19it197.akashbhai.chauhan
- status
- ok
- fetched_at
- 2026-06-24 04:09:36