Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 

Repository files navigation

Software Design Playbook

License: MIT Web Reactions

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.


Table of Contents


Companion playbooks

These repositories form one playbook suite:


Philosophy

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.


The defaults I'd reach for first

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.


Separation of concerns

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.

The one-sentence test

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.

Module boundaries and dependency direction

A module is a promise: this is my public surface, everything else is mine to change.

  • the index.ts of 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 and cohesion

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.


Pragmatic OOP

Two bad instincts dominate: build a hierarchy for everything, or swear off classes entirely. Both are cargo cults.

When a class earns its place

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 Order that refuses to ship unpaid, a Money that 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.

Composition over inheritance

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.


Functions vs classes in TypeScript

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 taking deps explicitly;
  • 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.


SOLID without the cargo cult

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.


Side effects at the edges

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.


Naming

Names are the cheapest design tool and the most ignored one.

  • domain nouns for valuesinvoice, remainingQuota, shippingRate; not data, result, item, obj;
  • precise verbs for functionsreserveStock, expireSessions, applyDiscount; not handle, process, manage, doWork;
  • no dumping groundsutils.ts, helpers.ts, and XxxManager are 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.

Refactoring triggers

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.

When to leave it alone

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

Things to avoid

  • Speculative abstraction — flexibility for requirements nobody has; it will be wrong in exactly the dimension the real requirement needs.
  • Interface-for-everythingIFoo with one Foo, forever; indirection sold as decoupling.
  • God objects — the class every feature imports and every incident implicates.
  • Anemic wrappers over wrappersOrderServiceWrapper calling OrderServiceImpl calling OrderRepositoryAdapter, 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.

Worth reading


License

MIT is a sensible default for a repository like this, but choose the license that fits how you want others to reuse the material.

About

Practical guide to software design in TypeScript: separation of concerns, composition, module boundaries, and OOP without the cargo cult.

Topics

Resources

Stars

Watchers

Forks

Contributors