Skip to content
Back

SOLID as Change Isolation

Computer Science

Five axes of “what should not break when X changes” — modules and types, not class diagrams

SOLID is not five object-oriented programming (OOP) commandments. The name is five principles: Single Responsibility, Open-Closed, Liskov Substitution, Interface Segregation, Dependency Inversion. It is five axes of what should not break when X changes. In this stack the unit is a module and a type, not a class.

A class earns a keep when instanceof or a mutable lifetime is required — TypeScript Classes and Runtime Identity. A throw that vanishes from the signature is TypeScript Error Handling. This note is the change.



Pattern Map

LetterChange that must not cascadeKeepFailure
SRP — Single ResponsibilityWho asks for the changeOne actor per moduleGod notify, or “does one thing” micro-files
OCP — Open-ClosedA new channelNew file + register at the rootGrowing if (channel) in the dispatcher
LSP — Liskov SubstitutionA substitute delivererSame success / failure / idempotencySilent no-operation (Noop) that always reports ok
ISP — Interface SegregationA client that only emailsA small DeliverFat NotificationChannel
DIP — Dependency InversionThe vendor SDK (software development kit)Depend on Deliver; compose at the rootimport { Resend } inside notify

The spine is one notifier. Welcome mail, password reset, later Slack. Each letter is a different cut through that module.



1. One function, three reasons

Signup. You send a welcome email. The first draft looks fine.


Failure — first draft: copy, send, and vendor in one function
import { Resend } from "resend"

const resend = new Resend(process.env.RESEND_API_KEY)

export async function notify(event: {
  user: { email: string; name: string }
  type: "welcome" | "password-reset"
}) {
  const subject =
    event.type === "welcome"
      ? `Welcome, ${event.user.name}`
      : "Reset your password"
  const html =
    event.type === "welcome"
      ? `<p>Hi ${event.user.name}</p>`
      : `<p>Click to reset</p>`

  await resend.emails.send({
    from: "hello@example.com",
    to: event.user.email,
    subject,
    html,
  })
}

Product wants Slack. Marketing wants the greeting. Infra rotates the application programming interface (API) key. Three actors. One function. Edit copy, you risk the send. Add a channel, you touch Resend. Rotate the key, you open the file that owns the prose.


Failure: treating “it sends the email” as one reason to change because it is one function.


Keep — one actor per module
export type Notification = {
  to: string
  subject: string
  body: string
}

export function formatNotification(event: SignupEvent): Notification {
  if (event.type === "welcome") {
    return {
      to: event.user.email,
      subject: `Welcome, ${event.user.name}`,
      body: `<p>Hi ${event.user.name}</p>`,
    }
  }

  return {
    to: event.user.email,
    subject: "Reset your password",
    body: "<p>Click to reset</p>",
  }
}

export async function deliverEmail(
  message: Notification,
): Promise<DeliverResult> {
  const sent = await resend.emails.send({
    from: "hello@example.com",
    to: message.to,
    subject: message.subject,
    html: message.body,
  })

  return sent.error
    ? { ok: false, error: sent.error.message }
    : { ok: true }
}

export async function notify(event: SignupEvent): Promise<DeliverResult> {
  return deliverEmail(formatNotification(event))
}

Marketing owns formatNotification. Infra owns deliverEmail. notify is the use case: it does not own copy and it does not own the vendor.



2. SRP — who asks

The Single Responsibility Principle (SRP): a module has one actor, one reason to change. Not “one thing.” “Does one thing” produces getSubject.ts and getHtml.ts that still change together when marketing rewrites the greeting. That is not SRP. That is a folder.


Failure — split by line count, not by actor
export function getSubject(event: SignupEvent): string {
  return event.type === "welcome"
    ? `Welcome, ${event.user.name}`
    : "Reset your password"
}

export function getHtml(event: SignupEvent): string {
  return event.type === "welcome"
    ? `<p>Hi ${event.user.name}</p>`
    : "<p>Click to reset</p>"
}

