Skip to content

About

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

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

5 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 

Repository files navigation

Software Design Playbook

License: MIT Emojery

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.

It helps to know it is not a theory. The five principles were written separately by Robert C. Martin over the 1990s; Michael Feathers noticed around 2004 that they could be reordered into the word SOLID. The acronym is a mnemonic applied afterwards, not a system — which is why the letters have no shared scope, no ordering, and nothing to say about when they conflict.

Martin later restated the first one, and the restatement is the version worth carrying: a module should have one reason to change because it answers to one actor — one person or role whose decisions move it. "One responsibility" invites counting functions; "one stakeholder" is a question about the business, and it is answerable.

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 values — invoice, remainingQuota, shippingRate; not data, result, item, obj;
  • precise verbs for functions — reserveStock, expireSessions, applyDiscount; not handle, process, manage, doWork;
  • no dumping grounds — utils.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.

The triggers above are what Kent Beck named code smells in his chapter of Fowler's Refactoring (1999), and the definition he gave has one word doing all the work: a smell is a hint that something may be wrong, not a verdict. It tells you where to look, and looking is allowed to end in "this is fine".

Tooling has steadily eroded that. A linter that fails a build on a long method has converted a hint into a rule, which is precisely what the "when to leave it alone" list above exists to resist.


Things to avoid

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

0 stars

Watchers

0 watching

Forks

Contributors