ARTICLE · Playbook
SOLID and Friends (JS flavored)
S — Single Responsibility Principle. A module has one reason to change. Smell: UserService validates input, hashes passwords, writes to Postgres, and sends the welcome email.
SOLID#
S — Single Responsibility Principle. A module has one reason to change.
Smell: UserService validates input, hashes passwords, writes to Postgres, and sends the welcome email.
// fix: split by reason-to-changeclass UserRepository { save(user) { /* SQL */ } }class PasswordHasher { hash(pw) { return argon2.hash(pw); } }class WelcomeMailer { send(user) { /* SES */ } }class RegisterUser { constructor(repo, hasher, mailer) {...} async run(input) {...} }O — Open/Closed Principle. Open for extension, closed for modification: add behavior without editing existing code.
Smell: if (type === 'card') ... else if (type === 'paypal') ... else if (type === 'crypto') growing in one function.
// fix: strategy map / polymorphismconst processors = { card: new CardProcessor(), paypal: new PaypalProcessor() };function pay(order) { return processors[order.method].charge(order); } // add crypto by adding an entryL — Liskov Substitution Principle. Subtypes must be usable wherever the base type is, without surprises.
Smell: class ReadOnlyRepo extends Repo { save() { throw new Error('not supported'); } }.
// fix: don't inherit capabilities you can't honor; split the interfaceclass Reader { find(id) {} }class Repo extends Reader { save(x) {} } // ReadOnlyRepo is just a ReaderI — Interface Segregation Principle. Many small client-specific interfaces beat one fat one.
Smell: every plugin must implement onInstall, onUpgrade, onRender, onSchedule, onWebhook even if it only renders.
// fix: optional capability objects / duck-typed small contractsconst plugin = { render: (ctx) => {...} }; // only what it needsif (plugin.onSchedule) scheduler.register(plugin); // check capability, not classD — Dependency Inversion Principle. High-level policy depends on abstractions; details (DB, HTTP, SDKs) plug in.
Smell: const db = require('pg'); class OrderService { place() { db.query(...) } } — business logic welded to Postgres.
// fix: inject the port, implement the adapterclass OrderService { constructor({ orders, clock }) { this.orders = orders; this.clock = clock; } }new OrderService({ orders: new PgOrderRepo(pool), clock: Date }); // prodnew OrderService({ orders: new InMemoryOrderRepo(), clock: fakeClock }); // testThe rest of the vocabulary#
- DRY (Don't Repeat Yourself) — one source of truth per piece of knowledge. Smell: the same validation regex in three files. Fix: one
validators.js. Caveat: duplicated code that represents different knowledge should stay separate; premature DRY couples unrelated things. - KISS (Keep It Simple) — simplest thing that meets the requirement. Smell: event bus + plugin system for a CLI with two commands.
- YAGNI (You Aren't Gonna Need It) — don't build for hypothetical requirements. Smell:
storageAdapterabstraction with one implementation and no second in sight. - Separation of concerns — layer by kind of work: transport (HTTP handlers) / application (use cases) / domain (rules) / infrastructure (DB, queues). Smell: SQL inside an Express route handler.
- Composition over inheritance — build behavior by combining objects/functions, not deep class trees. Smell:
AdminUserWithAuditLogging extends UserWithAuditLogging extends User.
const withAudit = (repo) => ({ ...repo, save: async (x) => { await repo.save(x); audit('save', x); } });const repo = withAudit(withCache(new PgRepo()));- Law of Demeter (principle of least knowledge) — talk to your friends, not strangers: one dot where possible. Smell:
order.customer.address.country.codeacross modules. Fix:order.shippingCountryCode(). - Dependency injection — the mechanism for DIP: pass collaborators in (constructor args, factory functions, a container) instead of
new/require-ing them inside. Enables testing with fakes and swapping adapters. - Encapsulation / information hiding — expose intent, hide representation;
#privatefields, module scope. - Cohesion high, coupling low — the one-line summary of all of the above.
- Tell, don't ask — call
account.withdraw(50)notif (account.balance >= 50) account.balance -= 50. - Fail fast / explicit over implicit — validate at boundaries, throw early, no magic defaults.
In the interview (one breath each)#
- "I'd keep domain logic free of frameworks and inject the DB and clock so it's testable: that's DIP via DI."
- "Each service has one reason to change: SRP at the service level mirrors bounded contexts."
- "Adding a new channel shouldn't touch existing code: strategy per channel, open/closed."
- "I avoid inheritance across modules; I compose behaviors (caching, auditing) as wrappers."
See also