export async function notify(event: SignupEvent): Promise<DeliverResult> {
  return deliverEmail({
    to: event.user.email,
    subject: getSubject(event),
    body: getHtml(event),
  })
}

Keep — split by who asks, not by how many lines
export function formatNotification(event: SignupEvent): Notification {
  if (event.type === "welcome") {
    return {
      to: event.user.email,
      subject: `Welcome, ${event.user.name}`,
      body: `<p>Hi ${event.user.name}</p>`,
    }
  }

  return {
    to: event.user.email,
    subject: "Reset your password",
    body: "<p>Click to reset</p>",
  }
}

export async function notify(event: SignupEvent): Promise<DeliverResult> {
  return deliverEmail(formatNotification(event))
}

The split is who will ask, not how many lines each file has.


Failure: splitting a function because it has two ifs, or leaving copy and the SDK in one module because they run in one call.



3. OCP — a new channel

The Open-Closed Principle (OCP): open for extension, closed for modification. In this stack that is not a subclass. It is a port plus a register. Adding Slack is a new file and a line at the composition root. notify does not grow.


Failure — dispatcher grows with every channel
export async function notify(
  event: SignupEvent,
  channel: "email" | "webhook" | "slack",
): Promise<DeliverResult> {
  const message = formatNotification(event)

  if (channel === "email") return deliverEmail(message)
  if (channel === "webhook") return deliverWebhook(message)
  return deliverSlack(message)
}

Keep — dispatcher closed for modification
export type DeliverResult = { ok: true } | { ok: false; error: string }

export type Deliver = (message: Notification) => Promise<DeliverResult>

export async function notify(
  event: SignupEvent,
  deliverers: readonly Deliver[],
): Promise<DeliverResult[]> {
  const message = formatNotification(event)
  return Promise.all(deliverers.map((deliver) => deliver(message)))
}

Keep — register channels at the composition root
// composition root — a route, an entry module, a test harness
const deliverers: Deliver[] = [deliverEmail, deliverWebhook, deliverInApp]

deliverSlack.ts implements Deliver. Push it onto deliverers. The dispatcher never names Slack. The dispatcher never names email either — that is the next letter.


A discriminated union plus an exhaustive switch is honest when the set is closed — the three locales in this repo, the two event types above. The compiler forces every call site to update. That is not classical OCP. It is the right TypeScript when you do not want a plugin. OCP is for an open set: channels you will keep adding without editing the dispatcher.


Failure: growing if (channel) inside notify each time product asks for Slack, SMS (text message), push.



4. LSP — the Deliver contract

The Liskov Substitution Principle (LSP): there is no Bird / Penguin. Substitutability still exists. Anything you pass as Deliver must honor the same outcomes: a result the caller can branch on, the same idempotency story, no extra throws.


Failure — silent Noop breaks the Deliver contract
export const deliverNoop: Deliver = async () => ({ ok: true })

Production deliverEmail returns { ok: false } on a 429. Callers retry. A no-operation (Noop) that always reports ok means retries never run and the operator thinks it sent. The type checks. The contract does not.


Keep — recording fake honors the Deliver contract
export function createRecordingDeliverer(sent: Notification[]): Deliver {
  return async (message) => {
    sent.push(message)
    return { ok: true }
  }
}

// test
const sent: Notification[] = []
await notify(event, [createRecordingDeliverer(sent)])
expect(sent).toHaveLength(1)

Failure — throw when the type says Result
export const deliverThrows: Deliver = async () => {
  throw new Error("network down")
}

A fake that records messages and returns the same DeliverResult is substitutable. A fake that throws when the type says Result is not — that half is TypeScript Error Handling.


Idempotency is part of the contract. If deliverEmail is safe to call twice with the same message, deliverSlack must be too, or the dispatcher cannot retry one without doubling the other.


Failure: a silent Noop, or a channel that throws, when every other Deliver returns a result.



5. ISP — a small port

The Interface Segregation Principle (ISP): clients must not depend on methods they do not use. In this stack that is arguments and fields, not a class with empty stubs.


