Designing for Change
Stage 3 · Architecture & System Design · B.U.I.L.D. letter: U
You shipped a beautiful app. Three weeks later, the business switches payment providers, and you discover the Stripe API call is copy-pasted in eleven files. You fix it in three and leave eight bugs behind. That's not a vibe problem — it's a design problem. The only certainty in software is that requirements will change. Your job isn't to predict the future; it's to build seams in the right places so that when change arrives, it costs you an hour instead of a week.
⚠️ The vibe trap
When you're moving fast, it feels natural to call stripe.charges.create(...) directly wherever you need it, or to hardcode "https://api.openai.com/v1" in six different files. That works until it doesn't. The trap has two jaws: hardcoding volatile things everywhere (so one change ripples across the whole codebase), and the opposite — building elaborate abstraction layers for imaginary futures that never arrive. Both paths cost you dearly. The goal isn't to predict change; it's to make the likely changes cheap and the unlikely ones possible.
🔩 Identifying seams: where is change likely to arrive?
Not everything changes at the same rate. A button label changes weekly. Your core domain logic — "an order has line items that sum to a total" — might not change for years. The skill is telling them apart.
High-volatility zones (design seams here):
- Third-party API integrations (payments, email, SMS, maps, AI providers)
- Feature flags and experiment variants
- Anything the business might swap (shipping carrier today, different carrier next quarter)
- Anything where the interface is stable but the implementation might shift (file storage: local → S3 → GCS)
Low-volatility zones (don't over-engineer here):
- Core domain rules ("a discount can't exceed the order total")
- Data relationships you've had for years
- Pure utility functions with no external dependencies
VOLATILITY MAP — ask these questions per module:
1. Does this touch a third-party service? → HIGH volatility, add a seam
2. Could the business switch this vendor? → HIGH volatility, add a seam
3. Is this core domain logic? → LOW volatility, keep it direct
4. Is this a formatting/utility helper? → LOW volatility, keep it direct
5. Does this read from environment/config? → MEDIUM — centralise config, no seam needed
Mental model: Think of seams like expansion joints in a bridge. Concrete would crack if it were one solid slab. The joints absorb movement. Your code needs the same — deliberate gaps where the pieces can shift independently.
Why it matters: Without seams in volatile places, a single vendor change forces you to grep the entire codebase. With seams, you edit one file.
Common mistake: Adding seams everywhere. If you wrap a Date.now() call behind an interface "just in case," you've added indirection that confuses the next developer for zero benefit. Seams cost cognitive load; spend that budget only where volatility is real.
🧩 YAGNI vs. corner-traps: the tension you must hold
YAGNI — "You Aren't Gonna Need It" — is one of the most misquoted principles in software. Beginners hear "don't build things you don't need yet" and conclude "never plan ahead." That's how you paint yourself into a corner.
The real principle: don't build speculative features; do build deliberate seams. A seam isn't a feature. It's a line in the sand that says "this thing could change, so I'm keeping its definition in one place."
// ❌ YAGNI violation: building a full plugin system "just in case"
class EmailPluginRegistry {
plugins = new Map();
register(name, plugin) { this.plugins.set(name, plugin); }
get(name) { return this.plugins.get(name); }
list() { return [...this.plugins.keys()]; }
// ...50 more lines nobody asked for
}
// ✅ YAGNI + seam: one function call, one place to change
// No plugin architecture, but the dependency is contained
function sendEmail(to, subject, body) {
return sendgrid.send({ to, subject, text: body });
}
// Tomorrow when you swap to Resend: edit THIS one function, done.
Mental model: YAGNI says don't build the plugin registry. But it does NOT say scatter sendgrid.send(...) across 30 files. Centralising a call is not over-engineering — it's basic hygiene.
Why it matters: Premature abstraction creates indirection with no payoff, slows onboarding, and makes bugs harder to trace. But zero abstraction on volatile dependencies means a vendor change becomes a full-day grep-and-pray session.
Common mistake: Using "YAGNI" as a justification for hardcoding. YAGNI is about features, not coupling. "I hardcoded Stripe everywhere because YAGNI" is a misreading that will bite you.
🔌 Isolating volatile dependencies behind an interface
This is the concrete technique. When something is volatile, you define what you need from it as an interface (a plain JS object or class with a clear shape), then write one adapter that maps your interface onto the real library. Everything else in your app calls your interface — never the library directly.
// FILE: src/services/email/types.js
// The interface — what YOUR app needs. No sendgrid/resend/mailgun anywhere.
/**
* @typedef {Object} EmailPayload
* @property {string} to
* @property {string} subject
* @property {string} text
* @property {string} [html]
*/
/**
* @typedef {Object} EmailAdapter
* @property {(payload: EmailPayload) => Promise<void>} send
*/
// FILE: src/services/email/sendgrid-adapter.js
// The ONLY file that knows about SendGrid.
import sgMail from "@sendgrid/mail";
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
/** @type {import('./types').EmailAdapter} */
export const sendgridAdapter = {
async send({ to, subject, text, html }) {
await sgMail.send({
to,
from: process.env.EMAIL_FROM,
subject,
text,
html,
});
},
};
// FILE: src/services/email/index.js
// One switch to flip. The rest of the app never changes.
import { sendgridAdapter } from "./sendgrid-adapter.js";
import { resendAdapter } from "./resend-adapter.js"; // ready when you need it
export const email = sendgridAdapter; // ← swap this one line to change providers
// FILE: src/features/orders/order-service.js
// Business logic is clean. It knows nothing about SendGrid or Resend.
import { email } from "../../services/email/index.js";
export async function confirmOrder(order, user) {
await saveOrder(order);
await email.send({ // ← calls YOUR interface
to: user.email,
subject: `Order #${order.id} confirmed`,
text: `Thanks for your order! Total: $${order.total}`,
});
}
Mental model: Your app speaks a house language ("send an email"). The adapter is a translator. When you hire a different translator (swap providers), only the translator file changes — your house language stays the same.
Why it matters: When you need to swap Sendgrid for Resend, you write resend-adapter.js, change one line in index.js, and every caller automatically uses the new provider. Zero grep needed.
Common mistake: Writing the adapter but then still calling the library directly in two other places "just this once." Discipline matters: if the rule is "only adapters touch third-party libraries," enforce it with a linting comment or a note in your team docs.
⚙️ Configuration over hardcoding
Some change isn't about swapping a whole system — it's about adjusting a value. Feature limits, API endpoints, rate thresholds, pricing tiers, timeout durations. These are volatile. Hardcoding them means a code deploy for every business change.
The fix is to centralise configuration and read it from environment variables or a config file that isn't scattered through your logic.
// ❌ Hardcoded everywhere — a maintenance nightmare
async function generateAIResponse(prompt) {
const res = await fetch("https://api.openai.com/v1/chat/completions", {
method: "POST",
headers: { Authorization: `Bearer sk-abc123...` }, // hardcoded key!
body: JSON.stringify({ model: "gpt-4o", max_tokens: 1000, messages: [...] }),
});
}
// If you want to switch to Claude, raise the token limit, or rotate the key,
// you're hunting through files instead of editing one config.
// ✅ Configuration centralised
// FILE: src/config/ai.js
export const AI_CONFIG = {
baseUrl: process.env.AI_BASE_URL ?? "https://api.openai.com/v1",
apiKey: process.env.AI_API_KEY,
model: process.env.AI_MODEL ?? "gpt-4o",
maxTokens: Number(process.env.AI_MAX_TOKENS ?? 1000),
};
// FILE: src/services/ai/client.js
import { AI_CONFIG } from "../../config/ai.js";
export async function generateResponse(prompt) {
const res = await fetch(`${AI_CONFIG.baseUrl}/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${AI_CONFIG.apiKey}` },
body: JSON.stringify({
model: AI_CONFIG.model,
max_tokens: AI_CONFIG.maxTokens,
messages: [{ role: "user", content: prompt }],
}),
});
return res.json();
}
Mental model: Your config file is the control panel. Business needs to change a limit? They change one env variable. DevOps needs to rotate a key? One file. No code change, no deploy needed for the values themselves.
Why it matters: Hardcoded values mean every small business change requires a developer, a code review, and a deploy. Centralised config means a business operator (or a feature flag system) can make changes safely and independently.
Common mistake: Having a config file but then hardcoding "just the important stuff" inline — usually the API key, because "it's just one line." That one line is the one that leaks into your git history.
📐 "Make the change easy, then make the easy change" — Kent Beck
This is one of the most useful sentences in software engineering. When you see a change that needs to happen, you often have two jobs, not one.
Job 1 — Refactor: Make the codebase ready to accept the change cleanly. This might mean extracting a function, adding an interface, moving a constant to config. You're not building the feature yet; you're preparing the soil.
Job 2 — Implement: Now that the soil is ready, plant the seed. The actual change is now trivially small.
The trap is trying to do both at once. That's where bugs hide: you're refactoring AND changing behaviour in the same commit, and when something breaks you can't tell which half caused it.
KENT BECK TWO-STEP WORKFLOW
Step 1 — Make the change easy (refactor, does not change behaviour):
git commit -m "refactor: extract email sender into adapter"
Step 2 — Make the easy change (implement the actual feature):
git commit -m "feat: swap email provider from SendGrid to Resend"
Result:
- Each commit is reviewable and safe to revert independently
- The refactor commit has zero risk (behaviour unchanged)
- The swap commit is tiny and obvious
Mental model: A surgeon doesn't operate with a cluttered table. They prep the sterile field first, then make the incision. The prep is part of the procedure, not overhead.
Why it matters: Conflating refactor and feature work in one commit is the number one source of "I fixed one thing and broke three others." Separating them gives you a clean rollback path and a readable git history.
Common mistake: Skipping Step 1 entirely because "the refactor is obvious and I'm already here." An hour of prep refactoring saves three hours of debugging when you introduce a subtle behaviour change mid-refactor. Do the two-step.
🛠️ Your mission
Pick one volatile dependency in your app — a payment API call, an email send, an AI provider call, or even a hardcoded API endpoint URL. Right now it probably lives scattered in your code or at least tied tightly to a specific library.
Your job:
- Create a
src/services/<name>/directory (e.g.src/services/payments/). - Write a
types.js(or JSDoc comment) that defines what your app needs from this service — just the methods and shapes, no third-party library imported. - Write
<provider>-adapter.jsthat imports the real library and implements your interface. - Write
index.jsthat exports the active adapter. - Refactor every caller to import from
index.jsonly — no more direct library imports outside the adapter folder. - Write one
<provider>-stub.js— a fake adapter that justconsole.logs what it would do. Swap it in during development or testing so you don't burn API credits.
The goal is not to build a plugin system. It's to make "swap providers" a one-line change in index.js.
✅ You're done when…
- Every caller in your codebase imports from your adapter's
index.js— a search for the real library name (e.g."@sendgrid/mail"or"stripe") returns results ONLY inside your adapter folder, nowhere else. - You can swap providers by changing exactly one line in
index.js— confirmed by pointing to the stub adapter and verifying the app still runs. - Your interface type/shape is documented in
types.jsso a future adapter author knows exactly what to implement. - The
src/services/<name>/folder passes the Code Review Rubric check: volatile dependency isolated, no direct library calls outside the adapter boundary, config values read from environment. - You could onboard a new developer to your adapter pattern in under five minutes by showing them just the
types.jsandindex.jsfiles.
➡️ Next: Monolith vs Services. Build It Right, Or Don't Build It At All. 🏛️