Practical guide to software design in TypeScript: separation of concerns, composition, module boundaries, and OOP without the cargo cult.
If I were designing a codebase today, what would I care about before I cared about patterns, frameworks, or diagrams?
This playbook is about the design thinking underneath structure:
- separation of concerns that survives contact with deadlines;
- module boundaries and dependency direction;
- classes that earn their place and functions that don't apologize;
- SOLID read honestly, not recited;
- side effects pushed to the edges;
- knowing when to refactor and when to walk away.
This is the principles-level parent of the Frontend Architecture Playbook and the Backend Architecture Playbook: they define structure; this one defines the design thinking underneath.
- Software Design Playbook
- Table of Contents
- Companion playbooks
- Philosophy
- The defaults I'd reach for first
- Separation of concerns
- Module boundaries and dependency direction
- Coupling and cohesion
- Pragmatic OOP
- Functions vs classes in TypeScript
- SOLID without the cargo cult
- Side effects at the edges
- Naming
- Refactoring triggers
- Things to avoid
- Worth reading
- License
These repositories form one playbook suite:
- AI-Assisted Engineering Playbook — agent workflows, guardrails, and quality control for AI-heavy teams
- API Design Playbook — versioning, pagination, idempotency, error contracts, and webhooks
- Auth & Identity Playbook — sessions, tokens, OAuth, and identity boundaries across the stack
- Backend Architecture Playbook — APIs, boundaries, OpenAPI, persistence, and errors
- Best of JavaScript — curated JS/TS tooling and stack defaults
- Caching Playbook — HTTP, CDN, and application caches; consistency and invalidation
- Code Review Playbook — PR quality, ownership, and review culture
- DevOps Delivery Playbook — CI/CD, environments, rollout safety, and observability
- Engineering Lead Playbook — standards, RFCs, and technical leadership habits
- Frontend Architecture Playbook — React structure, performance, and consuming API contracts
- Git Collaboration Playbook — branching, stacked PRs, merge queues, and CI collaboration at scale
- Marketing and SEO Playbook — growth, SEO, experimentation, and marketing surfaces
- Messaging & Async Playbook — queues, events, outbox, idempotent consumers, and retries
- Monorepo Architecture Playbook — workspaces, package boundaries, and shared code at scale
- Node.js Runtime & Performance Playbook — event loop, streams, memory, and production Node performance
- Observability Playbook — logs, traces, metrics, SLOs, and production visibility
- React Cross-Platform Playbook — shared React UI and logic across web and native with TypeScript
- Software Design Playbook — separation of concerns, composition, and module boundaries
- State Management Playbook — client state architecture, MobX, and choosing a state layer
- Styling Architecture Playbook — type-safe styling, design tokens, and theming at scale
- Testing Strategy Playbook — unit, integration, contract, E2E, and CI-friendly test layers
Good design is not about elegance, diagrams, or pattern vocabulary.
It is about:
- managing the cost of change — the design that makes next month's change cheap is the good one;
- choosing your coupling instead of discovering it during an incident;
- boring code that a tired reviewer understands on the first pass;
- deleting the abstraction that stopped paying rent.
The enemy is not complexity in general. The enemy is coupling you didn't choose.
If I were starting a TypeScript codebase today, I would start with:
- Core / edges split: pure decision logic in the middle, IO and side effects at the edges
- Functions first: plain functions and plain data; a class only when state and behavior truly cohere
- Composition over inheritance: wrap and delegate; extend only for a true is-a with a stable base
- Dependency direction: everything points inward — domain code imports nothing from HTTP, DB, or UI layers
- Explicit boundaries: every boundary has a typed public surface; crossing means going through the types
- One job per module: if I can't say a module's job in one sentence, it's two modules
None of these require a framework. All of them require saying no a few times a week.
A "concern" is not a file type. Putting all controllers in one folder and all models in another separates categories, not concerns.
A concern is a reason to change: two pieces of code share a concern when the same decision forces both to move. Hence feature-oriented structure over layer-oriented: pricing rules, pricing types, and pricing tests change together, so they live together. Layers still exist — inside a feature, not as the top-level religion.
The three concerns worth physically separating:
- IO — reading and writing the outside world: HTTP, DB, filesystem, clock, random
- Decisions — the rules: given these inputs, what should happen
- Presentation — shaping results for a consumer: response bodies, view models, UI
Mix the three in one function and every test needs a server and every refactor becomes a hostage negotiation. How this plays out for client state is covered in depth in the State Management Playbook.
Explain the module's job in one sentence, without "and".
- "Calculates shipping cost for a cart." — fine.
- "Calculates shipping cost and caches rates and logs anomalies." — three modules wearing one trench coat.
A module is a promise: this is my public surface, everything else is mine to change.
- the
index.tsof a module is its contract — what it exports is supported, what it doesn't is off-limits; - aim for deep modules: a small interface hiding a lot of implementation — Ousterhout's test;
- no reaching into internals —
import { repo } from "../orders/internal"is a violation even when the compiler allows it; - dependency direction points inward:
domain ← application ← adapters; the domain never imports an adapter; - a shared type crossing a boundary belongs to the boundary, not to whichever side wrote it first.
Cyclic dependencies are an emergency, not a style issue. A cycle means two modules are secretly one, and every change to either now requires understanding both. Break it the day you find it — extract the shared piece, invert one edge, or merge the modules honestly.
Coupling is not a dirty word — code that works together must know things about each other. The question is which kind, and whether you picked it.
Kinds worth naming in review:
- Data-shape coupling — both sides depend on the same type. The cheapest kind — TypeScript makes it explicit. Prefer it.
- Temporal coupling — B only works if A ran first, and nothing in the types says so. Encode the ordering (make B take A's output) or collapse the steps.
- Control coupling — A passes a flag telling B how to behave. B is now two functions with one name. Split it.
Cohesion is the other side of the coin: things that change together live together. High cohesion is not "small files" — it's a single product decision landing in a single place. If renaming a domain concept touches nine directories, cohesion is low no matter how tidy the folders look.
Two bad instincts dominate: build a hierarchy for everything, or swear off classes entirely. Both are cargo cults.
A class earns its place when invariants and behavior sit over private state — rules that must hold, enforced in one place:
- domain entities with real invariants — an
Orderthat refuses to ship unpaid, aMoneythat refuses to add mismatched currencies; - stateful clients — a connection pool, a rate-limited API client, a cache with eviction;
- anything where "construct invalid" must be impossible — private constructor, static factory, invariant checked once.
If every field could be public and nothing would break, there is no invariant — it's a plain object wearing a costume. Use a type and functions.
Interfaces live at boundaries, not everywhere: the port your domain exposes to adapters, the seam you substitute in tests. An interface with one implementation and no test double is ceremony.
Default shape: wrap and delegate.
interface Channel {
send(message: OutboundMessage): Promise<void>;
}
class RetryingChannel implements Channel {
constructor(private readonly inner: Channel, private readonly maxAttempts: number) {}
async send(message: OutboundMessage): Promise<void> {
// retry loop around this.inner.send — no base class involved
}
}RetryingChannel works on every Channel ever written, including ones that don't exist yet. The inheritance version — class RetryingEmailChannel extends EmailChannel — couples retry logic to one transport and its base's internals forever.
Inheritance is acceptable for a true is-a with a stable base: Error subclasses, an abstract base you own that hasn't changed in a year. It is a tool for closed hierarchies, not for reuse.
TypeScript modules are already namespaces with privacy. A file that exports three functions and keeps two helpers private does everything a static class does, with less syntax.
My defaults:
- services are plain data + functions — a
createOrderService(deps)returning an object of functions, or just exported functions takingdepsexplicitly; - closures over fields when state is configuration captured once — a closure can't be mutated from outside;
- fields over closures when state genuinely evolves and has invariants — that's the class case above;
- no class as a bag of static methods — that's a module with extra steps.
The question is never ideology. Does this thing have private state with rules? Yes → class. No → functions and a type.
Each letter is a decent heuristic that dies when treated as law.
S — Single Responsibility. Usefully: one reason to change per module — one audience whose decisions move it. Abused as "one function per class", which shatters cohesive logic into confetti. The unit is the reason to change, not the line count.
O — Open/Closed. Usefully: at stable, published boundaries, prefer adding an implementation over editing every consumer. Abused as plugin systems everywhere — strategy registries in code with exactly one behavior. Inside your own codebase, editing the code is fine.
L — Liskov Substitution. The honest one. A subtype that can't keep its base's promises — throws where the base didn't, weakens a guarantee — is a lie that gets debugged at 3am. Mostly an argument for less inheritance, not better inheritance.
I — Interface Segregation. Usefully: don't force a consumer to depend on methods it never calls. TypeScript makes this nearly free — declare the two-method type the consumer needs and let structural typing do the rest. Abused as pre-splitting every interface into single-method shards nobody asked for.
D — Dependency Inversion. Usefully: the domain defines the port, the adapter implements it — dependency direction points inward. Abused as "an interface for every class", where UserService ships with IUserService and one implementation, forever. Invert at boundaries you actually substitute; everywhere else, just import the thing.
The highest-leverage design habit: keep the decision pure, push the effect outward.
- the core takes values and returns values — decisions, calculations, validations, transitions;
- the shell does the reading and writing — fetch the order, call the core, persist the verdict;
- the core never imports a client, a repository, or a clock; time and randomness come in as arguments.
Why this pays:
- pure decisions test as plain function calls — no mocks, no containers; see the Testing Strategy Playbook for how cheap testing gets when this holds;
- effects concentrate in a thin shell that integration tests cover a few times instead of everywhere;
- the core tells you what the system decides; the shell tells you what it touches — two questions, two places.
Most functions don't need to be pure. The ones that make decisions do.
Names are the cheapest design tool and the most ignored one.
- domain nouns for values —
invoice,remainingQuota,shippingRate; notdata,result,item,obj; - precise verbs for functions —
reserveStock,expireSessions,applyDiscount; nothandle,process,manage,doWork; - no dumping grounds —
utils.ts,helpers.ts, andXxxManagerare where names go to die; a file that can't be named after its job doesn't know its job; - a function that needs a comment to say what it does usually needs a better name instead;
- a name that requires "and" —
validateAndSave— is the one-sentence test failing at function scale.
Restructure when the code tells you to, not when the mood strikes:
- the third duplicate — two similar blocks are a coincidence; the third proves a concept exists and wants a name. Extract on three, not two;
- a leaking boundary — callers reaching past a module's surface, or a "private" type showing up in four other features;
- test pain — testing a unit requires mocking half the app; the dependencies are wrong, fix the design, not the mocks;
- shotgun changes — one product decision touches many files every time; cohesion is asking to be restored;
- fear — a module people quietly avoid changing has already failed as a design.
- code that is ugly but stable, cohesive, and untouched for a year — ugliness is not a trigger;
- duplication across modules that change for different reasons — see DRY, below;
- any refactor without tests around the behavior first — that's not refactoring, that's gambling with extra steps.
- Speculative abstraction — flexibility for requirements nobody has; it will be wrong in exactly the dimension the real requirement needs.
- Interface-for-everything —
IFoowith oneFoo, forever; indirection sold as decoupling. - God objects — the class every feature imports and every incident implicates.
- Anemic wrappers over wrappers —
OrderServiceWrappercallingOrderServiceImplcallingOrderRepositoryAdapter, each layer adding a name and no decision. - DRY applied to coincidences — merging two functions because their lines match today, then feeding flags to the survivor when they diverge tomorrow. Duplication is cheaper than the wrong abstraction.
- Patterns as goals — "we should use the visitor pattern somewhere" is a solution shopping for a problem; patterns are vocabulary for designs you already need.
- A Philosophy of Software Design — Ousterhout; deep modules, shallow modules, and complexity as the enemy
- Refactoring catalog — Fowler's named, safe restructuring moves
- Yagni — the case against building for imagined futures
- Anemic Domain Model — what it costs when entities are just data bags
- Tell Don't Ask — behavior next to the state it governs
MIT is a sensible default for a repository like this, but choose the license that fits how you want others to reuse the material.