SOLID for Builders
Stage 3 · Architecture & System Design · B.U.I.L.D. letter: U
You built it fast. It works. Then six weeks later you touch one tiny thing and three other things break. SOLID is the set of five principles that separates code you can grow from code you eventually have to throw away.
⚠️ The vibe trap
Googling "SOLID principles" returns a wall of UML diagrams and Java textbook prose that makes it feel like academic gatekeeping for people who already have CS degrees. It isn't. Every SOLID principle is answering a real, practical question that you have already bumped into — you just didn't have the word for it yet. Skip the theory long enough and your codebase starts fighting you instead of helping you. These five ideas take about an hour to learn and save you hundreds of hours over the life of a real project.
🪆 S — Single Responsibility
One module, one reason to change.
If you have to open the same file to fix a bug, update a business rule, AND change how the UI looks, that file has too many jobs.
// BEFORE — UserCard does everything: fetches, formats, and renders
function UserCard({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(data => {
data.displayName = `${data.first} ${data.last}`.toUpperCase();
setUser(data);
});
}, [userId]);
if (!user) return <p>Loading…</p>;
return <div className="card"><h2>{user.displayName}</h2></div>;
}
// AFTER — three tiny responsibilities, each in its own home
// hooks/useUser.js
function useUser(userId) {
const [user, setUser] = useState(null);
useEffect(() => {
fetch(`/api/users/${userId}`).then(r => r.json()).then(setUser);
}, [userId]);
return user;
}
// utils/formatUser.js
function formatDisplayName(user) {
return `${user.first} ${user.last}`.toUpperCase();
}
// components/UserCard.jsx
function UserCard({ userId }) {
const user = useUser(userId);
if (!user) return <p>Loading…</p>;
return <div className="card"><h2>{formatDisplayName(user)}</h2></div>;
}
What it really means for you: When a requirement changes ("show nickname instead of full name"), you touch exactly one file. Nothing else breaks.
Common mistake: Naming a file utils.js and dumping everything in it. That's one file with many responsibilities — the opposite of SRP.
🔓 O — Open/Closed
Open for extension, closed for modification.
Add new behavior by adding new code, not by editing existing code that already works.
// BEFORE — adding a new payment method means editing this function forever
function processPayment(order, method) {
if (method === 'stripe') {
return stripe.charge(order.total);
} else if (method === 'paypal') {
return paypal.pay(order.total);
}
// next week: add apple pay? edit this again. and again. and again.
}
// AFTER — each payment method is its own module; core logic never changes
class StripeProcessor {
pay(amount) { return stripe.charge(amount); }
}
class PayPalProcessor {
pay(amount) { return paypal.pay(amount); }
}
class ApplePayProcessor {
pay(amount) { return applePay.authorize(amount); }
}
// processPayment never needs to change again
function processPayment(order, processor) {
return processor.pay(order.total);
}
What it really means for you: The if/else chain that keeps growing is a red flag. When you find yourself adding a new else if to handle a new type of thing, that's your cue to use the Open/Closed pattern instead.
Common mistake: Treating OCP as "never edit any file ever." That's not it — you can still fix bugs. The goal is that adding new behavior shouldn't require touching stable, tested code.
🤝 L — Liskov Substitution
If B extends A, you must be able to use B anywhere A is expected — without surprises.
A subtype has to honor the contract the parent set. If it silently breaks the rules, callers can't trust the abstraction.
// BEFORE — ReadOnlyList claims to be a List but breaks the contract
class List {
constructor() { this.items = []; }
add(item) { this.items.push(item); }
getAll() { return this.items; }
}
class ReadOnlyList extends List {
add(item) {
throw new Error("Cannot add to a read-only list"); // surprise!
}
}
function fillList(list) {
list.add("apple"); // blows up when passed a ReadOnlyList
}
// AFTER — use composition or a proper hierarchy instead
class ReadOnlyList {
constructor(items) { this.items = [...items]; }
getAll() { return this.items; }
}
class MutableList {
constructor() { this.items = []; }
add(item) { this.items.push(item); }
getAll() { return this.items; }
}
What it really means for you: Before you extend a class, ask: "Does every method on the parent still make sense on my subtype?" If you'd have to throw an error or return nonsense, inheritance is the wrong tool — use composition instead.
Common mistake: Over-using extends just to share code. Inheritance is a strong promise ("I am a kind of X"). If you only want to reuse a function, just import it.
🎯 I — Interface Segregation
Don't force a module to depend on things it doesn't use.
Bloated interfaces create invisible coupling. A component that imports a giant object but only uses two fields is now fragile — it re-renders (or breaks) whenever any field on that object changes.
// BEFORE — Avatar depends on the entire user object
function Avatar({ user }) {
// only needs user.avatarUrl and user.name —
// but re-renders when user.billingAddress or user.role changes too
return <img src={user.avatarUrl} alt={user.name} />;
}
// also bad: a single "service" object with 30 methods passed everywhere
const userService = {
getUser, updateUser, deleteUser, sendEmail,
resetPassword, logAudit, exportData, /* ... */
};
// every caller must import all 30 methods even if it uses one
// AFTER — pass only what the component actually needs
function Avatar({ avatarUrl, name }) {
return <img src={avatarUrl} alt={name} />;
}
// and split large service objects into focused ones
const authService = { login, logout, resetPassword };
const profileService = { getUser, updateUser };
const auditService = { logAudit, exportData };
What it really means for you: If you pass a giant props object or a mega-service everywhere "for convenience," you've created hidden coupling. Narrow the interface down to only what each consumer actually needs.
Common mistake: Thinking this only applies to TypeScript interfaces. ISP applies to props, function arguments, and any module boundary — in any language.
🔌 D — Dependency Inversion
High-level logic should not depend on low-level details. Both should depend on abstractions.
In plain English: your business logic shouldn't care which database, email provider, or API you use. It should talk to a simple contract; the implementation is swapped in from outside.
// BEFORE — OrderService is hardwired to Stripe forever
class OrderService {
async checkout(cart) {
const total = cart.items.reduce((sum, i) => sum + i.price, 0);
// direct dependency — can never be tested without a real Stripe account
await stripe.charges.create({ amount: total, currency: 'usd' });
return { success: true, total };
}
}
// AFTER — OrderService depends on an abstraction it receives from outside
class OrderService {
constructor(paymentGateway) {
// injected — callers decide which gateway to use
this.paymentGateway = paymentGateway;
}
async checkout(cart) {
const total = cart.items.reduce((sum, i) => sum + i.price, 0);
await this.paymentGateway.charge({ amount: total, currency: 'usd' });
return { success: true, total };
}
}
// Production
const service = new OrderService(stripeGateway);
// Tests — zero API calls, zero Stripe account needed
const mockGateway = { charge: jest.fn().mockResolvedValue({ id: 'mock' }) };
const testService = new OrderService(mockGateway);
What it really means for you: If your business logic imports a specific third-party library directly, you've locked yourself in. Pass the dependency in from the outside (dependency injection) and your core logic becomes swappable and testable.
Common mistake: Thinking you need a full DI framework to do this. Passing a function or object into a constructor or as a prop is dependency injection. No library required.
💡 A note on religion
SOLID is a compass, not a commandment. A two-function utility script doesn't need five separate files in the name of Single Responsibility. A toy project with one payment method doesn't need an abstraction layer around Stripe. Apply these principles where the cost of not applying them is real: code that will be extended, code that will be tested, code that will be touched by more than one person. The goal is software that stays under your control — not software that demonstrates you've read the right textbooks.
🛠️ Your mission
Pick one real module from your current project — ideally something that felt messy the last time you touched it.
- Single Responsibility audit: List every reason you might need to change that module. If you count more than one, split it. Move each responsibility into its own function or file.
- Dependency Inversion refactor: Find one place where your module directly imports a third-party library or external service. Wrap that library in a thin adapter object, then inject the adapter. Confirm your module no longer imports the library directly.
Write a short comment at the top of each refactored file explaining its single responsibility in one sentence.
✅ You're done when…
- Your refactored module passes the Code Review Rubric (each file has one clear responsibility, dependencies are injected, no surprise side effects)
- You can swap the injected dependency for a mock in a test without changing any business logic
- You can explain, in plain English, which SOLID principle each refactor addressed and why it matters for that specific file
- A teammate could extend one of your modules with new behavior without editing the file that already works
➡️ Next: Managing State & Data Flow. Build It Right, Or Don't Build It At All. 🏛️