SOLID and Four Real Patterns
Five principles and four patterns, each stated plainly, each shown as code that goes from bad to better. This is where the fifteen lessons turn into judgement.
By the end: Name the smell, then name the fix.
Fourteen lessons of how things work. This one is about choosing well: five principles that tell you when a design is going wrong, and four patterns you will genuinely reach for. Everything here is shown as code that starts bad and gets better, because a principle you cannot recognise in code is a slogan.
S — Single responsibility
A class should have one reason to change. When a class holds data and formats it and saves it, three unrelated teams end up editing the same file.
class Booking {
constructor(seats) { this.seats = seats; }
get total() { return this.seats.length * 1800; } // pricing
toHtml() { return `<p>${this.seats.join(', ')}</p>`; } // presentation
save(store) { store['booking'] = JSON.stringify(this.seats); } // persistence
}
const store = {};
const b = new Booking(['D11', 'D12']);
b.save(store);
console.log(b.total, b.toHtml(), store);
console.log('Change the price rules, the markup, or the storage: same file, every time.');
Output
class Booking {
#seats;
constructor(seats) { this.#seats = [...seats]; }
get seats() { return [...this.#seats]; }
get count() { return this.#seats.length; }
}
class Pricing {
static SEAT = 12000;
static FEE = 200;
totalFor(booking) {
return booking.count === 0 ? 0 : booking.count * Pricing.SEAT + Pricing.FEE;
}
}
class BookingView {
render(booking, pricing) {
return `<p>${booking.seats.join(', ')} — ₦${pricing.totalFor(booking)}</p>`;
}
}
class BookingStore {
#data = new Map();
save(ref, booking) { this.#data.set(ref, booking.seats); return this; }
load(ref) { const s = this.#data.get(ref); return s ? new Booking(s) : null; }
}
const booking = new Booking(['1A', '1B']);
const pricing = new Pricing();
console.log(new BookingView().render(booking, pricing));
const store = new BookingStore().save('BK-1', booking);
console.log('reloaded:', store.load('BK-1').seats);
Output
O — Open for extension, closed for modification
You should be able to add a new case without editing existing, working code. In practice this means: no switch on type in code that will keep growing.
function priceWith(discount, naira) {
switch (discount) {
case 'none': return naira;
case 'student': return Math.round(naira * 0.8);
case 'senior': return Math.round(naira * 0.75);
default: throw new Error('Unknown discount: ' + discount);
}
}
console.log(priceWith('student', 12000));
console.log('Add a group rate and this function changes. Again.');
Output
class Discount {
constructor(label, apply) { this.label = label; this.apply = apply; }
}
const DISCOUNTS = [
new Discount('full price', p => p),
new Discount('student', p => Math.round(p * 0.8)),
new Discount('senior', p => Math.round(p * 0.75)),
];
// Extension: push a new one. Nothing above is edited.
DISCOUNTS.push(new Discount('group of 10+', p => Math.round(p * 0.7)));
for (const d of DISCOUNTS) {
console.log(d.label.padEnd(13), '₦' + d.apply(12000));
}
Output
L — Substitutability
A subclass must work anywhere its parent works. The textbook example is genuinely the clearest one.
class Rectangle {
constructor(w, h) { this.w = w; this.h = h; }
setWidth(w) { this.w = w; return this; }
setHeight(h) { this.h = h; return this; }
get area() { return this.w * this.h; }
}
class Square extends Rectangle {
constructor(side) { super(side, side); }
setWidth(w) { this.w = w; this.h = w; return this; } // must stay square
setHeight(h) { this.w = h; this.h = h; return this; }
}
// A function written against Rectangle, and entirely reasonable.
function stretchToFit(rect) {
rect.setWidth(10).setHeight(4);
return rect.area;
}
console.log('rectangle:', stretchToFit(new Rectangle(2, 3))); // 40, as expected
console.log('square:', stretchToFit(new Square(2))); // 16, silently wrong
console.log('Nothing threw. The caller has no way to notice.');
Output
class Shape {
get area() { throw new Error(`${this.constructor.name} must implement area.`); }
}
class Rectangle extends Shape {
constructor(w, h) { super(); this.w = w; this.h = h; Object.freeze(this); }
get area() { return this.w * this.h; }
resized(w, h) { return new Rectangle(w, h); } // returns a new shape
}
class Square extends Shape {
constructor(side) { super(); this.side = side; Object.freeze(this); }
get area() { return this.side ** 2; }
resized(side) { return new Square(side); }
}
function totalArea(shapes) { return shapes.reduce((n, s) => n + s.area, 0); }
const shapes = [new Rectangle(2, 3), new Square(4)];
console.log('total:', totalArea(shapes));
console.log('resize returns new:', shapes[1].resized(5).area, '| original:', shapes[1].area);
Output
I — Small interfaces
Nothing should be forced to implement methods it does not need. A fat base class makes every subclass carry stubs.
class Device {
play() { throw new Error('must implement play'); }
record() { throw new Error('must implement record'); }
fade() { throw new Error('must implement fade'); }
}
class Speaker extends Device {
play() { return 'playing'; }
fade() { return 'fading'; }
record() { throw new Error('A speaker cannot record.'); } // a forced stub
}
try { new Speaker().record(); } catch (err) { console.log('forced stub:', err.message); }
Output
class Speaker {
play() { return 'playing'; }
fade() { return 'fading'; }
}
class Microphone {
record() { return 'recording'; }
}
class Mixer {
play() { return 'passing signal'; }
fade() { return 'fading'; }
record() { return 'bouncing to disk'; }
}
const can = (thing, ability) => typeof thing[ability] === 'function';
for (const device of [new Speaker(), new Microphone(), new Mixer()]) {
const abilities = ['play', 'record', 'fade'].filter(a => can(device, a));
console.log(device.constructor.name.padEnd(11), abilities.join(', ') || 'none');
}
Output
D — Depend on the interface, not the concrete thing
A class should not reach out and build the other objects it needs. Pass them in, and it becomes testable and swappable at no cost.
class Session {
constructor() {
this.startedAt = Date.now(); // untestable: depends on the real clock
this.log = [];
}
record(event) { this.log.push(`${Date.now() - this.startedAt}ms ${event}`); }
}
const s = new Session();
s.record('opened');
console.log('output changes every run, so it cannot be asserted on');
Output
class Session {
#now;
#startedAt;
#log = [];
constructor({ now = () => Date.now() } = {}) {
this.#now = now;
this.#startedAt = now();
}
record(event) {
this.#log.push(`${this.#now() - this.#startedAt}ms ${event}`);
return this;
}
get entries() { return [...this.#log]; }
}
// A fake clock makes the output exact, and the class never knew the difference.
let tick = 1000;
const fakeClock = () => (tick += 250);
const s = new Session({ now: fakeClock });
s.record('opened').record('booked seat').record('paid');
console.log(s.entries);
// The real clock still works, unchanged.
console.log('with the real clock:', new Session().record('opened').entries.length, 'entry');
Output
Four patterns worth knowing
class AirconBus { constructor(kw) { this.kw = kw; } prepare() { return `test ${this.kw}kW aircon`; } }
class RackBus { constructor(kg) { this.kg = kg; } prepare() { return `check straps for ${this.kg}kg`; } }
class BusFactory {
static from(spec) {
if (spec.kw) return new AirconBus(spec.kw);
if (spec.kg !== undefined) return new RackBus(spec.kg);
throw new SyntaxError('Cannot tell what kind of bus this is: ' + JSON.stringify(spec));
}
}
const yard = [{ kw: 9 }, { kg: 400 }].map(BusFactory.from);
for (const bus of yard) console.log(bus.constructor.name + ':', bus.prepare());
try { BusFactory.from({}); } catch (err) { console.log('refused:', err.message); }
Output
class Emitter {
#listeners = new Map();
on(event, fn) {
if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());
this.#listeners.get(event).add(fn);
return () => this.off(event, fn); // an unsubscribe function
}
off(event, fn) {
this.#listeners.get(event)?.delete(fn);
return this;
}
emit(event, payload) {
for (const fn of this.#listeners.get(event) ?? []) fn(payload);
return this;
}
}
class Trip extends Emitter {
#sold = new Set();
constructor(route, capacity) { super(); this.route = route; this.capacity = capacity; }
book(seat) {
this.#sold.add(seat);
this.emit('booked', { seat, remaining: this.capacity - this.#sold.size });
if (this.#sold.size === this.capacity) this.emit('soldout', { route: this.route });
return this;
}
}
const trip = new Trip('Lagos - Abuja', 2);
const stopLogging = trip.on('booked', d => console.log(`booked ${d.seat}, ${d.remaining} left`));
trip.on('soldout', d => console.log(`FULLY BOOKED: ${d.route}`));
trip.book('1A');
stopLogging(); // unsubscribed; the next booking is quiet
trip.book('1B');
Output
class Basket {
#lines = [];
#pricing;
constructor(pricing) { this.#pricing = pricing; }
add(label, naira) { this.#lines.push({ label, naira }); return this; }
using(pricing) { this.#pricing = pricing; return this; }
get total() { return this.#pricing.total(this.#lines); }
}
const standard = { total: (lines) => lines.reduce((n, l) => n + l.naira, 0) };
const withFee = { total: (lines) => standard.total(lines) + (lines.length ? 200 : 0) };
const memberRate = { total: (lines) => Math.round(standard.total(lines) * 0.9) };
const basket = new Basket(standard).add('1A', 12000).add('1B', 12000);
for (const [name, strategy] of [['standard', standard], ['with fee', withFee], ['member', memberRate]]) {
console.log(name.padEnd(10), '₦' + basket.using(strategy).total);
}
Output
// Two libraries you do not control, with different shapes.
const legacyStore = {
put(key, json) { this._d = this._d || {}; this._d[key] = json; },
fetch(key) { return (this._d || {})[key] ?? null; },
};
const modernStore = new Map();
// One interface your code is written against.
class LegacyAdapter {
save(key, value) { legacyStore.put(key, JSON.stringify(value)); return this; }
load(key) { const j = legacyStore.fetch(key); return j === null ? null : JSON.parse(j); }
}
class MapAdapter {
save(key, value) { modernStore.set(key, structuredClone(value)); return this; }
load(key) { return modernStore.get(key) ?? null; }
}
function roundTrip(store) {
store.save('BK-1', { seats: ['D11', 'D12'] });
return store.load('BK-1');
}
console.log('legacy:', roundTrip(new LegacyAdapter()));
console.log('modern:', roundTrip(new MapAdapter()));
console.log('The caller cannot tell, which is the point.');
Output
Where to stop
Every technique here has a cost in indirection: one more file to open, one more hop to follow. The honest rule is to add abstraction when you have felt the pain twice, not the first time you can imagine it. Two similar functions are fine. Five are a pattern.
Your turn: name the smell
Three snippets, each violating exactly one principle from this lesson. Say which, then fix it. All three are real shapes you will meet in working code.
Output
Recap
- Single responsibility: one reason to change per class. Pricing, presentation and persistence are three reasons.
- Open/closed: adding a case should mean adding code, not editing working code. A growing
switchis the warning. - Substitutability: a subclass must work wherever its parent works. When it cannot, the parent promised too much.
- Small interfaces and injected dependencies are what make a class testable, and testability is the cheapest proxy for good design.
- Factory, Observer, Strategy and Adapter cover most of what you will need; reach for a pattern after the second repetition, not the first.