Jerry Orta
← Concepts

Domain-Driven Design

  • architecture
  • ddd
  • nx

Sample code — the actual source files this note examines, in the public ngx-experiments repo:

Domain-Driven Design asks the business domain — not the framework, the database, or the UI — to drive the software model. On a large front-end that principle carries a concrete architectural consequence: code organizes around what the product does, and a single domain owns its models, its state, its presentation, and even its palette as one self-contained unit. Everything below is grounded in one real, public domain — a personal-finance ledger (the ldg- prefix, the @nge/ledger-* libraries) built on a shared foundation — because an abstraction is easiest to trust when each one points at a file.

Bounded contexts

A bounded context is the boundary within which a model and its language stay consistent. In this workspace that boundary is drawn as a domain, and a domain is a set of libraries rather than a folder of loosely related files. The ledger owns six:

libs/ledger/
  ├── models          # the domain's types — Account, Transaction, Budget, Category, Bill
  ├── utils           # its pure functions — spendingByCategory, cashflow, netWorthSeries
  ├── store           # its state
  ├── ui              # its smart, container components
  ├── design-library  # its presentational components (ldg- prefix)
  └── themes          # its tokens (--ldg-*)

The boundary is stated three times over: the library set that encloses the domain, the @nge/ledger-* import path that names it, and the ldg- prefix that marks everything belonging to it. Two contexts may use one word for two things — a balance under a budgeting model is not the balance an accounting-reconciliation model would compute — and that divergence stays safe precisely because each context is sealed behind its own seam. Nothing outside the ledger reaches past that boundary to bind itself to the domain's internals; a consumer depends on the published import path, never on a file buried inside it.

Ubiquitous language

The names in the code should be the names the domain speaks, and the discipline is to let that language reach everywhere — into the types, through every function that reads them, and, less obviously, into the design tokens.

The clearest instance is how the ledger models money. A Transaction carries no type field at all:

export interface Transaction {
  accountId: string;
  /** Signed integer minor units (cents). Negative = outflow, positive = inflow. */
  amountCents: number;
  categoryId: string;
  // …
}

The sign of amountCents is the type: a negative amount is an expense, a positive amount is income, and every aggregation in @nge/ledger-utilsspendingByCategory, cashflow, budgetVsActual, netWorthSeries — keys off that one convention rather than inventing its own. Money is always an integer count of minor units (cents), never a float and never a formatted string, and it is formatted only at the presentation edge. A single sentence of domain language — the sign is the type, money is cents — therefore propagates unbroken from the model through every calculation that touches it.

That language does not stop at TypeScript. A domain's vocabulary also becomes color. The ledger themes define what money means as a token — --ldg-money-positive for a credit, --ldg-money-negative for a debit — held deliberately apart from the generic --ldg-error and --ldg-warning states, because a debit is not a failed form field. The token layer even records that a spending-category hue, once shipped, is never reassigned, so no historical chart silently relabels itself. The full mechanics of a domain encoding its language as tokens are the subject of the design-library note under "Tokens speak the domain's language"; the point here is narrower — the language is one thing, expressed in two materials, and it says the same thing in both.

Self-contained domains as a direction

If a bounded context is the idea, a self-contained domain is the direction the workspace takes to honor it. The working rule is to prefer a domain's own libraries and to reserve the shared foundation for what is genuinely generic — a primitive button, a token contract, a charting engine no single domain should own.

Self-contained does not mean sealed off. A domain builds on the shared foundation freely — importing its primitives, inheriting its tokens — because the dependency runs in one safe direction: a domain draws downward on shared infrastructure, never sideways into a sibling domain, and the shared foundation never reaches back up. What a boundary forbids is coupling between sibling domains, not use of the shared floor they all stand on.

The ledger shows both sides of that balance. Its own presentational pieces — an account card, a budget card — live in libs/ledger/design-library under the ldg- prefix, and they compose the generic primitives such as dlc-button and dlc-card from the shared @nge/ui-design-library instead of reimplementing them. Its tokens make the same move: most --ldg-* values are pure passthrough aliases of the shared --dlc-* contract.

@mixin ldg-aliases() {
  --ldg-surface: var(--dlc-surface, #f8f9fa);
  --ldg-primary: var(--dlc-primary, #0f2b3c);
  // …
}

Custom-property indirection resolves each alias against whichever shared persona is active on an ancestor, so the domain inherits the shared look and writes fresh values only for what is truly its own — the money and category tokens above. A domain defines what only it can define, and it inherits everything else.

That preference for domain-owned code is also why placement is a deliberate act rather than a reflex. Before a type, a utility, or a component is written, the first question is whether the domain or the shared foundation already provides it, and the second is where a genuinely new piece belongs: a pure model in models, a stateless function in utils, a presentational component in the domain's design-library. Searching before writing is not a tidiness rule; it is the practical expression of a context boundary. Duplication is how a boundary quietly erodes — one convenient copy at a time — until two contexts that were meant to stay separate share a model that neither of them fully owns.

Why it holds together

Three ideas, one shape:

  • A bounded context is a domain — a set of libraries (models, utils, store, ui, design-library, themes) sealed behind an import path and a prefix, so two contexts can hold different meanings for one word without collision.
  • A ubiquitous language reaches everywhere — the domain's vocabulary lives in its type names, travels through every function that reads them, and surfaces again in its tokens; the sign is the type, money is cents holds from the model all the way to the palette.
  • Self-contained domains are the direction — a domain owns its libraries and builds on the shared foundation for the generic, defining only its deltas and inheriting the rest, with placement settled by searching before writing.

The dividend is a dependency graph that stays legible as the workspace grows: each domain is a unit one can reason about whole, and the lines between domains are written down as import paths rather than left to discipline. The same instinct — a self-contained unit that owns everything it needs — reappears one layer out, in how I arrange the repository's instruction files for an AI agent, and one layer in, in how a themeable component library and a charts library each keep their structure and their values on opposite sides of a single contract. A domain is that same discipline turned on the product itself.