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
| Letter | Change that must not cascade | Keep | Failure |
|---|---|---|---|
| SRP — Single Responsibility | Who asks for the change | One actor per module | God notify, or “does one thing” micro-files |
| OCP — Open-Closed | A new channel | New file + register at the root | Growing if (channel) in the dispatcher |
| LSP — Liskov Substitution | A substitute deliverer | Same success / failure / idempotency | Silent no-operation (Noop) that always reports ok |
| ISP — Interface Segregation | A client that only emails | A small Deliver | Fat NotificationChannel |
| DIP — Dependency Inversion | The vendor SDK (software development kit) | Depend on Deliver; compose at the root | import { 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.
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.
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.
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),
})
}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.
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)
}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)))
}// 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.
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.
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)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.
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.
export type Notification = {
to: string
subject: string
body: string
deviceToken?: string
}
export type Deliver = (message: Notification) => Promise<DeliverResult>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.
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 }
}export async function notify(
event: SignupEvent,
deliver: Deliver,
): Promise<DeliverResult> {
return deliver(formatNotification(event))
}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 }
}
}// 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.
Deliveris 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
Deliverand 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.