Design Patterns That Matter
Stage 3 · Architecture & System Design · B.U.I.L.D. letter: I
You already speak the language of building. Design patterns give you and every other engineer on the planet a shared vocabulary — so "let's use a Strategy here" is a complete sentence that means something precise and saves ten minutes of whiteboarding.
⚠️ The vibe trap
Vibe coding builds fast, and that's genuinely great. But there are two failure modes waiting on either side: the builder who never abstracts anything, so every new requirement means rewriting the same mess in a slightly different shape — and the builder who discovers patterns and starts cramming them everywhere, turning a 40-line script into a framework that requires reading documentation just to add a button. Both traps cost real time. Patterns are tools with specific jobs, not architecture jewelry. Reach for them when the problem they solve is the problem you actually have.
🏭 Factory — Centralize Creation
The mental model
A Factory is just a function (or object) that knows how to build things so the rest of your code doesn't have to. Instead of scattering new DatabaseAdapter(config) calls across fifty files, one place owns that decision.
// Without a factory — creation logic leaks everywhere
const db1 = new PostgresAdapter({ host: process.env.DB_HOST, ssl: true });
const db2 = new PostgresAdapter({ host: process.env.DB_HOST, ssl: true }); // copied again
// With a factory — one place owns the knowledge
function createDatabase(type = 'postgres') {
const configs = {
postgres: () => new PostgresAdapter({ host: process.env.DB_HOST, ssl: true }),
sqlite: () => new SQLiteAdapter({ file: ':memory:' }),
mock: () => new MockAdapter(),
};
const builder = configs[type];
if (!builder) throw new Error(`Unknown database type: ${type}`);
return builder();
}
// Anywhere in the app
const db = createDatabase(process.env.NODE_ENV === 'test' ? 'mock' : 'postgres');
When to use it: You create the same type of object in multiple places. You want to swap implementations (real vs. mock, production vs. dev). The constructor requires config your callers shouldn't have to know about.
Common mistake: Building a Factory for a class you only instantiate once. If createDatabase is called exactly once in server.js, you just added indirection for no benefit — pass the config directly.
🎭 Strategy — Swap Behaviors at Runtime
The mental model
Strategy lets you define a family of algorithms, put each one behind the same interface, and hand whichever you need to the code that runs it. The runner doesn't care which strategy it gets — it just calls it.
// Concrete strategies — same shape, different behavior
const strategies = {
flat: (subtotal) => 0,
percentage: (subtotal) => subtotal * 0.1,
tiered: (subtotal) => subtotal < 50 ? 5 : subtotal < 200 ? 10 : 20,
};
// The runner knows nothing about business logic
function calculateShipping(subtotal, strategy) {
if (typeof strategy !== 'function') throw new Error('strategy must be a function');
return strategy(subtotal);
}
// Caller picks the strategy — the runner just executes
const fee = calculateShipping(
cart.subtotal,
strategies[user.membershipTier] ?? strategies.flat
);
When to use it: You have a decision point where the algorithm genuinely changes based on context (user tier, region, feature flag, A/B test). The naive alternative is a sprawling if/else or switch that grows every time the business adds a new rule.
Common mistake: Using Strategy when a simple if/else is clearer. If there will only ever be two cases and they're stable, the pattern adds a layer of abstraction with no payoff. Start with the if/else, refactor to Strategy when the third case arrives.
🔌 Adapter — Wrap a Messy External API
The mental model
Third-party APIs change. They're inconsistent. They return data in shapes your app doesn't want. An Adapter is a thin translation layer: your app speaks your language, the adapter converts, the external service never touches your internals.
// Raw Stripe shape — deeply nested, verbose, liable to change
// { id: 'ch_xxx', amount: 4999, currency: 'usd', status: 'succeeded', ... }
// Your app's shape — flat, predictable, currency-agnostic
// { chargeId, amountDollars, succeeded }
class StripeAdapter {
constructor(stripeClient) {
this.client = stripeClient;
}
async charge({ amountDollars, customerId, description }) {
const raw = await this.client.charges.create({
amount: Math.round(amountDollars * 100), // Stripe wants cents
currency: 'usd',
customer: customerId,
description,
});
return this.#normalize(raw);
}
#normalize(raw) {
return {
chargeId: raw.id,
amountDollars: raw.amount / 100,
succeeded: raw.status === 'succeeded',
};
}
}
// Now your app never sees Stripe's raw shape
const payments = new StripeAdapter(stripe);
const result = await payments.charge({ amountDollars: 49.99, customerId, description: 'Pro plan' });
When to use it: You're wrapping a third-party SDK, a legacy internal service, or any external contract you don't control. When the external API changes, you fix the Adapter — nothing else in your app needs to know.
Common mistake: Making the adapter do business logic. The adapter's only job is translation. Pricing rules, validation, error-handling policy — those belong elsewhere. A fat adapter becomes the new mess you were trying to escape.
📢 Observer / Pub-Sub — Decouple with Events
The mental model
When one thing happens and multiple other things need to react — but the thing that happened shouldn't have to know about all the reactors — Observer (or its close cousin Pub-Sub) is the pattern. Publishers emit events; subscribers listen; neither depends on the other directly.
// A minimal event emitter — Node's EventEmitter does this for you,
// but building it once makes the pattern visible
class EventBus {
#listeners = new Map();
on(event, handler) {
if (!this.#listeners.has(event)) this.#listeners.set(event, []);
this.#listeners.get(event).push(handler);
return () => this.off(event, handler); // returns unsubscribe fn
}
off(event, handler) {
const handlers = this.#listeners.get(event) ?? [];
this.#listeners.set(event, handlers.filter(h => h !== handler));
}
emit(event, payload) {
(this.#listeners.get(event) ?? []).forEach(h => h(payload));
}
}
const bus = new EventBus();
// Subscribers register independently — order is irrelevant
bus.on('order.completed', ({ orderId, userId }) => sendConfirmationEmail(userId, orderId));
bus.on('order.completed', ({ orderId }) => updateInventory(orderId));
bus.on('order.completed', ({ userId }) => awardLoyaltyPoints(userId));
// Publisher knows nothing about the above
function completeOrder(order) {
order.status = 'completed';
bus.emit('order.completed', { orderId: order.id, userId: order.userId });
}
When to use it: You have one source event and multiple independent reactions. You want to add new reactions without modifying the publisher. Side effects (email, analytics, inventory) shouldn't live inside core business logic.
Common mistake: Overusing events for things that are actually sequential and must succeed together. If "send email" and "charge card" must both succeed as a unit, an event bus won't give you that guarantee — use a transaction or a saga instead.
🔒 Singleton — One Instance (and Its Dangers)
The mental model
A Singleton ensures a class has exactly one instance and provides a global point of access to it. Database connection pools, configuration objects, and loggers are classic examples where you genuinely want one shared thing.
// Module-level singleton — in Node/ES modules, the module cache
// handles this for you. Import once, share everywhere.
let instance = null;
class AppConfig {
#data;
constructor() {
if (instance) return instance; // enforce single instance
this.#data = {
apiBaseUrl: process.env.API_BASE_URL ?? 'http://localhost:3000',
featureFlags: JSON.parse(process.env.FEATURE_FLAGS ?? '{}'),
};
instance = this;
}
get(key) {
return this.#data[key];
}
}
// Both of these return the exact same object
const config1 = new AppConfig();
const config2 = new AppConfig();
console.log(config1 === config2); // true
When to use it: You have a resource that is genuinely expensive to create (DB pool, HTTP client), or something that must be shared and consistent across the app (config, logger). In JavaScript/Node, ES module caching gives you Singleton behavior for free — just export an instance.
Common mistake: Using Singleton as a disguised global variable store. Global mutable state is the real danger here, not the instantiation rule. If your Singleton accumulates mutable state that any part of the app can modify at any time, you've rebuilt the worst parts of global variables behind a respectable-looking class. Keep Singletons either read-only or narrowly scoped.
🔍 Recognizing the Pattern Already in Your Code
Before you introduce a pattern, look for it latently. You may already have one:
- Multiple
if/elsebranches creating objects differently → Factory waiting to emerge - A giant
if/elseorswitchthat picks an algorithm → Strategy waiting to emerge - Direct calls into a third-party SDK scattered across files → Adapter waiting to emerge
- A function that runs four different "side effects" after one action → Observer waiting to emerge
const db = require('./db')imported in thirty files → Singleton already in place (thanks, module cache)
Refactoring a latent pattern into an explicit one is almost always lower risk than introducing it fresh, because the behavior already exists — you're just naming and centralizing it.
🛠️ Your Mission
Look at a project you've already built — your capstone app, a side project, anything with more than a few files.
- Find one place where object creation is duplicated or scattered across files. Extract it into a
create___()factory function and replace all the scattered calls with it. - Find one place where a long
if/elseorswitchpicks between algorithms or behaviors. Refactor it using the Strategy pattern — pull each branch into its own named function, put them in a strategies object keyed by the condition, and let the caller pick. - If you use any external SDK (Stripe, Twilio, a maps API, anything), wrap all direct SDK calls in a single Adapter class. Your app should never
import stripeexcept inside that one file.
Test that your app still works identically after each refactor — no behavior should change, only the shape of the code.
✅ You're Done When…
- You can pass the Code Review Rubric check for "no duplicated construction logic" — your factory is the single source of truth for building that type of object
- Your strategy refactor eliminates at least one
if/elsebranch from a business-logic function and the function is now open to new strategies without modification - Your adapter wraps an external SDK completely — no other file in your project imports or calls the SDK directly
- You can explain in one sentence why you chose each pattern you applied, and name a case where you deliberately chose NOT to apply it
➡️ Next: SOLID for Builders.
Build It Right, Or Don't Build It At All. 🏛️