Failure — fat port forces stubs for unused methods
type NotificationChannel = {
  sendEmail: (to: string, html: string) => Promise<DeliverResult>
  sendPush: (deviceToken: string, title: string) => Promise<DeliverResult>
  sendWebhook: (url: string, body: unknown) => Promise<DeliverResult>
}

export const emailChannel: NotificationChannel = {
  sendEmail: (to, html) => deliverEmail({ to, subject: "", body: html }),
  sendPush: async () => ({ ok: false, error: "not supported" }),
  sendWebhook: async () => ({ ok: false, error: "not supported" }),
}

Email is forced to pretend it can push. Push is forced to exist so email can compile. Add deviceToken to Notification so one Deliver can serve everyone, and format now owns a field marketing never asked for.


Failure — one message type stuffed for every channel
export type Notification = {
  to: string
  subject: string
  body: string
  deviceToken?: string
}

export type Deliver = (message: Notification) => Promise<DeliverResult>

Keep — Deliver stays small; push is a different port
export type Notification = {
  to: string
  subject: string
  body: string
}

export type PushMessage = {
  deviceToken: string
  title: string
  body: string
}

type DeliverEmail = (message: Notification) => Promise<DeliverResult>
type DeliverPush = (message: PushMessage) => Promise<DeliverResult>

Failure: a fat NotificationChannel, or stuffing deviceToken onto Notification so one function signature can lie for every channel.



6. DIP — compose at the root

The Dependency Inversion Principle (DIP): depend on abstractions, not concretes. Dependency injection — passing the dependency in — is how you do it here: pass a Deliver, not a Resend. There is no container. The composition root imports the SDK.


OCP said adding Slack does not edit notify. DIP says notify never imported Resend, even when email was the only channel.


Failure — use case imports the vendor SDK
import { Resend } from "resend"

export async function notify(event: SignupEvent): Promise<DeliverResult> {
  const message = formatNotification(event)
  const resend = new Resend(process.env.RESEND_API_KEY)

  const sent = await resend.emails.send({
    from: "hello@example.com",
    to: message.to,
    subject: message.subject,
    html: message.body,
  })

  return sent.error
    ? { ok: false, error: sent.error.message }
    : { ok: true }
}

Keep — use case depends on Deliver, not Resend
export async function notify(
  event: SignupEvent,
  deliver: Deliver,
): Promise<DeliverResult> {
  return deliver(formatNotification(event))
}

Keep — concrete SDK stays in the adapter factory
import { Resend } from "resend"

export function createEmailDeliverer(resend: Resend): Deliver {
  return async (message) => {
    const sent = await resend.emails.send({
      from: "hello@example.com",
      to: message.to,
      subject: message.subject,
      html: message.body,
    })

    return sent.error
      ? { ok: false, error: sent.error.message }
      : { ok: true }
  }
}

Keep — composition root wires concretes to the use case
// composition root
const resend = new Resend(process.env.RESEND_API_KEY)

const deliverers: Deliver[] = [
  createEmailDeliverer(resend),
  deliverWebhook,
  deliverInApp,
]

await notify(event, deliverers)

notify.ts imports Deliver and formatNotification. Tests pass a recording fake. The route module is allowed to know Resend. The domain module is not.


Failure: import { Resend } in the module that decides whether to notify. The vendor change then lands in the use case.



7. When SOLID is the wrong frame

Ports are a second caller. A second channel. A second actor. Before that, the first draft is the keep.


  • A 40-line script with one channel and one caller: send the email. Deliver is a name, not an isolation.
  • A module that will not change — slugify, a date formatter — already has one actor. Splitting it is “does one thing.”
  • A closed set you want the compiler to exhaust: a union and a switch, not a register. OCP is for the open set.
  • DIP before a second implementation: you wrap Resend in Deliver and still only have Resend. Wait for the fake, or for Slack.

The letters are cheap to recite and expensive to invent early. Name the change that must not cascade. If there is not one yet, do not cut.


Failure: extracting a port for a module with one caller, then maintaining the indirection as if it had isolated something.



Recap Q&A