diff --git a/docs/architecture/data-flow-explainer.html b/docs/architecture/data-flow-explainer.html index d26f5f5..306e520 100644 --- a/docs/architecture/data-flow-explainer.html +++ b/docs/architecture/data-flow-explainer.html @@ -1123,6 +1123,136 @@ ul.role-jobs > li code { .role-factory { width: 100%; } } +/* ─── Tradeoffs by part ──────────────────────────────────────────────── */ + +.tradeoffs-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 22px; + margin-top: 8px; +} + +.tradeoff-card { + border: 1px solid var(--rule-strong); + background: var(--paper-3); + padding: 26px 30px; + border-radius: 4px; + display: flex; + flex-direction: column; +} + +.tradeoff-card .tag { + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: 10px; + word-break: break-all; +} + +.tradeoff-card h3 { + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 30, "SOFT" 30, "wght" 440; + font-size: 24px; + line-height: 1.1; + letter-spacing: -0.005em; + margin: 0 0 10px; +} + +.tradeoff-card .blurb { + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 14, "SOFT" 40; + font-size: 14px; + line-height: 1.5; + color: var(--ink-2); + margin: 0 0 18px; + font-style: italic; +} + +.tradeoff-card .blurb code { + font-family: "JetBrains Mono", monospace; + font-style: normal; + font-size: 12px; + background: var(--paper-2); + padding: 1px 5px; + border-radius: 2px; +} + +.pc-cols { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 22px; + padding-top: 14px; + border-top: 1px dashed var(--rule); + flex: 1; +} + +.pc-cols h5 { + font-family: "JetBrains Mono", monospace; + font-size: 10px; + letter-spacing: 0.22em; + text-transform: uppercase; + margin: 0 0 10px; + font-weight: 500; +} + +.pc-cols .pros h5 { color: var(--ok); } +.pc-cols .cons h5 { color: var(--accent); } + +.pc-cols ul { + list-style: none; + padding: 0; + margin: 0; + font-family: "Fraunces", serif; + font-variation-settings: "opsz" 14, "SOFT" 40; + font-size: 13.5px; + line-height: 1.5; + color: var(--ink); +} + +.pc-cols li { + position: relative; + padding-left: 18px; + margin-bottom: 8px; + text-wrap: pretty; +} + +.pc-cols .pros li::before { + content: "+"; + position: absolute; + left: 0; + top: 0; + color: var(--ok); + font-family: "JetBrains Mono", monospace; + font-weight: 600; + font-size: 14px; +} + +.pc-cols .cons li::before { + content: "−"; + position: absolute; + left: 0; + top: 0; + color: var(--accent); + font-family: "JetBrains Mono", monospace; + font-weight: 600; + font-size: 14px; +} + +.pc-cols code { + font-family: "JetBrains Mono", monospace; + font-size: 11.5px; + background: var(--paper-2); + padding: 0 4px; + border-radius: 2px; +} + +@media (max-width: 1100px) { + .tradeoffs-grid { grid-template-columns: 1fr; } + .pc-cols { grid-template-columns: 1fr; gap: 14px; } +} + /* ─── Verdict ────────────────────────────────────────────────────────── */ .verdict-block { @@ -1297,12 +1427,13 @@ footer .colophon {
Contents
-
    +
    1. 01Feature anatomy
    2. 02Request flow
    3. 03Dependency injection
    4. 04Mocks, contracts & factories
    5. -
    6. 05The verdict
    7. +
    8. 05Tradeoffs by part
    9. +
    10. 06The verdict
@@ -1768,10 +1899,416 @@ footer .colophon { - -
+ +
§ 05
+
+

Tradeoffs by part.

+

Every layer in the feature anatomy gives you something and costs you something. This is the honest accounting — what each folder or file buys, what it asks in return. Read this when you're deciding whether to add a piece, not all at once.

+
+
+ +
+ +
+
entities/models/
+

Entity Zod schemas + types

+

One file per entity (article.ts, user.ts): a Zod schema and the inferred TypeScript type.

+
+
Pros
    +
  • One source of truth: schema and type from a single declaration via z.infer
  • +
  • Runtime validation available anywhere the schema is imported (output schemas, parser-transforms)
  • +
  • Pure-domain — zero framework knowledge, infinitely portable
  • +
  • Test factories build against this same shape, can't drift
  • +
+
Cons
    +
  • Adds Zod as a feature-level dep where a plain TS interface would suffice
  • +
  • Schema can drift from Payload's actual collection shape — must keep in sync manually
  • +
  • Overkill for entities that never get parsed at runtime
  • +
+
+
+ +
+
entities/errors/
+

Domain error classes

+

One file per error domain (article.ts, auth.ts) plus common.ts for InputParseError.

+
+
Pros
    +
  • Domain errors carry meaning — ArticleNotFoundError beats a generic Error by miles
  • +
  • defineErrorMiddleware matches by instanceof and translates to TRPCError codes
  • +
  • Per-feature ownership — auth's errors don't leak into blog
  • +
+
Cons
    +
  • Every constructor must set this.name (R6) — easy to forget, was the systemic Plan-9 fix-up
  • +
  • InputParseError is duplicated per feature (~6 lines × 5) — by design, but feels redundant
  • +
  • Adding a new error class = update the feature's procedures.ts map too
  • +
+
+
+ +
+
application/repositories/
+

Repository interfaces

+

<x>.repository.interface.ts — TypeScript interface, no implementation, no Zod.

+
+
Pros
    +
  • Use cases depend on the contract, not a concrete class — testable, swappable
  • +
  • Mock and real impl share the interface — TypeScript catches drift at compile time
  • +
  • Plain TypeScript — no extra deps, no runtime cost
  • +
+
Cons
    +
  • One more file per repo (interface + real + mock = 3 minimum)
  • +
  • Method names get written twice (interface + each impl) — refactors touch both
  • +
  • Wider repo signatures vs narrower use-case schemas can feel duplicative
  • +
+
+
+ +
+
application/services/
+

Service interfaces

+

<x>.service.interface.ts — interface for non-data-access boundaries (auth, email, …).

+
+
Pros
    +
  • Same testability win as repos — stub the boundary, inject the stub
  • +
  • Lets stateful behavior (sessions, password hashing) sit behind a clean contract
  • +
  • Use cases don't import auth lib internals — they call methods on an interface
  • +
+
Cons
    +
  • Easy to over-create services for thin one-method wrappers
  • +
  • The "service" name is fuzzy — can become a junk drawer of "anything that's not a repo"
  • +
  • Most features don't need any (only auth has one today)
  • +
+
+
+ +
+
application/use-cases/
+

Factory-function use cases

+

One file per verb-noun (get-articles.use-case.ts): input + output schemas + factory.

+
+
Pros
    +
  • Single source of truth for I/O contracts (R1) — schema lives here, controllers and routers import
  • +
  • xOutputSchema.parse(...) at the end of the body catches malformed repo returns at the layer that owns the contract
  • +
  • Factory function = trivially testable; tests construct mocks and inject directly
  • +
  • One concern per file; clear single responsibility
  • +
+
Cons
    +
  • ~30 lines of file overhead per use case (input + output + types + factory)
  • +
  • Identity output schemas (z.array(articleSchema)) feel ceremonial when they don't add validation
  • +
  • Many files per feature (blog has 3, media has 3, marketing-pages has 2)
  • +
  • .parse() on every call has measurable cost on hot paths (negligible in practice)
  • +
+
+
+ +
+
infrastructure/repositories/<x>.repository.ts
+

Real (Payload-backed) repository

+

Constructor takes SanitizedConfig, methods call getPayload({ config }), map to domain.

+
+
Pros
    +
  • All Payload knowledge lives in one place per repo — easy to swap CMS later
  • +
  • Constructor injection keeps the feature boundary clean (no core-cms dep)
  • +
  • Class names without Payload prefix — DI swaps mock ↔ real cleanly
  • +
+
Cons
    +
  • Per-method getPayload({ config }) is repetitive
  • +
  • toDomain mappers are easy to forget for new fields → silent shape drift
  • +
  • Tests need vi.mock("payload") + Payload stub setup — more ceremony than testing the mock impl
  • +
+
+
+ +
+
infrastructure/repositories/<x>.repository.mock.ts
+

Mock repository (sibling of real)

+

In-memory implementation. The default DI binding; also injected directly in unit tests.

+
+
Pros
    +
  • Dev mode runs without Payload booted — pnpm dev just works
  • +
  • Direct test injection — no DI ceremony for use-case / controller tests
  • +
  • Same interface as real impl; contract suite proves behavioral parity
  • +
  • Fast and deterministic — no setup, no fixtures, no I/O
  • +
+
Cons
    +
  • Without the contract suite, mock can silently diverge from real (e.g. ID-uniqueness assumptions)
  • +
  • Doubles the repo file count (one real + one mock per repo)
  • +
  • Easy to give the mock "extra" behavior the real impl can't match (false confidence)
  • +
+
+
+ +
+
infrastructure/services/
+

Real + mock services

+

Same dual-impl pattern as repositories. Auth has one (real AuthenticationService + mock).

+
+
Pros
    +
  • Lets stateful boundary code (session creation, password hashing) be unit-tested with a fake
  • +
  • Real impl can defer hard parts as NotImplementedError while the mock fully works (auth's session methods do this today)
  • +
+
Cons
    +
  • Adds a directory tree most features don't need
  • +
  • "Service" abstraction can hide what's actually being mocked — repos are clearer
  • +
  • Deferred-real-impl pattern is honest but technical-debt-shaped
  • +
+
+
+ +
+
interface-adapters/controllers/
+

Factory controllers + presenter

+

One file per use case. Receives unknown, safeParses, calls use case, runs presenter.

+
+
Pros
    +
  • Transport-agnostic — same controller works from tRPC, CLI, server actions, cron
  • +
  • Owns input parsing — InputParseError is the controller's responsibility, never the use case's
  • +
  • Co-located function presenter means view-shape transforms live next to the wire
  • +
  • One controller per use case — clear single responsibility, easy to test
  • +
+
Cons
    +
  • Schema runs twice on the tRPC path (procedure .input + controller safeParse) — defense in depth has a cost
  • +
  • Identity presenters feel ceremonial when no transform is needed (R11 always-present rule)
  • +
  • Per-use-case files multiply — blog has 3, marketing-pages has 2
  • +
+
+
+ +
+
di/symbols.ts
+

The address book

+

Plain object of Symbol.for("blog:I…") keys. One per binding the container holds.

+
+
Pros
    +
  • Symbol.for namespacing prevents cross-feature collisions
  • +
  • Type-erased keys let the container index without forcing eager class imports
  • +
  • Const object — every binding has a compile-time-checked key
  • +
+
Cons
    +
  • Adding a use case = update three places: factory, symbols, module binding
  • +
  • Symbols carry no type info at runtime — bind/get must agree on the type parameter (footgun)
  • +
+
+
+ +
+
di/module.ts
+

Default binding registry

+

ContainerModule with all repository, service, use-case, controller bindings.

+
+
Pros
    +
  • Declarative — every binding visible in one block; easy to audit
  • +
  • .toDynamicValue is what makes factory functions work as DI bindings
  • +
  • Loaded once at module construction; nothing runs per-request
  • +
+
Cons
    +
  • Verbose — each .toDynamicValue((ctx) => factoryFn(ctx.container.get(...))) repeats boilerplate
  • +
  • Imports every concrete class + factory in the feature — large surface area in one file
  • +
+
+
+ +
+
di/container.ts
+

The singleton

+

Three lines: reflect-metadata, new Container({ defaultScope: "Singleton" }), load(Module).

+
+
Pros
    +
  • Singleton scope = automatic caching; subsequent .get() calls reuse the closure
  • +
  • One-line file — almost no maintenance
  • +
  • Per-feature container = vertical isolation; no cross-feature DI coupling (ADR-008)
  • +
+
Cons
    +
  • Module-level singleton = global state; tests must unbindAll() + reload to start fresh
  • +
  • import "reflect-metadata" is a side-effect import — easy to forget when scaffolding new files
  • +
+
+
+ +
+
di/bind-production.ts
+

Production binder

+

bindProduction<F>(config) — unbinds the mock, rebinds the real Payload-backed impl.

+
+
Pros
    +
  • Decouples Payload config from the feature package — boundary stays clean
  • +
  • Idempotent (isBound guard) — safe to call multiple times
  • +
  • Only the repo binding swaps; use cases + controllers stay put and pick up the new repo automatically
  • +
+
Cons
    +
  • Easy to forget to call when wiring a new app — silent fallback to mock in production
  • +
  • One per feature × app — boilerplate compounds with feature count
  • +
+
+
+ +
+
di/bind-dev-seed.ts
+

Dev-seed binder

+

bindDevSeed<F>() — unbinds the empty mock, rebinds a populated mock (post-Plan-9).

+
+
Pros
    +
  • Dev mode shows realistic data without Payload running — design review, storybook, offline work all just work
  • +
  • Reuses the same factory the tests use — no separate fixture system
  • +
  • Symmetric with bind-production — one mental model, two binders
  • +
+
Cons
    +
  • Yet another file per feature
  • +
  • Seed data drifts from real Payload shape over time — needs occasional refresh
  • +
  • Fourth place to update when entity schemas change (after schema, factory, repo)
  • +
+
+
+ +
+
integrations/api/procedures.ts
+

Feature-scoped tRPC procedure

+

xProcedure = t.procedure.use(defineErrorMiddleware([...])) — owns the feature's error-to-code map.

+
+
Pros
    +
  • Feature owns its error → TRPCError mapping — no central registry, no core-shared coupling
  • +
  • Adding an error class = one tuple in this file; type system guides you
  • +
  • defineErrorMiddleware in core-shared is plumbing only — boundary stays clean
  • +
+
Cons
    +
  • Adds a fifth file to integrations/api/ — a feature with two procedures has the same overhead as one with ten
  • +
  • The InputParseError → BAD_REQUEST tuple is dormant on the tRPC path (tRPC's own zod parse fires first) — feels theatrical
  • +
+
+
+ +
+
integrations/api/router.ts
+

tRPC router slice

+

One file per feature, composed into core-api's appRouter via the ./api export.

+
+
Pros
    +
  • xProcedure.input(xInputSchema) — schemas imported from the use-case file, never redefined
  • +
  • One slice per feature — easy to add or remove a feature from the API
  • +
  • Container is the only thing the router knows about — controllers are resolved, not imported
  • +
+
Cons
    +
  • Router files grow proportional to procedure count — blog already has three
  • +
  • Routers can't be tested without DI involvement — createCaller goes through the container
  • +
+
+
+ +
+
integrations/cms/
+

Payload collections + globals

+

Collection / global definitions exposed via the ./cms export, composed into core-cms.

+
+
Pros
    +
  • Each collection lives with its feature — no central cms-core grab-bag
  • +
  • Hooks for the feature's domain logic (revalidation, slugify) are co-located
  • +
  • core-cms just composes; features stay independently versionable
  • +
+
Cons
    +
  • Coupling to Payload's API surface — swapping CMS later is non-trivial
  • +
  • Collection schema can drift from the entity Zod schema — two places to keep in sync
  • +
+
+
+ +
+
ui/
+

Frontend public surface

+

ui/index.ts exports query builders + components. Apps import via @repo/<feature>/ui.

+
+
Pros
    +
  • Apps depend on the feature's UI surface explicitly — clear separation from contracts
  • +
  • Query builders sit next to the data they query — refactoring is local
  • +
  • Components for feature-specific UI (e.g. ArticleCard) live with the feature, not in core-ui
  • +
+
Cons
    +
  • Features without UI today (auth, media) still need a placeholder export {}
  • +
  • React Query API surface coupling — swapping client lib touches every feature
  • +
  • The ./ui subpath is a fifth public-API entry to maintain per feature
  • +
+
+
+ +
+
__factories__/
+

Test data factories

+

One factory per entity. Sequence-counter defaults; .build({ overrides }); .reset() in beforeEach.

+
+
Pros
    +
  • Tests stop drowning in inline fixtures — only override what the assertion cares about
  • +
  • Sequence-driven defaults = deterministic IDs across runs
  • +
  • Shared between unit tests, contract suites, and __seeds__/ — one source of "valid entity"
  • +
+
Cons
    +
  • Factory defaults can become outdated as entity schema evolves — Zod tightens, factory still hands out old shape, tests pass but production fails
  • +
  • Forgetting .reset() in beforeEach causes flaky test ordering
  • +
+
+
+ +
+
__contracts__/
+

Repository contract suite

+

A portable test suite parameterized by buildSubject. Run against mock + real impl.

+
+
Pros
    +
  • Catches mock / real divergence at unit-test time, not in production
  • +
  • The behavioral contract is a literal artifact, not folklore
  • +
  • Twelve it() blocks run twice — twenty-four assertions for the price of writing twelve
  • +
+
Cons
    +
  • ~50 lines of test boilerplate per repository
  • +
  • Adding a method to the interface means writing the contract test before either impl can pass
  • +
  • Easy to over-specify and lock implementation details (e.g. assert exact ID format the mock happens to produce)
  • +
+
+
+ +
+
__seeds__/
+

Dev-seed data

+

buildDev<Entities>() — uses the feature's factory; consumed by bind-dev-seed.

+
+
Pros
    +
  • App-level realistic data without Payload running — storybook, design review, offline dev
  • +
  • Lazy function = side-effect-free at module load (factory sequence not advanced on import)
  • +
  • Reuses factory defaults — no parallel fixture system
  • +
+
Cons
    +
  • The __ prefix borrows from the test convention but this folder is reachable from runtime via DI — slightly mislabeled
  • +
  • Seed entities can rot — refreshes when entity schema or domain model changes
  • +
  • Yet another place where realistic data lives — tests, factories, seeds, real Payload all need to agree
  • +
+
+
+ +
+
src/index.ts (root)
+

Public contract surface

+

The feature's . export — types, errors, schemas, IUseCase aliases, router type, constants.

+
+
Pros
    +
  • Single file lists everything the feature exposes — easy to audit, easy to enforce
  • +
  • Clean split from ./ui: contracts here, UI artifacts there
  • +
  • Type aliases (IXUseCase, IXController) decouple consumers from the impl
  • +
+
Cons
    +
  • Maintenance burden — every new schema / error / type needs an explicit re-export
  • +
  • Easy to forget; downstream consumers can't reach a symbol that wasn't re-exported
  • +
+
+
+ +
+
+ + +
+
+
§ 06

Do we need them?