Emmanuel Akinfulubi

Lesson 15 of 1518 min read

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.

Before: one class, three jobs

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


      

After: three small classes, each with one reason to change

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.

Before: every new discount edits this function

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


      

After: a new discount is a new object

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.

Square is not a Rectangle, if a Rectangle can be resized

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


      

The fix: immutable shapes, so there is nothing to violate

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.

Before: everything must implement everything

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


      

After: separate capabilities, checked before use

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.

Before: the clock and the store are welded in

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


      

After: inject the dependencies

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

Factory: hide which class gets built

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


      

Observer: tell whoever is interested, without knowing who they are

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


      

Strategy: swap an algorithm without touching the caller

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


      

Adapter: one interface over two incompatible things

// 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.

Editable. Name it, fix it, then run it.

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 switch is 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.

Saved in this browser only.

Newsletter

New writing, when it lands.

Occasional notes on shipping software with AI agents. No schedule, no filler, unsubscribe in one click.