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 {
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.
+One file per entity (article.ts, user.ts): a Zod schema and the inferred TypeScript type.
z.inferOne file per error domain (article.ts, auth.ts) plus common.ts for InputParseError.
ArticleNotFoundError beats a generic Error by milesdefineErrorMiddleware matches by instanceof and translates to TRPCError codesthis.name (R6) — easy to forget, was the systemic Plan-9 fix-upInputParseError is duplicated per feature (~6 lines × 5) — by design, but feels redundantprocedures.ts map too<x>.repository.interface.ts — TypeScript interface, no implementation, no Zod.
<x>.service.interface.ts — interface for non-data-access boundaries (auth, email, …).
One file per verb-noun (get-articles.use-case.ts): input + output schemas + factory.
xOutputSchema.parse(...) at the end of the body catches malformed repo returns at the layer that owns the contractz.array(articleSchema)) feel ceremonial when they don't add validation.parse() on every call has measurable cost on hot paths (negligible in practice)Constructor takes SanitizedConfig, methods call getPayload({ config }), map to domain.
core-cms dep)Payload prefix — DI swaps mock ↔ real cleanlygetPayload({ config }) is repetitivetoDomain mappers are easy to forget for new fields → silent shape driftvi.mock("payload") + Payload stub setup — more ceremony than testing the mock implIn-memory implementation. The default DI binding; also injected directly in unit tests.
+pnpm dev just worksSame dual-impl pattern as repositories. Auth has one (real AuthenticationService + mock).
NotImplementedError while the mock fully works (auth's session methods do this today)One file per use case. Receives unknown, safeParses, calls use case, runs presenter.
InputParseError is the controller's responsibility, never the use case'sfunction presenter means view-shape transforms live next to the wire.input + controller safeParse) — defense in depth has a costPlain object of Symbol.for("blog:I…") keys. One per binding the container holds.
Symbol.for namespacing prevents cross-feature collisionsContainerModule with all repository, service, use-case, controller bindings.
.toDynamicValue is what makes factory functions work as DI bindings.toDynamicValue((ctx) => factoryFn(ctx.container.get(...))) repeats boilerplateThree lines: reflect-metadata, new Container({ defaultScope: "Singleton" }), load(Module).
.get() calls reuse the closureunbindAll() + reload to start freshimport "reflect-metadata" is a side-effect import — easy to forget when scaffolding new filesbindProduction<F>(config) — unbinds the mock, rebinds the real Payload-backed impl.
isBound guard) — safe to call multiple timesbindDevSeed<F>() — unbinds the empty mock, rebinds a populated mock (post-Plan-9).
bind-production — one mental model, two bindersxProcedure = t.procedure.use(defineErrorMiddleware([...])) — owns the feature's error-to-code map.
TRPCError mapping — no central registry, no core-shared couplingdefineErrorMiddleware in core-shared is plumbing only — boundary stays cleanintegrations/api/ — a feature with two procedures has the same overhead as one with tenInputParseError → BAD_REQUEST tuple is dormant on the tRPC path (tRPC's own zod parse fires first) — feels theatricalOne file per feature, composed into core-api's appRouter via the ./api export.
xProcedure.input(xInputSchema) — schemas imported from the use-case file, never redefinedcreateCaller goes through the containerCollection / global definitions exposed via the ./cms export, composed into core-cms.
cms-core grab-bagcore-cms just composes; features stay independently versionableui/index.ts exports query builders + components. Apps import via @repo/<feature>/ui.
ArticleCard) live with the feature, not in core-uiexport {}./ui subpath is a fifth public-API entry to maintain per featureOne factory per entity. Sequence-counter defaults; .build({ overrides }); .reset() in beforeEach.
__seeds__/ — one source of "valid entity".reset() in beforeEach causes flaky test orderingA portable test suite parameterized by buildSubject. Run against mock + real impl.
it() blocks run twice — twenty-four assertions for the price of writing twelvebuildDev<Entities>() — uses the feature's factory; consumed by bind-dev-seed.
__ prefix borrows from the test convention but this folder is reachable from runtime via DI — slightly mislabeledThe feature's . export — types, errors, schemas, IUseCase aliases, router type, constants.
./ui: contracts here, UI artifacts thereIXUseCase, IXController) decouple consumers from the impl