Files
agentic-dev/docs/architecture/data-flow-explainer.html

3232 lines
138 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=1280">
<title>data-flow / template-vertical / explainer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght,SOFT@0,9..144,300..900,0..100;1,9..144,300..900,0..100&family=JetBrains+Mono:ital,wght@0,400;0,500;0,700;1,400&display=swap" rel="stylesheet">
<style>
:root {
--paper: #f4ecd8;
--paper-2: #ebe2cb;
--paper-3: #f9f3e3;
--paper-edge: #ddd0b1;
--ink: #1a1310;
--ink-2: #5b4a3c;
--ink-3: #8a7563;
--rule: rgba(26, 19, 16, 0.12);
--rule-strong: rgba(26, 19, 16, 0.28);
--accent: #7d2828;
--accent-soft: #b86a47;
--accent-2: #1f3d4a;
--accent-3: #c5912f;
--ok: #4a6a32;
--code-bg: #16110d;
--code-bg-2: #1d1814;
--code-paper: #ebe2cb;
--code-mute: #8a7563;
--code-accent: #d8a04a;
--measure: 66ch;
--col-narrow: 720px;
--col-wide: 1080px;
--col-flow: 1180px;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--paper);
color: var(--ink);
font-family: "Fraunces", Georgia, serif;
font-feature-settings: "ss01", "ss02";
-webkit-font-smoothing: antialiased;
font-variation-settings: "opsz" 14, "SOFT" 50;
}
body {
position: relative;
background-image:
repeating-linear-gradient(0deg, transparent 0 31px, rgba(26,19,16,0.04) 31px 32px),
radial-gradient(circle at 22% 18%, rgba(125, 40, 40, 0.05), transparent 40%),
radial-gradient(circle at 88% 84%, rgba(31, 61, 74, 0.04), transparent 40%);
}
body::before {
content: "";
position: fixed;
inset: 0;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><filter id='n'><feTurbulence baseFrequency='0.85' numOctaves='3'/><feColorMatrix values='0 0 0 0 0.10 0 0 0 0 0.07 0 0 0 0 0.05 0 0 0 0.10 0'/></filter><rect width='200' height='200' filter='url(%23n)'/></svg>");
pointer-events: none;
opacity: 0.45;
mix-blend-mode: multiply;
z-index: 1;
}
main, header, footer { position: relative; z-index: 2; }
a {
color: inherit;
text-decoration: underline;
text-decoration-thickness: 1px;
text-decoration-color: var(--rule-strong);
text-underline-offset: 3px;
transition: text-decoration-color 0.2s ease;
}
a:hover { text-decoration-color: var(--accent); }
/* ─── Layout ─────────────────────────────────────────────────────────── */
.frame {
max-width: 1280px;
margin: 0 auto;
padding: 0 56px;
}
.gutter-frame {
position: relative;
}
/* ─── Masthead ───────────────────────────────────────────────────────── */
header.masthead {
border-bottom: 1px solid var(--rule-strong);
padding-top: 48px;
padding-bottom: 12px;
}
.folio-row {
display: flex;
justify-content: space-between;
align-items: baseline;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-2);
border-bottom: 1px solid var(--rule);
padding-bottom: 12px;
margin-bottom: 64px;
}
.folio-row .folio-label {
display: inline-flex;
gap: 14px;
align-items: baseline;
}
.folio-row .folio-mark {
width: 9px; height: 9px;
background: var(--accent);
display: inline-block;
border-radius: 50%;
transform: translateY(1px);
}
.title-block {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 80px;
align-items: end;
padding-bottom: 56px;
border-bottom: 1px solid var(--rule);
}
h1.title {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 144, "SOFT" 0, "wght" 380;
font-size: clamp(64px, 7.5vw, 112px);
line-height: 0.92;
letter-spacing: -0.025em;
margin: 0 0 0 -6px;
color: var(--ink);
}
h1.title em {
font-style: italic;
font-variation-settings: "opsz" 144, "SOFT" 100, "wght" 380;
color: var(--accent);
}
h1.title .ampersand {
font-style: italic;
font-variation-settings: "opsz" 144, "SOFT" 100, "wght" 360;
color: var(--accent);
}
.lede {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 18, "SOFT" 30;
font-size: 17px;
line-height: 1.55;
color: var(--ink-2);
margin: 0;
max-width: 360px;
text-wrap: pretty;
}
.contents-row {
margin-top: 36px;
padding-top: 28px;
border-top: 1px dashed var(--rule);
display: grid;
grid-template-columns: 110px 1fr;
gap: 32px;
align-items: start;
}
.contents-label {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
padding-top: 4px;
}
ol.contents {
list-style: none;
margin: 0; padding: 0;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 28px;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
letter-spacing: 0.04em;
}
ol.contents li {
border-top: 1px solid var(--ink);
padding-top: 10px;
}
ol.contents a {
display: block;
text-decoration: none;
color: var(--ink);
}
ol.contents .num {
color: var(--accent);
display: block;
font-size: 11px;
margin-bottom: 6px;
letter-spacing: 0.18em;
}
ol.contents li:hover .num { color: var(--accent-2); }
/* ─── Section primitives ─────────────────────────────────────────────── */
main { padding-top: 56px; padding-bottom: 80px; }
section {
padding: 88px 0;
border-bottom: 1px solid var(--rule);
}
.section-head {
display: grid;
grid-template-columns: 110px 1fr;
gap: 32px;
align-items: baseline;
margin-bottom: 56px;
}
.section-num {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
padding-top: 12px;
}
.section-title {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 96, "SOFT" 0, "wght" 360;
font-size: clamp(40px, 5vw, 64px);
line-height: 0.96;
letter-spacing: -0.018em;
margin: 0 0 14px;
}
.section-title em {
font-style: italic;
font-variation-settings: "opsz" 96, "SOFT" 100, "wght" 360;
color: var(--accent);
}
.section-blurb {
max-width: 70ch;
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 16.5px;
line-height: 1.6;
color: var(--ink-2);
margin: 0;
text-wrap: pretty;
}
.section-blurb + .section-blurb { margin-top: 1em; }
p { line-height: 1.6; text-wrap: pretty; }
.col-row {
display: grid;
grid-template-columns: 110px 1fr;
gap: 32px;
align-items: start;
}
.col-row .col-label {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
padding-top: 6px;
}
/* ─── Buttons / pills ────────────────────────────────────────────────── */
.pillbar {
display: inline-flex;
gap: 0;
border: 1px solid var(--ink);
background: var(--paper-3);
padding: 4px;
border-radius: 999px;
margin-bottom: 28px;
}
.pillbar button {
appearance: none;
border: 0;
background: transparent;
font-family: "JetBrains Mono", monospace;
font-size: 11.5px;
letter-spacing: 0.08em;
color: var(--ink-2);
padding: 7px 16px;
cursor: pointer;
border-radius: 999px;
transition: background 0.18s ease, color 0.18s ease, transform 0.18s ease;
text-transform: lowercase;
}
.pillbar button:hover { color: var(--ink); }
.pillbar button[aria-pressed="true"] {
background: var(--ink);
color: var(--paper);
}
.btn {
appearance: none;
border: 1px solid var(--ink);
background: transparent;
font-family: "JetBrains Mono", monospace;
font-size: 11.5px;
letter-spacing: 0.08em;
color: var(--ink);
padding: 8px 14px;
cursor: pointer;
text-transform: lowercase;
transition: background 0.18s ease, color 0.18s ease;
}
.btn:hover { background: var(--ink); color: var(--paper); }
.btn[disabled] { opacity: 0.35; cursor: not-allowed; }
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--paper);
}
.btn.primary:hover { background: #5e1c1c; border-color: #5e1c1c; }
/* ─── Anatomy section ────────────────────────────────────────────────── */
.anatomy-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 64px;
margin-top: 16px;
align-items: start;
}
.tree {
font-family: "JetBrains Mono", monospace;
font-size: 13px;
line-height: 1.85;
color: var(--ink);
background: var(--paper-3);
border: 1px solid var(--rule-strong);
padding: 28px 32px;
border-radius: 4px;
position: relative;
}
.tree::before {
content: "packages/<feature>/";
position: absolute;
top: -10px; left: 24px;
background: var(--paper);
padding: 0 10px;
font-size: 10px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-3);
}
.tree-row {
display: block;
cursor: pointer;
padding: 0 8px;
border-radius: 2px;
transition: background 0.12s ease;
white-space: pre;
}
.tree-row:hover { background: var(--paper-2); }
.tree-row.is-active {
background: var(--ink);
color: var(--paper);
}
.tree-row.is-active .dim { color: var(--paper-2); opacity: 0.7; }
.tree-row .dim { color: var(--ink-3); }
.tree-row .dot { color: var(--accent); }
.layer-card {
background: var(--paper-3);
border: 1px solid var(--rule-strong);
padding: 32px;
border-radius: 4px;
min-height: 360px;
}
.layer-card .layer-tag {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 8px;
}
.layer-card h3 {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 36, "SOFT" 30, "wght" 420;
font-size: 30px;
line-height: 1.05;
margin: 0 0 14px;
letter-spacing: -0.01em;
}
.layer-card h3 em {
font-style: italic;
font-variation-settings: "opsz" 36, "SOFT" 100, "wght" 420;
color: var(--accent);
}
.layer-card p {
font-size: 15.5px;
line-height: 1.6;
color: var(--ink-2);
margin: 0 0 12px;
}
.layer-card .meta {
margin-top: 18px;
padding-top: 14px;
border-top: 1px solid var(--rule);
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: var(--ink-3);
letter-spacing: 0.04em;
}
.layer-card .meta strong { color: var(--ink); font-weight: 500; }
.layer-card .examples {
margin-top: 12px;
font-family: "JetBrains Mono", monospace;
font-size: 11.5px;
color: var(--ink-2);
}
.layer-card .examples li { margin: 3px 0; padding-left: 14px; position: relative; list-style: none; }
.layer-card .examples li::before {
content: "";
position: absolute; left: 0;
color: var(--accent);
}
.layer-card .examples ul { list-style: none; padding: 0; margin: 6px 0 0; }
/* ─── Flow section ───────────────────────────────────────────────────── */
.feature-picker {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 56px;
flex-wrap: wrap;
}
.feature-picker .label {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
margin-right: 12px;
}
.flow-grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 56px;
align-items: start;
}
.flow-pipeline {
position: relative;
padding: 0 8px;
}
.flow-stage {
display: grid;
grid-template-columns: 36px 1fr;
gap: 14px;
align-items: center;
padding: 14px 14px 14px 6px;
cursor: pointer;
border-radius: 4px;
transition: background 0.18s ease, transform 0.18s ease;
position: relative;
}
.flow-stage:hover { background: var(--paper-2); }
.flow-stage.is-active {
background: var(--ink);
color: var(--paper);
}
.flow-stage.is-active .stage-num { color: var(--accent-3); }
.flow-stage.is-active .stage-meta { color: var(--paper-2); opacity: 0.78; }
.flow-stage::after {
content: "";
position: absolute;
left: 23px;
bottom: -10px;
width: 1px;
height: 22px;
background: var(--rule-strong);
}
.flow-stage:last-of-type::after { display: none; }
.stage-num {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
font-weight: 500;
color: var(--accent);
text-align: right;
letter-spacing: 0.05em;
padding-right: 4px;
}
.stage-body {
display: flex;
flex-direction: column;
}
.stage-name {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 18, "SOFT" 30, "wght" 480;
font-size: 17px;
line-height: 1.2;
letter-spacing: -0.005em;
}
.stage-meta {
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
letter-spacing: 0.04em;
color: var(--ink-3);
margin-top: 2px;
}
.flow-stage.fork-success { color: var(--ok); }
.flow-stage.fork-error { color: var(--accent); }
.flow-stage.fork-success .stage-num,
.flow-stage.fork-error .stage-num { color: currentColor; }
.flow-stage.is-active.fork-success { background: #2c4220; color: var(--paper); }
.flow-stage.is-active.fork-success .stage-num { color: #c8d4a3; }
.flow-stage.is-active.fork-error { background: #5e1c1c; color: var(--paper); }
.flow-stage.is-active.fork-error .stage-num { color: #f0c4b3; }
.flow-fork-divider {
display: flex;
align-items: center;
gap: 10px;
margin: 16px 0 8px;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
}
.flow-fork-divider::before,
.flow-fork-divider::after {
content: "";
flex: 1;
height: 1px;
background: var(--rule-strong);
}
.flow-controls {
display: flex;
gap: 8px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--rule);
align-items: center;
}
.flow-controls .progress {
flex: 1;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: var(--ink-3);
text-align: right;
letter-spacing: 0.06em;
}
.stage-detail {
border: 1px solid var(--rule-strong);
background: var(--paper-3);
padding: 36px 40px;
border-radius: 4px;
min-height: 580px;
position: relative;
}
.stage-detail .detail-tag {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
}
.stage-detail h3 {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 60, "SOFT" 30, "wght" 380;
font-size: 38px;
line-height: 1.05;
letter-spacing: -0.015em;
margin: 0 0 8px;
}
.stage-detail h3 em {
font-style: italic;
font-variation-settings: "opsz" 60, "SOFT" 100, "wght" 380;
color: var(--accent);
}
.stage-detail .file-path {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
color: var(--ink-2);
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--rule);
}
.stage-detail p.detail-prose {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 17, "SOFT" 40;
font-size: 16.5px;
line-height: 1.6;
color: var(--ink);
margin: 0 0 24px;
max-width: 60ch;
text-wrap: pretty;
}
/* ─── Code blocks ────────────────────────────────────────────────────── */
pre.code {
background: var(--code-bg);
color: var(--code-paper);
font-family: "JetBrains Mono", monospace;
font-size: 12.5px;
line-height: 1.62;
padding: 22px 24px;
border-radius: 4px;
overflow-x: auto;
margin: 0;
border: 1px solid var(--code-bg-2);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 14px 40px -20px rgba(20, 14, 8, 0.5);
}
pre.code .c { color: var(--code-mute); font-style: italic; }
pre.code .k { color: var(--code-accent); }
pre.code .s { color: var(--code-paper); opacity: 0.92; }
pre.code .t { color: #b3c8a6; }
pre.code .n { color: #d2b896; }
pre.code .h { background: rgba(216, 160, 74, 0.16); padding: 0 4px; border-radius: 2px; }
pre.code::before {
content: attr(data-lang);
display: block;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: #6b574a;
margin-bottom: 10px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(216, 160, 74, 0.18);
}
/* ─── DI section ─────────────────────────────────────────────────────── */
.di-grid {
display: grid;
grid-template-columns: 1fr;
gap: 36px;
margin-top: 8px;
}
.di-board {
background: var(--paper-3);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 36px 40px;
position: relative;
overflow: hidden;
}
.di-board-head {
display: flex;
justify-content: space-between;
align-items: end;
margin-bottom: 32px;
padding-bottom: 18px;
border-bottom: 1px solid var(--rule);
gap: 20px;
}
.di-board-head h3 {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 36, "SOFT" 30, "wght" 380;
font-size: 28px;
margin: 0;
letter-spacing: -0.01em;
}
.di-board-head h3 em {
font-style: italic;
font-variation-settings: "opsz" 36, "SOFT" 100, "wght" 380;
color: var(--accent);
}
.di-board-head .meta {
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: var(--ink-3);
text-align: right;
letter-spacing: 0.06em;
}
.di-canvas {
display: grid;
grid-template-columns: 280px 1fr 280px;
gap: 32px;
align-items: start;
position: relative;
}
.di-col h4 {
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
margin: 0 0 16px;
}
.di-node {
display: block;
border: 1px solid var(--ink);
background: var(--paper);
padding: 12px 14px;
margin-bottom: 8px;
border-radius: 2px;
font-family: "JetBrains Mono", monospace;
font-size: 12px;
position: relative;
transition: background 0.18s ease, border-color 0.18s ease, transform 0.18s ease;
}
.di-node .node-kind {
font-size: 9.5px;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-3);
margin-bottom: 4px;
}
.di-node .node-name { color: var(--ink); }
.di-node.is-mock { border-color: var(--ink); }
.di-node.is-real {
border-color: var(--accent);
background: #fff7e8;
}
.di-node.is-real .node-kind { color: var(--accent); }
.di-node.is-factory {
background: var(--paper-2);
border-style: dashed;
}
.di-node.is-factory .node-kind { color: var(--accent-2); }
.di-bridge {
position: relative;
padding-top: 36px;
text-align: center;
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
letter-spacing: 0.06em;
color: var(--ink-3);
}
.di-bridge .bridge-label {
background: var(--paper-3);
padding: 0 10px;
display: inline-block;
position: relative;
z-index: 2;
}
.di-explainer {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 28px;
margin-top: 28px;
}
.di-card {
background: var(--paper-3);
border: 1px solid var(--rule-strong);
border-radius: 4px;
padding: 28px 30px;
}
.di-card h4 {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 24, "SOFT" 40, "wght" 460;
font-size: 22px;
margin: 0 0 12px;
letter-spacing: -0.005em;
}
.di-card h4 em {
font-style: italic;
font-variation-settings: "opsz" 24, "SOFT" 100, "wght" 460;
color: var(--accent);
}
.di-card p {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 15.5px;
color: var(--ink-2);
line-height: 1.6;
margin: 0 0 12px;
}
/* ─── Contracts / factories section ─────────────────────────────────── */
.cf-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 40px;
margin-top: 16px;
}
.cf-card {
border: 1px solid var(--rule-strong);
background: var(--paper-3);
padding: 32px 36px;
border-radius: 4px;
}
.cf-card .cf-tag {
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 8px;
}
.cf-card h3 {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 60, "SOFT" 0, "wght" 380;
font-size: 42px;
line-height: 1.0;
letter-spacing: -0.018em;
margin: 0 0 18px;
}
.cf-card h3 em {
font-style: italic;
font-variation-settings: "opsz" 60, "SOFT" 100, "wght" 380;
color: var(--accent);
}
.cf-card p {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 15.5px;
line-height: 1.6;
color: var(--ink-2);
margin: 0 0 14px;
}
.cf-card p strong { color: var(--ink); font-weight: 500; }
details.cf-detail {
border: 0;
margin-top: 18px;
padding-top: 16px;
border-top: 1px solid var(--rule);
}
details.cf-detail > summary {
list-style: none;
cursor: pointer;
font-family: "JetBrains Mono", monospace;
font-size: 11.5px;
letter-spacing: 0.06em;
color: var(--ink-2);
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
user-select: none;
}
details.cf-detail > summary::-webkit-details-marker { display: none; }
details.cf-detail > summary::before {
content: "+";
display: inline-flex;
width: 20px; height: 20px;
align-items: center; justify-content: center;
border: 1px solid var(--ink);
border-radius: 2px;
font-size: 14px;
background: var(--paper);
transition: transform 0.18s ease, background 0.18s ease, color 0.18s ease;
}
details.cf-detail[open] > summary::before {
content: "";
background: var(--ink);
color: var(--paper);
}
details.cf-detail > summary:hover { color: var(--ink); }
details.cf-detail > .detail-body {
padding-top: 16px;
}
/* ─── Three-roles diagram (anatomy of mock/contract/factory) ─────────── */
.three-roles-card { margin-bottom: 40px; }
.three-roles-card h3 {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 60, "SOFT" 0, "wght" 380;
font-size: 42px;
line-height: 1.0;
letter-spacing: -0.018em;
margin: 0 0 18px;
}
.three-roles-card h3 em {
font-style: italic;
font-variation-settings: "opsz" 60, "SOFT" 100, "wght" 380;
color: var(--accent);
}
.three-roles-card p {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 15.5px;
line-height: 1.6;
color: var(--ink-2);
margin: 0 0 12px;
}
.three-roles-card p strong { color: var(--ink); font-weight: 500; }
.three-roles-card code {
font-family: "JetBrains Mono", monospace;
font-size: 12.5px;
background: var(--paper-2);
padding: 1px 5px;
border-radius: 2px;
}
.three-roles-diagram {
display: flex;
flex-direction: column;
gap: 0;
margin: 32px auto;
max-width: 760px;
}
.role {
border: 1px solid var(--ink);
background: var(--paper);
padding: 16px 20px;
border-radius: 3px;
position: relative;
}
.role .role-where {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
margin-bottom: 6px;
}
.role > strong {
display: block;
font-family: "JetBrains Mono", monospace;
font-weight: 500;
color: var(--ink);
font-size: 14.5px;
margin-bottom: 6px;
letter-spacing: 0.01em;
}
.role .role-what {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 14, "SOFT" 40;
font-style: italic;
font-size: 13.5px;
line-height: 1.4;
color: var(--ink-2);
margin: 0;
}
.role .role-what em {
font-style: italic;
font-variation-settings: "opsz" 14, "SOFT" 100, "wght" 460;
color: var(--accent);
font-weight: 460;
}
.role .role-what code {
font-family: "JetBrains Mono", monospace;
font-style: normal;
font-size: 12px;
background: var(--paper-2);
padding: 1px 4px;
border-radius: 2px;
}
.role-interface {
border-width: 2px;
background: var(--paper-3);
}
.role-real {
background: #fbf1de;
border-color: var(--accent);
}
.role-real .role-where { color: var(--accent); }
.role-contract {
border-style: dashed;
border-color: var(--accent-2);
}
.role-contract .role-where { color: var(--accent-2); }
.role-factory {
border-color: var(--ok);
align-self: center;
width: min(78%, 540px);
}
.role-factory .role-where { color: var(--ok); }
.roles-pair {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
.role-arrow {
align-self: center;
font-family: "JetBrains Mono", monospace;
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-3);
padding: 14px 0;
position: relative;
}
.role-arrow::before {
content: "";
position: absolute;
left: 50%;
top: 0;
width: 1px;
height: 14px;
background: var(--rule-strong);
transform: translateX(-0.5px);
}
.role-arrow::after {
content: "";
position: absolute;
left: 50%;
bottom: 0;
width: 1px;
height: 14px;
background: var(--rule-strong);
transform: translateX(-0.5px);
}
.role-jobs {
list-style: none;
padding: 0;
margin: 14px 0 0;
counter-reset: job;
}
ol.role-jobs { counter-reset: job; }
ol.role-jobs > li {
counter-increment: job;
position: relative;
padding: 14px 18px 14px 56px;
margin-bottom: 8px;
background: var(--paper-3);
border-left: 2px solid var(--accent);
border-radius: 0 3px 3px 0;
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 15.5px;
line-height: 1.55;
color: var(--ink);
}
ol.role-jobs > li::before {
content: counter(job, decimal-leading-zero);
position: absolute;
left: 18px;
top: 14px;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
letter-spacing: 0.18em;
color: var(--accent);
}
ol.role-jobs > li strong {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 30, "wght" 520;
font-weight: 500;
color: var(--ink);
}
ol.role-jobs > li code {
font-family: "JetBrains Mono", monospace;
font-size: 12.5px;
background: var(--paper-2);
padding: 1px 5px;
border-radius: 2px;
}
ul.role-jobs {
list-style: none;
padding: 0;
margin: 14px 0 0;
}
ul.role-jobs > li {
padding: 12px 18px;
margin-bottom: 8px;
background: var(--paper-3);
border-left: 2px solid var(--accent-2);
border-radius: 0 3px 3px 0;
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 40;
font-size: 15.5px;
line-height: 1.55;
color: var(--ink);
}
ul.role-jobs > li strong {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 16, "SOFT" 30, "wght" 520;
font-weight: 500;
color: var(--ink);
}
ul.role-jobs > li code {
font-family: "JetBrains Mono", monospace;
font-size: 12.5px;
background: var(--paper-2);
padding: 1px 5px;
border-radius: 2px;
}
@media (max-width: 700px) {
.roles-pair { grid-template-columns: 1fr; }
.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 {
background: var(--ink);
color: var(--paper);
padding: 56px 64px;
border-radius: 4px;
position: relative;
overflow: hidden;
}
.verdict-block::before {
content: "";
position: absolute;
inset: 0;
background-image:
radial-gradient(circle at 90% -10%, rgba(125, 40, 40, 0.5), transparent 50%),
radial-gradient(circle at -10% 110%, rgba(31, 61, 74, 0.4), transparent 50%);
pointer-events: none;
}
.verdict-block h3 {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 96, "SOFT" 0, "wght" 360;
font-size: 56px;
line-height: 0.96;
letter-spacing: -0.02em;
margin: 0 0 24px;
position: relative;
max-width: 16ch;
}
.verdict-block h3 em {
font-style: italic;
font-variation-settings: "opsz" 96, "SOFT" 100, "wght" 360;
color: var(--accent-3);
}
.verdict-block p {
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 18, "SOFT" 40;
font-size: 17.5px;
line-height: 1.6;
max-width: 60ch;
margin: 0 0 16px;
position: relative;
color: rgba(244, 236, 216, 0.86);
}
.verdict-block p strong { color: var(--paper); font-weight: 500; }
.verdict-block .badge-row {
display: flex;
gap: 24px;
margin-top: 32px;
position: relative;
flex-wrap: wrap;
}
.verdict-block .badge {
border: 1px solid rgba(244, 236, 216, 0.3);
padding: 14px 18px;
border-radius: 4px;
font-family: "JetBrains Mono", monospace;
font-size: 11.5px;
letter-spacing: 0.04em;
color: rgba(244, 236, 216, 0.78);
}
.verdict-block .badge strong {
display: block;
font-family: "Fraunces", serif;
font-variation-settings: "opsz" 24, "SOFT" 30, "wght" 460;
font-style: italic;
font-size: 22px;
color: var(--accent-3);
margin-bottom: 4px;
letter-spacing: 0;
}
/* ─── Footer ─────────────────────────────────────────────────────────── */
footer {
border-top: 2px solid var(--ink);
padding: 36px 0 56px;
}
footer .foot-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 32px;
font-family: "JetBrains Mono", monospace;
font-size: 11px;
color: var(--ink-2);
letter-spacing: 0.04em;
}
footer .foot-col h5 {
font-size: 10px;
letter-spacing: 0.22em;
text-transform: uppercase;
margin: 0 0 12px;
color: var(--ink-3);
font-weight: 500;
}
footer .foot-col a { display: block; margin: 4px 0; }
footer .colophon {
margin-top: 36px;
padding-top: 18px;
border-top: 1px dashed var(--rule);
font-size: 10.5px;
color: var(--ink-3);
letter-spacing: 0.04em;
}
/* ─── Errors-to-codes table ─────────────────────────────────────────── */
.codes-table {
margin-top: 32px;
border: 1px solid var(--rule-strong);
background: var(--paper-3);
border-radius: 4px;
overflow: hidden;
}
.codes-table-head {
display: grid;
grid-template-columns: 1fr 200px;
background: var(--ink);
color: var(--paper);
padding: 14px 22px;
font-family: "JetBrains Mono", monospace;
font-size: 10.5px;
letter-spacing: 0.22em;
text-transform: uppercase;
}
.codes-row {
display: grid;
grid-template-columns: 1fr 200px;
padding: 14px 22px;
border-top: 1px solid var(--rule);
font-family: "JetBrains Mono", monospace;
font-size: 13px;
align-items: baseline;
}
.codes-row:first-child { border-top: 0; }
.codes-row .err { color: var(--ink); }
.codes-row .arrow { color: var(--ink-3); margin: 0 6px; }
.codes-row .code {
color: var(--accent);
font-weight: 500;
letter-spacing: 0.04em;
}
/* ─── Animations ─────────────────────────────────────────────────────── */
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
.fade-in { animation: fadeIn 0.32s ease; }
/* ─── §06 Tracing & error capture (Plan 10) ─────────────────────────── */
.trace-h3 {
font-family: "Fraunces", serif;
font-weight: 500;
font-size: 1.1rem;
margin: 32px 0 14px;
color: var(--paper);
}
.trace-p {
margin: 0 0 18px;
max-width: 70ch;
}
.trace-tree {
background: var(--code-bg);
color: var(--code-paper);
padding: 1.25rem 1.5rem;
border-radius: 4px;
overflow-x: auto;
font-family: "JetBrains Mono", monospace;
font-size: 0.85rem;
line-height: 1.6;
margin: 0 0 18px;
border: 1px solid var(--code-bg-2);
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.04) inset,
0 14px 40px -20px rgba(20, 14, 8, 0.5);
}
.capture-rules {
border-collapse: collapse;
width: 100%;
margin: 0 0 28px;
}
.capture-rules th, .capture-rules td {
padding: 0.6rem 0.85rem;
text-align: left;
border-bottom: 1px solid var(--rule, rgba(255,255,255,.12));
}
.capture-rules th {
font-family: "Fraunces", serif;
font-weight: 500;
}
.capture-rules.instrumentation-where th:nth-child(2),
.capture-rules.instrumentation-where th:nth-child(3),
.capture-rules.instrumentation-where td:nth-child(2),
.capture-rules.instrumentation-where td:nth-child(3) {
white-space: nowrap;
width: 1%;
}
.pii-rules {
display: grid;
gap: 0.5rem;
padding-left: 1.2rem;
margin: 0 0 14px;
}
.pii-rules li {
font-size: 0.95rem;
}
/* ─── Responsive (basic) ─────────────────────────────────────────────── */
@media (max-width: 1100px) {
.frame { padding: 0 32px; }
.anatomy-grid, .flow-grid, .cf-grid, .di-explainer { grid-template-columns: 1fr; }
.title-block { grid-template-columns: 1fr; gap: 32px; }
ol.contents { grid-template-columns: repeat(2, 1fr); }
.di-canvas { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header class="masthead frame">
<div class="folio-row">
<span class="folio-label"><span class="folio-mark"></span> template-vertical / architecture / explainer</span>
<span>2026-05-06 · post-Plan-9</span>
</div>
<div class="title-block">
<h1 class="title">A guided tour<br>of one feature's<br><em>data flow</em>.</h1>
<p class="lede">An internal explainer for the post-Plan-9 architecture — written for the engineer who built it and just wants the mental model in one place. Click through the request flow, flip the DI binding mode, swap features. Real code from this repo, not a tutorial.</p>
</div>
<div class="contents-row">
<div class="contents-label">Contents</div>
<ol class="contents" style="grid-template-columns: repeat(7, 1fr);">
<li><a href="#anatomy"><span class="num">01</span>Feature anatomy</a></li>
<li><a href="#flow"><span class="num">02</span>Request flow</a></li>
<li><a href="#di"><span class="num">03</span>Dependency injection</a></li>
<li><a href="#contracts"><span class="num">04</span>Mocks, contracts &amp; factories</a></li>
<li><a href="#tradeoffs"><span class="num">05</span>Tradeoffs by part</a></li>
<li><a href="#tracing"><span class="num">06</span>Tracing &amp; error capture</a></li>
<li><a href="#verdict"><span class="num">07</span>The verdict</a></li>
</ol>
</div>
</header>
<main class="frame">
<!-- ─── 01 ─ ANATOMY ─────────────────────────────────────────────────── -->
<section id="anatomy">
<div class="section-head">
<div class="section-num">§ 01</div>
<div>
<h2 class="section-title">The shape of a <em>feature</em>.</h2>
<p class="section-blurb">Every feature package — <code>auth</code>, <code>blog</code>, <code>marketing-pages</code>, <code>navigation</code>, <code>media</code> — has the same internal layout. Click any layer to see what lives there and why.</p>
</div>
</div>
<div class="anatomy-grid">
<div class="tree" id="tree" aria-label="Feature package tree">
<span class="tree-row" data-layer="entities"><span class="dim">├─</span> entities/</span>
<span class="tree-row" data-layer="entities"> <span class="dim">├─</span> models/<span class="dim"> ← Zod schemas + types</span></span>
<span class="tree-row" data-layer="entities"> <span class="dim">└─</span> errors/<span class="dim"> ← domain errors (set this.name)</span></span>
<span class="tree-row" data-layer="application"><span class="dim">├─</span> application/</span>
<span class="tree-row" data-layer="application"> <span class="dim">├─</span> repositories/<span class="dim">&lt;x&gt;.repository.interface.ts</span></span>
<span class="tree-row" data-layer="application"> <span class="dim">├─</span> services/<span class="dim">&lt;x&gt;.service.interface.ts</span></span>
<span class="tree-row" data-layer="application"> <span class="dim">└─</span> use-cases/<span class="dim"> ← factory + xInputSchema + xOutputSchema</span></span>
<span class="tree-row" data-layer="infrastructure"><span class="dim">├─</span> infrastructure/</span>
<span class="tree-row" data-layer="infrastructure"> <span class="dim">├─</span> repositories/<span class="dim">&lt;x&gt;.repository.ts (real)</span></span>
<span class="tree-row" data-layer="infrastructure"> <span class="dim"></span><span class="dim">+ &lt;x&gt;.repository.mock.ts</span></span>
<span class="tree-row" data-layer="infrastructure"> <span class="dim">└─</span> services/<span class="dim">&lt;x&gt;.service.ts + .mock.ts</span></span>
<span class="tree-row" data-layer="adapters"><span class="dim">├─</span> interface-adapters/</span>
<span class="tree-row" data-layer="adapters"> <span class="dim">└─</span> controllers/<span class="dim"> ← factory + safeParse + presenter</span></span>
<span class="tree-row" data-layer="di"><span class="dim">├─</span> di/</span>
<span class="tree-row" data-layer="di"> <span class="dim">├─</span> symbols.ts<span class="dim"> ← inversify Symbol.for(...) keys</span></span>
<span class="tree-row" data-layer="di"> <span class="dim">├─</span> module.ts<span class="dim"> ← ContainerModule with .toDynamicValue</span></span>
<span class="tree-row" data-layer="di"> <span class="dim">├─</span> container.ts<span class="dim"> ← Container + .load(Module)</span></span>
<span class="tree-row" data-layer="di"> <span class="dim">├─</span> bind-production.ts<span class="dim"> ← swaps mocks → real impls at boot</span></span>
<span class="tree-row" data-layer="di"> <span class="dim">└─</span> bind-dev-seed.ts<span class="dim"> ← swaps empty mocks → populated mocks</span></span>
<span class="tree-row" data-layer="integrations"><span class="dim">├─</span> integrations/</span>
<span class="tree-row" data-layer="integrations"> <span class="dim">├─</span> api/</span>
<span class="tree-row" data-layer="integrations"> <span class="dim">│ ├─</span> procedures.ts<span class="dim"> ← xProcedure + defineErrorMiddleware</span></span>
<span class="tree-row" data-layer="integrations"> <span class="dim">│ └─</span> router.ts<span class="dim"> ← xProcedure.input(xInputSchema)</span></span>
<span class="tree-row" data-layer="integrations"> <span class="dim">└─</span> cms/<span class="dim"> ← Payload collections / globals</span></span>
<span class="tree-row" data-layer="ui"><span class="dim">├─</span> ui/</span>
<span class="tree-row" data-layer="ui"> <span class="dim">├─</span> index.ts<span class="dim"> ← public surface for queries / components</span></span>
<span class="tree-row" data-layer="ui"> <span class="dim">└─</span> query.ts<span class="dim"> ← React Query option builders</span></span>
<span class="tree-row" data-layer="testing"><span class="dim">├─</span> __factories__/<span class="dim"> ← defineFactory&lt;Entity&gt;((seq)=&gt;{...})</span></span>
<span class="tree-row" data-layer="testing"><span class="dim">├─</span> __contracts__/<span class="dim"> ← defineContractSuite&lt;IRepo&gt;(...)</span></span>
<span class="tree-row" data-layer="seeds"><span class="dim">├─</span> __seeds__/<span class="dim"> ← buildDev&lt;Entities&gt;() — dev-mode realistic data</span></span>
<span class="tree-row" data-layer="public"><span class="dim">└─</span> index.ts<span class="dim"> ← root: contracts only</span></span>
</div>
<div class="layer-card" id="layerCard">
<!-- populated by JS -->
</div>
</div>
</section>
<!-- ─── 02 ─ REQUEST FLOW ────────────────────────────────────────────── -->
<section id="flow">
<div class="section-head">
<div class="section-num">§ 02</div>
<div>
<h2 class="section-title">A request, <em>step by step</em>.</h2>
<p class="section-blurb">From a React Query call on the client to Payload's local API and back. Pick a feature, then click a stage — or hit play. The error path branches off at <em>Use case</em> or <em>Repository</em> when a domain error is thrown; the success path runs through the controller's presenter on the way out.</p>
</div>
</div>
<div class="feature-picker" role="tablist" aria-label="Feature">
<span class="label">feature</span>
<div class="pillbar" id="featurePicker">
<button data-feature="blog" aria-pressed="true">blog</button>
<button data-feature="auth" aria-pressed="false">auth</button>
<button data-feature="marketing-pages" aria-pressed="false">marketing-pages</button>
<button data-feature="navigation" aria-pressed="false">navigation</button>
<button data-feature="media" aria-pressed="false">media</button>
</div>
</div>
<div class="flow-grid">
<div class="flow-pipeline" id="flowPipeline">
<!-- populated by JS -->
<div class="flow-controls">
<button class="btn" id="prevStage">← prev</button>
<button class="btn primary" id="playStage">▶ play</button>
<button class="btn" id="nextStage">next →</button>
<span class="progress" id="stageProgress">1 / 11</span>
</div>
</div>
<div class="stage-detail" id="stageDetail">
<!-- populated by JS -->
</div>
</div>
<div class="codes-table" id="codesTable">
<!-- populated by JS — feature-scoped error map -->
</div>
</section>
<!-- ─── 03 ─ DEPENDENCY INJECTION ────────────────────────────────────── -->
<section id="di">
<div class="section-head">
<div class="section-num">§ 03</div>
<div>
<h2 class="section-title">Wiring the <em>container</em>.</h2>
<p class="section-blurb">Each feature owns one InversifyJS container. Symbols → factory bindings via <code>.toDynamicValue</code>. The <em>same symbol</em> resolves to a mock at dev time and to a real Payload-backed impl after <code>bindProduction*(config)</code> runs at app boot. Toggle below to see what swaps.</p>
</div>
</div>
<div class="di-board">
<div class="di-board-head">
<h3>Resolving <em>blogContainer.get(IGetArticlesController)</em></h3>
<div class="meta">mode →
<span class="pillbar" style="margin: 0 0 0 12px; vertical-align: middle;">
<button id="diMockBtn" aria-pressed="true">default · mock</button>
<button id="diProdBtn" aria-pressed="false">production · real</button>
</span>
</div>
</div>
<div class="di-canvas">
<div class="di-col">
<h4>Symbols</h4>
<div class="di-node" data-pair="repo">
<div class="node-kind">repo symbol</div>
<div class="node-name">BLOG_SYMBOLS.IArticlesRepository</div>
</div>
<div class="di-node is-factory" data-pair="usecase">
<div class="node-kind">use case symbol</div>
<div class="node-name">BLOG_SYMBOLS.IGetArticlesUseCase</div>
</div>
<div class="di-node is-factory" data-pair="controller">
<div class="node-kind">controller symbol</div>
<div class="node-name">BLOG_SYMBOLS.IGetArticlesController</div>
</div>
</div>
<div class="di-col" style="position: relative;">
<h4 style="text-align: center;">Binding</h4>
<div class="di-bridge"><span class="bridge-label" id="bridgeRepo">.to(MockArticlesRepository)</span></div>
<div class="di-bridge"><span class="bridge-label">.toDynamicValue((ctx) ⇒ getArticlesUseCase(ctx.container.get(...)))</span></div>
<div class="di-bridge"><span class="bridge-label">.toDynamicValue((ctx) ⇒ getArticlesController(ctx.container.get(...)))</span></div>
</div>
<div class="di-col">
<h4 style="text-align: right;">Resolves to</h4>
<div class="di-node is-mock" id="repoNode">
<div class="node-kind">class · mock</div>
<div class="node-name">MockArticlesRepository</div>
</div>
<div class="di-node is-factory">
<div class="node-kind">closure · factory</div>
<div class="node-name">getArticlesUseCase(repo)</div>
</div>
<div class="di-node is-factory">
<div class="node-kind">closure · factory</div>
<div class="node-name">getArticlesController(useCase)</div>
</div>
</div>
</div>
</div>
<div class="di-explainer">
<div class="di-card">
<h4>Why <em>.toDynamicValue</em>?</h4>
<p>A use case is a curried factory: <code>(deps) => async (input) => result</code>. It isn't a class, so <code>.to(SomeClass)</code> can't construct it. <code>.toDynamicValue((ctx) =>&nbsp;...)</code> runs at resolution time, lets the container fetch each dependency, and returns a <em>closure</em> that captures them.</p>
<p>Result: every <code>container.get(SYMBOL)</code> call hands you a fully-wired async function. Tests don't need any of this — they construct mocks and pass them in directly.</p>
</div>
<div class="di-card">
<h4>Two binding modes, one symbol.</h4>
<p>The <code>BlogModule</code> binds <code>IArticlesRepository</code> to <code>MockArticlesRepository</code> by default — useful at dev/test time. At app boot, <code>bindProductionBlog(ctx: BindProductionContext)</code> unbinds the symbol and rebinds it to <code>new ArticlesRepository(ctx.config, ctx.tracer, ctx.logger)</code>. Use cases and controllers don't notice — they get whatever the symbol currently resolves to. The <code>ctx</code> object is built once by the app aggregator and passed to all feature binders.</p>
<p>This is also why the boundary stays clean: features don't import <code>core-cms</code>; the app passes the Payload config in.</p>
</div>
</div>
<div class="cf-grid" style="margin-top: 36px;">
<div class="cf-card">
<div class="cf-tag">module · default bindings</div>
<h3>blog/di/<em>module.ts</em></h3>
<p>One module, one container. Loaded once at <code>blogContainer.load(BlogModule)</code>.</p>
<pre class="code" data-lang="typescript // packages/blog/src/di/module.ts"><span class="k">export const</span> <span class="n">BlogModule</span> = <span class="k">new</span> <span class="t">ContainerModule</span>((<span class="n">bind</span>) => {
<span class="n">bind</span>&lt;<span class="t">IArticlesRepository</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>)
.<span class="n">to</span>(<span class="t">MockArticlesRepository</span>); <span class="c">// default</span>
<span class="n">bind</span>&lt;<span class="t">IGetArticlesUseCase</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IGetArticlesUseCase</span>)
.<span class="n">toDynamicValue</span>((<span class="n">ctx</span>) =&gt;
<span class="n">getArticlesUseCase</span>(
<span class="n">ctx</span>.<span class="n">container</span>.<span class="n">get</span>&lt;<span class="t">IArticlesRepository</span>&gt;(
<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>,
),
),
);
<span class="n">bind</span>&lt;<span class="t">IGetArticlesController</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IGetArticlesController</span>)
.<span class="n">toDynamicValue</span>((<span class="n">ctx</span>) =&gt;
<span class="n">getArticlesController</span>(
<span class="n">ctx</span>.<span class="n">container</span>.<span class="n">get</span>&lt;<span class="t">IGetArticlesUseCase</span>&gt;(
<span class="n">BLOG_SYMBOLS</span>.<span class="n">IGetArticlesUseCase</span>,
),
),
);
});</pre>
</div>
<div class="cf-card">
<div class="cf-tag">app boot · production override</div>
<h3>blog/di/<em>bind-production.ts</em></h3>
<p>Called from each app's bootstrap (<code>apps/web-next/src/server/bind-production.ts</code>) with the <code>ctx</code> object built once by the aggregator. <code>BindProductionContext</code> is imported from <code>@repo/core-shared/di</code>.</p>
<pre class="code" data-lang="typescript // packages/blog/src/di/bind-production.ts"><span class="k">export function</span> <span class="n">bindProductionBlog</span>(<span class="n">ctx</span>: <span class="t">BindProductionContext</span>): <span class="t">void</span> {
<span class="c">// bus, realtime, realtimeRegistry, auditLog are optional — present only when the corresponding optional package is scaffolded</span>
<span class="k">const</span> { <span class="n">config</span>, <span class="n">tracer</span>, <span class="n">logger</span>, <span class="n">bus</span>, <span class="n">queue</span>, <span class="n">realtime</span>, <span class="n">realtimeRegistry</span>, <span class="n">auditLog</span> } = <span class="n">ctx</span>;
<span class="k">if</span> (<span class="n">blogContainer</span>.<span class="n">isBound</span>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>)) {
<span class="n">blogContainer</span>.<span class="n">unbind</span>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>);
}
<span class="n">blogContainer</span>
.<span class="n">bind</span>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>)
.<span class="n">toConstantValue</span>(<span class="k">new</span> <span class="t">ArticlesRepository</span>(<span class="n">config</span>));
<span class="c">// Use cases + controllers stay untouched.</span>
<span class="c">// They'll resolve through the new repo automatically.</span>
}</pre>
</div>
</div>
</section>
<!-- ─── 04 ─ CONTRACTS & FACTORIES ───────────────────────────────────── -->
<section id="contracts">
<div class="section-head">
<div class="section-num">§ 04</div>
<div>
<h2 class="section-title">Mocks, contracts &amp; <em>factories</em>.</h2>
<p class="section-blurb">Three artifacts that sit near tests, at different distances from runtime. The <strong>mock repository</strong> is a real implementation of the interface — runtime code (DI, dev mode, storybook) reaches it. The <strong>contract</strong> is a test suite that runs against any implementation of the repo interface, mock or real. The <strong>factory</strong> builds valid entity values. The relationship between them is the interesting bit.</p>
</div>
</div>
<div class="cf-card three-roles-card">
<div class="cf-tag">where the boundaries are</div>
<h3>Same neighborhood, <em>different reach</em>.</h3>
<p>The mock <em>is</em> a test artifact, but it's also more than that — it's a real implementation of the repository interface, and runtime code reaches it directly. DI binds it as the default; dev mode runs against it when Payload isn't booted; storybook stories that need data resolve to it. The contract and factory are <em>only</em> reached from test files. That difference in reach is what determines where each lives.</p>
<p>So the mock sits in <code>infrastructure/repositories/</code> next to the real impl — they're sibling implementations of the same interface, both legitimate citizens of the runtime layer. The contract and factory live under <code>__</code>-prefixed directories that nothing outside <code>*.test.ts</code> ever imports from.</p>
<div class="three-roles-diagram">
<div class="role role-interface">
<div class="role-where">application/repositories/</div>
<strong>IArticlesRepository</strong>
<div class="role-what">interface — defines the shape every implementation must satisfy</div>
</div>
<div class="role-arrow">&nbsp; implemented by both &nbsp;</div>
<div class="roles-pair">
<div class="role role-mock">
<div class="role-where">infrastructure/repositories/</div>
<strong>MockArticlesRepository</strong>
<div class="role-what">in-memory · <em>default DI binding</em> · used by dev mode &amp; unit tests</div>
</div>
<div class="role role-real">
<div class="role-where">infrastructure/repositories/</div>
<strong>ArticlesRepository</strong>
<div class="role-what">Payload-backed · <em>production binding</em> · constructed at app boot</div>
</div>
</div>
<div class="role-arrow">&nbsp; both tested by &nbsp;</div>
<div class="role role-contract">
<div class="role-where">__contracts__/</div>
<strong>articlesRepositoryContract</strong>
<div class="role-what">portable test suite — same <code>it()</code> blocks, run twice (once per impl)</div>
</div>
<div class="role-arrow">&nbsp; consumes seed data from &nbsp;</div>
<div class="role role-factory">
<div class="role-where">__factories__/</div>
<strong>articleFactory</strong>
<div class="role-what">data builder — produces valid <code>Article</code> entities with overridable defaults</div>
</div>
</div>
<p style="margin-top: 32px;"><strong>The mock is reached from two directions.</strong> Both are legitimate, neither is "the test version":</p>
<ol class="role-jobs">
<li><strong>By the DI container at runtime.</strong> <code>BlogModule</code> binds <code>IArticlesRepository</code> to <code>MockArticlesRepository</code> at module-load time. Anything resolving that symbol — use cases, controllers, tRPC procedures, the dev server — gets the mock until <code>bindProductionBlog(ctx: BindProductionContext)</code> swaps it for the real Payload-backed one. See §03.</li>
<li><strong>By tests, via direct construction.</strong> Unit tests skip the container entirely. They construct the mock with <code>new MockArticlesRepository()</code> and pass it directly into the use-case factory function. Same class, different consumer — just a closure with a fake repo.</li>
</ol>
<p style="margin-top: 24px;"><strong>The contract and factory are reached from one direction only — tests.</strong> They never appear in runtime imports. Their roles:</p>
<ul class="role-jobs">
<li><strong>Contract</strong> = a single suite of <code>it()</code> blocks parameterized by <code>buildSubject</code>. Run it against the mock, run it against the real Payload-backed impl. If they diverge — your mock is lying about Payload's behavior, and you'd never catch it without the contract. This is its whole reason to exist: <em>it tests the mock so you can trust it</em>, alongside testing the real impl.</li>
<li><strong>Factory</strong> = a sequence-counter builder. <code>articleFactory.build({ slug: "x" })</code> hands you a valid <code>Article</code> with sensible defaults; you only override the fields the test cares about. Used by the contract <em>and</em> by every use-case / controller test that needs entity values without writing 8 lines of inline fixtures.</li>
</ul>
<details class="cf-detail">
<summary>show: the mock as DI binding (in module.ts)</summary>
<div class="detail-body">
<p>This is from <code>packages/blog/src/di/module.ts</code> — the very first binding in the module is the mock. Everything downstream (use cases, controllers) resolves through this default. <code>bindProductionBlog(ctx: BindProductionContext)</code> later replaces only this one line at app boot — use case + controller bindings stay put.</p>
<pre class="code" data-lang="typescript // packages/blog/src/di/module.ts"><span class="k">export const</span> <span class="n">BlogModule</span> = <span class="k">new</span> <span class="t">ContainerModule</span>((<span class="n">bind</span>) =&gt; {
<span class="c">// 1) Mock is the DEFAULT binding for the repo symbol.</span>
<span class="c">// Dev server, unit tests, storybook all resolve to this.</span>
<span class="n">bind</span>&lt;<span class="t">IArticlesRepository</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>)
.<span class="n">to</span>(<span class="t">MockArticlesRepository</span>);
<span class="c">// 2) Use cases consume IArticlesRepository — they don't know or</span>
<span class="c">// care which impl they got. Same factory function in either mode.</span>
<span class="n">bind</span>&lt;<span class="t">IGetArticlesUseCase</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IGetArticlesUseCase</span>)
.<span class="n">toDynamicValue</span>((<span class="n">ctx</span>) =&gt;
<span class="n">getArticlesUseCase</span>(
<span class="n">ctx</span>.<span class="n">container</span>.<span class="n">get</span>&lt;<span class="t">IArticlesRepository</span>&gt;(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>),
),
);
<span class="c">// ... + 5 more bindings, all the same shape.</span>
});</pre>
</div>
</details>
<details class="cf-detail">
<summary>show: the mock as direct test fake (no container)</summary>
<div class="detail-body">
<p>Use-case + controller tests skip DI entirely. They construct the mock and pass it as the first argument to the use-case factory, then call the resulting closure with the input. Three lines of setup, then assertions.</p>
<pre class="code" data-lang="typescript // get-articles.use-case.test.ts"><span class="n">it</span>(<span class="s">"filters by status"</span>, <span class="k">async</span> () =&gt; {
<span class="c">// Construct the mock directly — no DI container, no rebinding.</span>
<span class="k">const</span> <span class="n">repo</span> = <span class="k">new</span> <span class="t">MockArticlesRepository</span>();
<span class="c">// Use the factory to seed valid entities (only override what we care about).</span>
<span class="n">articleFactory</span>.<span class="n">reset</span>();
<span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">status</span>: <span class="s">"draft"</span> }));
<span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">status</span>: <span class="s">"published"</span> }));
<span class="c">// Inject the mock into the use-case factory; call the resulting closure.</span>
<span class="k">const</span> <span class="n">useCase</span> = <span class="n">getArticlesUseCase</span>(<span class="n">repo</span>);
<span class="k">const</span> <span class="n">result</span> = <span class="k">await</span> <span class="n">useCase</span>({ <span class="n">status</span>: <span class="s">"published"</span> });
<span class="n">expect</span>(<span class="n">result</span>).<span class="n">toHaveLength</span>(<span class="n">1</span>);
});</pre>
</div>
</details>
<details class="cf-detail">
<summary>show: the contract testing both impls (the proof-of-parity bit)</summary>
<div class="detail-body">
<p>Two tiny test files, one shared suite. If the suite ever fails on the real impl but passes on the mock — your mock is lying about Payload's behavior and you'd ship a bug. The factory is doing real work here too: every <code>it()</code> in the suite uses <code>articleFactory.build(...)</code> for seed data, so the assertions stay readable.</p>
<pre class="code" data-lang="typescript // articles.repository.mock.test.ts"><span class="n">describe</span>(<span class="s">"MockArticlesRepository"</span>, () =&gt; {
<span class="n">articlesRepositoryContract</span>.<span class="n">run</span>(<span class="k">async</span> () =&gt; <span class="k">new</span> <span class="t">MockArticlesRepository</span>());
});
<span class="c">// articles.repository.test.ts (Payload-backed) — same suite, real impl</span>
<span class="n">vi</span>.<span class="n">mock</span>(<span class="s">"payload"</span>, () =&gt; ({ <span class="n">getPayload</span>: <span class="n">vi</span>.<span class="n">fn</span>() }));
<span class="n">describe</span>(<span class="s">"ArticlesRepository (Payload)"</span>, () =&gt; {
<span class="n">articlesRepositoryContract</span>.<span class="n">run</span>(<span class="k">async</span> () =&gt; {
<span class="k">const</span> <span class="n">stub</span> = <span class="n">buildPayloadStub</span>();
(<span class="n">getPayload</span> <span class="k">as</span> <span class="t">Mock</span>).<span class="n">mockResolvedValue</span>(<span class="n">stub</span>);
<span class="k">return new</span> <span class="t">ArticlesRepository</span>(<span class="n">stubPayloadConfig</span>);
});
});</pre>
<p style="margin-top: 16px;">When you run <code>pnpm test --filter @repo/blog</code>, the contract's twelve <code>it()</code> blocks run twice — once per implementation. Twenty-four assertions for the price of writing twelve.</p>
</div>
</details>
</div>
<div class="cf-grid">
<div class="cf-card">
<div class="cf-tag">__contracts__/</div>
<h3>The behavioral <em>contract</em>.</h3>
<p>A contract suite is a portable set of tests that asserts every implementation of a repository interface behaves the same way. You write it once, run it against the mock, run it again against the real Payload-backed impl. If they diverge — bug.</p>
<p>The suite takes a <code>buildSubject</code> callback so each implementation can supply its own setup (e.g., the Payload impl needs to mock <code>getPayload()</code> first; the in-memory mock just constructs).</p>
<details class="cf-detail">
<summary>show: defining the suite</summary>
<div class="detail-body">
<pre class="code" data-lang="typescript // packages/blog/src/__contracts__/articles-repository.contract.ts"><span class="k">export const</span> <span class="n">articlesRepositoryContract</span> =
<span class="n">defineContractSuite</span>&lt;<span class="t">IArticlesRepository</span>&gt;(
<span class="s">"IArticlesRepository"</span>,
({ <span class="n">buildSubject</span> }) =&gt; {
<span class="k">let</span> <span class="n">repo</span>: <span class="t">IArticlesRepository</span>;
<span class="n">beforeEach</span>(<span class="k">async</span> () =&gt; {
<span class="n">articleFactory</span>.<span class="n">reset</span>();
<span class="n">repo</span> = <span class="k">await</span> <span class="n">buildSubject</span>();
});
<span class="n">it</span>(<span class="s">"createArticle returns an article with the correct fields"</span>, <span class="k">async</span> () =&gt; {
<span class="k">const</span> <span class="n">seed</span> = <span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">title</span>: <span class="s">"Hello World"</span> });
<span class="k">const</span> <span class="n">created</span> = <span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">seed</span>);
<span class="n">expect</span>(<span class="k">typeof</span> <span class="n">created</span>.<span class="n">id</span>).<span class="n">toBe</span>(<span class="s">"string"</span>);
<span class="n">expect</span>(<span class="n">created</span>.<span class="n">title</span>).<span class="n">toBe</span>(<span class="s">"Hello World"</span>);
});
<span class="n">it</span>(<span class="s">"getArticles filters by status"</span>, <span class="k">async</span> () =&gt; {
<span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">status</span>: <span class="s">"draft"</span> }));
<span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">status</span>: <span class="s">"published"</span> }));
<span class="k">const</span> <span class="n">drafts</span> = <span class="k">await</span> <span class="n">repo</span>.<span class="n">getArticles</span>({ <span class="n">status</span>: <span class="s">"draft"</span> });
<span class="n">expect</span>(<span class="n">drafts</span>).<span class="n">toHaveLength</span>(<span class="n">1</span>);
});
<span class="c">// ... ten more `it` cases covering every method on IArticlesRepository</span>
},
);</pre>
</div>
</details>
<details class="cf-detail">
<summary>show: running it against both impls</summary>
<div class="detail-body">
<pre class="code" data-lang="typescript // articles.repository.mock.test.ts"><span class="n">describe</span>(<span class="s">"MockArticlesRepository"</span>, () =&gt; {
<span class="n">articlesRepositoryContract</span>.<span class="n">run</span>(<span class="k">async</span> () =&gt; <span class="k">new</span> <span class="t">MockArticlesRepository</span>());
});
<span class="c">// articles.repository.test.ts (Payload-backed)</span>
<span class="n">vi</span>.<span class="n">mock</span>(<span class="s">"payload"</span>, () =&gt; ({ <span class="n">getPayload</span>: <span class="n">vi</span>.<span class="n">fn</span>() }));
<span class="n">describe</span>(<span class="s">"ArticlesRepository (Payload)"</span>, () =&gt; {
<span class="n">articlesRepositoryContract</span>.<span class="n">run</span>(<span class="k">async</span> () =&gt; {
<span class="k">const</span> <span class="n">stub</span> = <span class="n">buildPayloadStub</span>();
(<span class="n">getPayload</span> <span class="k">as</span> <span class="t">Mock</span>).<span class="n">mockResolvedValue</span>(<span class="n">stub</span>);
<span class="k">return new</span> <span class="t">ArticlesRepository</span>(<span class="n">stubPayloadConfig</span>);
});
});</pre>
</div>
</details>
</div>
<div class="cf-card">
<div class="cf-tag">__factories__/</div>
<h3>The data <em>factory</em>.</h3>
<p>A factory is a sequence-counter-driven builder for an entity. <code>articleFactory.build({ title: "X" })</code> hands you a complete, valid <code>Article</code> with sensible defaults — only the fields you specify get overridden. Call <code>.reset()</code> in <code>beforeEach</code> to keep ids deterministic.</p>
<p>The point: tests stop drowning in inline fixtures (<code>{ id: "abc", title: "...", slug: "...", content: null, status: "draft", authorId: "u1", createdAt: new Date(...), updatedAt: new Date(...) }</code>) and assert only the fields they care about.</p>
<details class="cf-detail">
<summary>show: defining a factory</summary>
<div class="detail-body">
<pre class="code" data-lang="typescript // packages/blog/src/__factories__/article.factory.ts"><span class="k">import</span> { <span class="n">defineFactory</span> } <span class="k">from</span> <span class="s">"@repo/core-testing/factory"</span>;
<span class="k">import type</span> { <span class="t">Article</span> } <span class="k">from</span> <span class="s">"../entities/models/article"</span>;
<span class="k">export const</span> <span class="n">articleFactory</span> = <span class="n">defineFactory</span>&lt;<span class="t">Article</span>&gt;(({ <span class="n">sequence</span> }) =&gt; ({
<span class="n">id</span>: <span class="s">`article-${sequence}`</span>,
<span class="n">title</span>: <span class="s">`Article ${sequence}`</span>,
<span class="n">slug</span>: <span class="s">`article-${sequence}`</span>,
<span class="n">content</span>: <span class="k">null</span>,
<span class="n">status</span>: <span class="s">"draft"</span>,
<span class="n">authorId</span>: <span class="s">"user-1"</span>,
<span class="n">createdAt</span>: <span class="k">new</span> <span class="t">Date</span>(<span class="s">"2026-01-01T00:00:00Z"</span>),
<span class="n">updatedAt</span>: <span class="k">new</span> <span class="t">Date</span>(<span class="s">"2026-01-01T00:00:00Z"</span>),
}));</pre>
</div>
</details>
<details class="cf-detail">
<summary>show: using it in a test</summary>
<div class="detail-body">
<pre class="code" data-lang="typescript // get-articles.use-case.test.ts"><span class="n">it</span>(<span class="s">"filters by status"</span>, <span class="k">async</span> () =&gt; {
<span class="k">const</span> <span class="n">repo</span> = <span class="k">new</span> <span class="t">MockArticlesRepository</span>();
<span class="n">articleFactory</span>.<span class="n">reset</span>();
<span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">status</span>: <span class="s">"draft"</span> }));
<span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">articleFactory</span>.<span class="n">build</span>({ <span class="n">status</span>: <span class="s">"published"</span> }));
<span class="k">const</span> <span class="n">useCase</span> = <span class="n">getArticlesUseCase</span>(<span class="n">repo</span>);
<span class="k">const</span> <span class="n">result</span> = <span class="k">await</span> <span class="n">useCase</span>({ <span class="n">status</span>: <span class="s">"published"</span> });
<span class="n">expect</span>(<span class="n">result</span>).<span class="n">toHaveLength</span>(<span class="n">1</span>);
});</pre>
</div>
</details>
</div>
</div>
</section>
<!-- ─── 05 ─ TRADEOFFS BY PART ──────────────────────────────────────── -->
<section id="tradeoffs">
<div class="section-head">
<div class="section-num">§ 05</div>
<div>
<h2 class="section-title">Tradeoffs <em>by part</em>.</h2>
<p class="section-blurb">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.</p>
</div>
</div>
<div class="tradeoffs-grid">
<div class="tradeoff-card">
<div class="tag">entities/models/</div>
<h3>Entity Zod schemas + types</h3>
<p class="blurb">One file per entity (<code>article.ts</code>, <code>user.ts</code>): a Zod schema and the inferred TypeScript type.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>One source of truth: schema and type from a single declaration via <code>z.infer</code></li>
<li>Runtime validation available anywhere the schema is imported (output schemas, parser-transforms)</li>
<li>Pure-domain — zero framework knowledge, infinitely portable</li>
<li>Test factories build against this same shape, can't drift</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Adds Zod as a feature-level dep where a plain TS interface would suffice</li>
<li>Schema can drift from Payload's actual collection shape — must keep in sync manually</li>
<li>Overkill for entities that never get parsed at runtime</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">entities/errors/</div>
<h3>Domain error classes</h3>
<p class="blurb">One file per error domain (<code>article.ts</code>, <code>auth.ts</code>) plus <code>common.ts</code> for <code>InputParseError</code>.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Domain errors carry meaning — <code>ArticleNotFoundError</code> beats a generic <code>Error</code> by miles</li>
<li><code>defineErrorMiddleware</code> matches by <code>instanceof</code> and translates to <code>TRPCError</code> codes</li>
<li>Per-feature ownership — auth's errors don't leak into blog</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Every constructor must set <code>this.name</code> (R6) — easy to forget, was the systemic Plan-9 fix-up</li>
<li><code>InputParseError</code> is duplicated per feature (~6 lines × 5) — by design, but feels redundant</li>
<li>Adding a new error class = update the feature's <code>procedures.ts</code> map too</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">application/repositories/</div>
<h3>Repository interfaces</h3>
<p class="blurb"><code>&lt;x&gt;.repository.interface.ts</code> — TypeScript interface, no implementation, no Zod.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Use cases depend on the contract, not a concrete class — testable, swappable</li>
<li>Mock and real impl share the interface — TypeScript catches drift at compile time</li>
<li>Plain TypeScript — no extra deps, no runtime cost</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>One more file per repo (interface + real + mock = 3 minimum)</li>
<li>Method names get written twice (interface + each impl) — refactors touch both</li>
<li>Wider repo signatures vs narrower use-case schemas can feel duplicative</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">application/services/</div>
<h3>Service interfaces</h3>
<p class="blurb"><code>&lt;x&gt;.service.interface.ts</code> — interface for non-data-access boundaries (auth, email, …).</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Same testability win as repos — stub the boundary, inject the stub</li>
<li>Lets stateful behavior (sessions, password hashing) sit behind a clean contract</li>
<li>Use cases don't import auth lib internals — they call methods on an interface</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Easy to over-create services for thin one-method wrappers</li>
<li>The "service" name is fuzzy — can become a junk drawer of "anything that's not a repo"</li>
<li>Most features don't need any (only auth has one today)</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">application/use-cases/</div>
<h3>Factory-function use cases</h3>
<p class="blurb">One file per verb-noun (<code>get-articles.use-case.ts</code>): input + output schemas + factory.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Single source of truth for I/O contracts (R1) — schema lives here, controllers and routers import</li>
<li><code>xOutputSchema.parse(...)</code> at the end of the body catches malformed repo returns at the layer that owns the contract</li>
<li>Factory function = trivially testable; tests construct mocks and inject directly</li>
<li>One concern per file; clear single responsibility</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>~30 lines of file overhead per use case (input + output + types + factory)</li>
<li>Identity output schemas (<code>z.array(articleSchema)</code>) feel ceremonial when they don't add validation</li>
<li>Many files per feature (blog has 3, media has 3, marketing-pages has 2)</li>
<li><code>.parse()</code> on every call has measurable cost on hot paths (negligible in practice)</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">infrastructure/repositories/&lt;x&gt;.repository.ts</div>
<h3>Real (Payload-backed) repository</h3>
<p class="blurb">Constructor takes <code>SanitizedConfig</code>, methods call <code>getPayload({ config })</code>, map to domain.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>All Payload knowledge lives in one place per repo — easy to swap CMS later</li>
<li>Constructor injection keeps the feature boundary clean (no <code>core-cms</code> dep)</li>
<li>Class names without <code>Payload</code> prefix — DI swaps mock ↔ real cleanly</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Per-method <code>getPayload({ config })</code> is repetitive</li>
<li><code>toDomain</code> mappers are easy to forget for new fields → silent shape drift</li>
<li>Tests need <code>vi.mock("payload")</code> + Payload stub setup — more ceremony than testing the mock impl</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">infrastructure/repositories/&lt;x&gt;.repository.mock.ts</div>
<h3>Mock repository (sibling of real)</h3>
<p class="blurb">In-memory implementation. The default DI binding; also injected directly in unit tests.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Dev mode runs without Payload booted — <code>pnpm dev</code> just works</li>
<li>Direct test injection — no DI ceremony for use-case / controller tests</li>
<li>Same interface as real impl; contract suite proves behavioral parity</li>
<li>Fast and deterministic — no setup, no fixtures, no I/O</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Without the contract suite, mock can silently diverge from real (e.g. ID-uniqueness assumptions)</li>
<li>Doubles the repo file count (one real + one mock per repo)</li>
<li>Easy to give the mock "extra" behavior the real impl can't match (false confidence)</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">infrastructure/services/</div>
<h3>Real + mock services</h3>
<p class="blurb">Same dual-impl pattern as repositories. Auth has one (real <code>AuthenticationService</code> + mock).</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Lets stateful boundary code (session creation, password hashing) be unit-tested with a fake</li>
<li>Real impl can defer hard parts as <code>NotImplementedError</code> while the mock fully works (auth's session methods do this today)</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Adds a directory tree most features don't need</li>
<li>"Service" abstraction can hide what's actually being mocked — repos are clearer</li>
<li>Deferred-real-impl pattern is honest but technical-debt-shaped</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">interface-adapters/controllers/</div>
<h3>Factory controllers + presenter</h3>
<p class="blurb">One file per use case. Receives <code>unknown</code>, <code>safeParse</code>s, calls use case, runs presenter.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Transport-agnostic — same controller works from tRPC, CLI, server actions, cron</li>
<li>Owns input parsing — <code>InputParseError</code> is the controller's responsibility, never the use case's</li>
<li>Co-located <code>function presenter</code> means view-shape transforms live next to the wire</li>
<li>One controller per use case — clear single responsibility, easy to test</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Schema runs twice on the tRPC path (procedure <code>.input</code> + controller <code>safeParse</code>) — defense in depth has a cost</li>
<li>Identity presenters feel ceremonial when no transform is needed (R11 always-present rule)</li>
<li>Per-use-case files multiply — blog has 3, marketing-pages has 2</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">di/symbols.ts</div>
<h3>The address book</h3>
<p class="blurb">Plain object of <code>Symbol.for("blog:I…")</code> keys. One per binding the container holds.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li><code>Symbol.for</code> namespacing prevents cross-feature collisions</li>
<li>Type-erased keys let the container index without forcing eager class imports</li>
<li>Const object — every binding has a compile-time-checked key</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Adding a use case = update three places: factory, symbols, module binding</li>
<li>Symbols carry no type info at runtime — bind/get must agree on the type parameter (footgun)</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">di/module.ts</div>
<h3>Default binding registry</h3>
<p class="blurb"><code>ContainerModule</code> with all repository, service, use-case, controller bindings.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Declarative — every binding visible in one block; easy to audit</li>
<li><code>.toDynamicValue</code> is what makes factory functions work as DI bindings</li>
<li>Loaded once at module construction; nothing runs per-request</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Verbose — each <code>.toDynamicValue((ctx) =&gt; factoryFn(ctx.container.get(...)))</code> repeats boilerplate</li>
<li>Imports every concrete class + factory in the feature — large surface area in one file</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">di/container.ts</div>
<h3>The singleton</h3>
<p class="blurb">Three lines: <code>reflect-metadata</code>, <code>new Container({ defaultScope: "Singleton" })</code>, <code>load(Module)</code>.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Singleton scope = automatic caching; subsequent <code>.get()</code> calls reuse the closure</li>
<li>One-line file — almost no maintenance</li>
<li>Per-feature container = vertical isolation; no cross-feature DI coupling (ADR-008)</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Module-level singleton = global state; tests must <code>unbindAll()</code> + reload to start fresh</li>
<li><code>import "reflect-metadata"</code> is a side-effect import — easy to forget when scaffolding new files</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">di/bind-production.ts</div>
<h3>Production binder</h3>
<p class="blurb"><code>bindProduction&lt;F&gt;(ctx: BindProductionContext)</code> — unbinds the mock, rebinds the real Payload-backed impl. The <code>ctx</code> arg carries required fields (<code>tracer</code>, <code>logger</code>, <code>config</code>) and optional cross-cutting deps (<code>queue</code>). Event bus (<code>bus</code>) is also optional — present only when <code>@repo/core-events</code> is scaffolded via <code>pnpm turbo gen core-package events</code>; absent, <code>bus?.subscribe/publish</code> calls are no-ops. Realtime deps (<code>realtime</code>, <code>realtimeRegistry</code>) are also optional — present only when <code>@repo/core-realtime</code> is scaffolded via <code>pnpm turbo gen core-package realtime</code>. Audit log (<code>auditLog</code>) is also optional — present only when <code>@repo/core-audit</code> is scaffolded via <code>pnpm turbo gen core-package audit</code>; absent, <code>ctx.auditLog?.record(entry)</code> calls are no-ops. When present, <code>auditLog</code> is a <code>TraceIdEnrichingAuditLog</code> that auto-populates <code>AuditEntry.correlationId</code> from the active OTel span.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Decouples Payload config from the feature package — boundary stays clean</li>
<li>Idempotent (<code>isBound</code> guard) — safe to call multiple times</li>
<li>Only the repo binding swaps; use cases + controllers stay put and pick up the new repo automatically</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Easy to forget to call when wiring a new app — silent fallback to mock in production</li>
<li>One per feature × app — boilerplate compounds with feature count</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">di/bind-dev-seed.ts</div>
<h3>Dev-seed binder</h3>
<p class="blurb"><code>bindDevSeed&lt;F&gt;(ctx: BindContext)</code> — unbinds the empty mock, rebinds a populated mock (post-Plan-9).</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Dev mode shows realistic data without Payload running — design review, storybook, offline work all just work</li>
<li>Reuses the same factory the tests use — no separate fixture system</li>
<li>Symmetric with <code>bind-production</code> — one mental model, two binders</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Yet another file per feature</li>
<li>Seed data drifts from real Payload shape over time — needs occasional refresh</li>
<li>Fourth place to update when entity schemas change (after schema, factory, repo)</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">integrations/api/procedures.ts</div>
<h3>Feature-scoped tRPC procedure</h3>
<p class="blurb"><code>xProcedure = t.procedure.use(defineErrorMiddleware([...]))</code> — owns the feature's error-to-code map.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Feature owns its error → <code>TRPCError</code> mapping — no central registry, no <code>core-shared</code> coupling</li>
<li>Adding an error class = one tuple in this file; type system guides you</li>
<li><code>defineErrorMiddleware</code> in <code>core-shared</code> is plumbing only — boundary stays clean</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Adds a fifth file to <code>integrations/api/</code> — a feature with two procedures has the same overhead as one with ten</li>
<li>The <code>InputParseError → BAD_REQUEST</code> tuple is dormant on the tRPC path (tRPC's own zod parse fires first) — feels theatrical</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">integrations/api/router.ts</div>
<h3>tRPC router slice</h3>
<p class="blurb">One file per feature, composed into <code>core-api</code>'s <code>appRouter</code> via the <code>./api</code> export.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li><code>xProcedure.input(xInputSchema)</code> — schemas imported from the use-case file, never redefined</li>
<li>One slice per feature — easy to add or remove a feature from the API</li>
<li>Container is the only thing the router knows about — controllers are resolved, not imported</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Router files grow proportional to procedure count — blog already has three</li>
<li>Routers can't be tested without DI involvement — <code>createCaller</code> goes through the container</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">integrations/cms/</div>
<h3>Payload collections + globals</h3>
<p class="blurb">Collection / global definitions exposed via the <code>./cms</code> export, composed into <code>core-cms</code>.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Each collection lives with its feature — no central <code>cms-core</code> grab-bag</li>
<li>Hooks for the feature's domain logic (revalidation, slugify) are co-located</li>
<li><code>core-cms</code> just composes; features stay independently versionable</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Coupling to Payload's API surface — swapping CMS later is non-trivial</li>
<li>Collection schema can drift from the entity Zod schema — two places to keep in sync</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">ui/</div>
<h3>Frontend public surface</h3>
<p class="blurb"><code>ui/index.ts</code> exports query builders + components. Apps import via <code>@repo/&lt;feature&gt;/ui</code>.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Apps depend on the feature's UI surface explicitly — clear separation from contracts</li>
<li>Query builders sit next to the data they query — refactoring is local</li>
<li>Components for feature-specific UI (e.g. <code>ArticleCard</code>) live with the feature, not in <code>core-ui</code> (<em>optional package — scaffold via <code>pnpm turbo gen core-package ui</code></em>)</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Features without UI today (auth, media) still need a placeholder <code>export {}</code></li>
<li>React Query API surface coupling — swapping client lib touches every feature</li>
<li>The <code>./ui</code> subpath is a fifth public-API entry to maintain per feature</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">__factories__/</div>
<h3>Test data factories</h3>
<p class="blurb">One factory per entity. Sequence-counter defaults; <code>.build({ overrides })</code>; <code>.reset()</code> in <code>beforeEach</code>.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Tests stop drowning in inline fixtures — only override what the assertion cares about</li>
<li>Sequence-driven defaults = deterministic IDs across runs</li>
<li>Shared between unit tests, contract suites, and <code>__seeds__/</code> — one source of "valid entity"</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Factory defaults can become outdated as entity schema evolves — Zod tightens, factory still hands out old shape, tests pass but production fails</li>
<li>Forgetting <code>.reset()</code> in <code>beforeEach</code> causes flaky test ordering</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">__contracts__/</div>
<h3>Repository contract suite</h3>
<p class="blurb">A portable test suite parameterized by <code>buildSubject</code>. Run against mock + real impl.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Catches mock / real divergence at unit-test time, not in production</li>
<li>The behavioral contract is a literal artifact, not folklore</li>
<li>Twelve <code>it()</code> blocks run twice — twenty-four assertions for the price of writing twelve</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>~50 lines of test boilerplate per repository</li>
<li>Adding a method to the interface means writing the contract test before either impl can pass</li>
<li>Easy to over-specify and lock implementation details (e.g. assert exact ID format the mock happens to produce)</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">__seeds__/</div>
<h3>Dev-seed data</h3>
<p class="blurb"><code>buildDev&lt;Entities&gt;()</code> — uses the feature's factory; consumed by <code>bind-dev-seed</code>.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>App-level realistic data without Payload running — storybook, design review, offline dev</li>
<li>Lazy function = side-effect-free at module load (factory sequence not advanced on import)</li>
<li>Reuses factory defaults — no parallel fixture system</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>The <code>__</code> prefix borrows from the test convention but this folder is reachable from runtime via DI — slightly mislabeled</li>
<li>Seed entities can rot — refreshes when entity schema or domain model changes</li>
<li>Yet another place where realistic data lives — tests, factories, seeds, real Payload all need to agree</li>
</ul></div>
</div>
</div>
<div class="tradeoff-card">
<div class="tag">src/index.ts (root)</div>
<h3>Public contract surface</h3>
<p class="blurb">The feature's <code>.</code> export — types, errors, schemas, IUseCase aliases, router type, constants.</p>
<div class="pc-cols">
<div class="pros"><h5>Pros</h5><ul>
<li>Single file lists everything the feature exposes — easy to audit, easy to enforce</li>
<li>Clean split from <code>./ui</code>: contracts here, UI artifacts there</li>
<li>Type aliases (<code>IXUseCase</code>, <code>IXController</code>) decouple consumers from the impl</li>
</ul></div>
<div class="cons"><h5>Cons</h5><ul>
<li>Maintenance burden — every new schema / error / type needs an explicit re-export</li>
<li>Easy to forget; downstream consumers can't reach a symbol that wasn't re-exported</li>
</ul></div>
</div>
</div>
</div>
</section>
<!-- ─── 06 ─ TRACING & ERROR CAPTURE ─────────────────────────────────── -->
<section id="tracing">
<div class="section-head">
<div class="section-num">§ 06</div>
<div>
<h2 class="section-title">Tracing &amp; <em>error capture</em>.</h2>
<p class="section-blurb">Every request produces a nested span tree: tRPC procedure → controller → use case → repository → Payload op. Errors are captured at the throw site closest to the cause, never at the boundary that translates them.</p>
</div>
</div>
<h3 class="trace-h3">The trace tree (one tRPC request)</h3>
<p class="trace-p"><strong>Substrate: OpenTelemetry SDK</strong> (ADR-017). Sentry is the exporter via <code>@sentry/opentelemetry</code>. Auto-instrumentations cover HTTP, undici, and pg; feature code emits via <code>ITracer</code> / <code>ILogger</code> interfaces only.</p>
<pre class="trace-tree"><code>HTTP transaction (auto, OTel HttpInstrumentation)
└── tRPC procedure span (auto, OTel + Sentry tRPC integration)
└── controller span (op="controller", composed at DI bind time via OtelTracer)
└── use-case span (op="use-case", composed at DI bind time via OtelTracer)
└── repository span (op="repository", inline per method via ITracer)
└── Payload Local API call (auto, OTel PgInstrumentation / UndiciInstrumentation)</code></pre>
<h3 class="trace-h3">Where instrumentation actually lives</h3>
<p class="trace-p">Two ways spans + captures get attached. <strong>Inline</strong> means the call appears in the layer's own body. <strong>Composed-in</strong> means a higher-order wrapper applied at DI bind time — the body stays vendor-clean.</p>
<table class="capture-rules instrumentation-where">
<thead>
<tr><th>Layer</th><th>Span</th><th>Capture</th><th>How</th></tr>
</thead>
<tbody>
<tr>
<td>Use case body</td>
<td></td>
<td></td>
<td>Composed: <code>withSpan(withCapture(useCase(deps)))</code> in <code>bind-production.ts</code></td>
</tr>
<tr>
<td>Controller body</td>
<td></td>
<td></td>
<td>Composed: <code>withSpan(withCapture(controller(uc)))</code> in <code>bind-production.ts</code></td>
</tr>
<tr>
<td>Repository (real)</td>
<td><strong>Inline</strong> per method</td>
<td><strong>Inline</strong> in <code>catch</code></td>
<td><code>this.tracer.startSpan(...)</code> + <code>this.logger.captureException(...)</code></td>
</tr>
<tr>
<td>Repository (mock)</td>
<td><strong>Inline</strong> per method</td>
<td></td>
<td>Span shape parity with real; mocks don't originate infra errors</td>
</tr>
<tr>
<td>tRPC procedure</td>
<td>Auto (SDK)</td>
<td></td>
<td>Sentry's tRPC integration — no code in this repo</td>
</tr>
<tr>
<td><code>defineErrorMiddleware</code></td>
<td></td>
<td></td>
<td>Maps domain errors → TRPCError. Never captures (R44 boundary)</td>
</tr>
</tbody>
</table>
<p class="trace-p"><strong>Verifiable:</strong> <code>grep -rn "this.tracer\|this.logger" packages/*/src</code> returns hits only in <code>infrastructure/repositories/*.repository.ts</code> and <code>*.repository.mock.ts</code>. Use case and controller bodies have zero matches. <code>withSpan</code> / <code>withCapture</code> appear only in <code>di/bind-*.ts</code> files.</p>
<h3 class="trace-h3">The wrapper sandwich (one feature, in <code>bind-production.ts</code>)</h3>
<pre class="trace-tree"><code>// Repository — inline, per public method
class ArticlesRepository {
async getArticles(input) {
return this.tracer.startSpan(
{ name: "articles.getArticles", op: "repository", attributes: { /* ... */ } },
async (span) =&gt; {
try {
const result = await /* payload op */;
span.setAttribute("count", result.length);
return result;
} catch (err) {
this.logger.captureException(err, {
tags: { feature: "blog", repo: "articles", method: "getArticles" },
});
span.setStatus("error", String(err));
throw err;
}
},
);
}
}
// Use cases + controllers — composed at bind time, body stays clean
const wrappedUC = withSpan(
tracer, { name: "blog.getArticles", op: "use-case" },
withCapture(
logger, { feature: "blog", layer: "use-case", name: "blog.getArticles" },
getArticlesUseCase(repo),
),
);
const wrappedCtrl = withSpan(
tracer, { name: "blog.getArticles", op: "controller" },
withCapture(
logger, { feature: "blog", layer: "controller", name: "blog.getArticles" },
getArticlesController(wrappedUC),
),
);</code></pre>
<p class="trace-p"><strong>Order matters.</strong> <code>withSpan</code> is outermost so the errored span's timing reflects the captured-and-rethrown failure. <code>withCapture</code> is between span and factory so the error is captured <em>before</em> the span closes with error status.</p>
<h3 class="trace-h3">Capture rules (where <code>captureException</code> fires)</h3>
<table class="capture-rules">
<thead>
<tr><th>Layer</th><th>Captures</th><th>Doesn't capture</th></tr>
</thead>
<tbody>
<tr>
<td>Repository</td>
<td>Infra / Payload errors that originate here</td>
<td>Bubbled errors (already captured downstream)</td>
</tr>
<tr>
<td>Use case</td>
<td>Business-rule violations originated in this body (e.g. <code>AuthenticationError</code>) and output-schema validation failures</td>
<td>Errors from repos — flag is set, <code>withCapture</code> bails</td>
</tr>
<tr>
<td>Controller</td>
<td><code>InputParseError</code> from <code>safeParse</code> failure</td>
<td>Errors from use cases — flag is set, <code>withCapture</code> bails</td>
</tr>
<tr>
<td><code>defineErrorMiddleware</code></td>
<td><strong>Nothing</strong> — maps domain → TRPCError only</td>
<td></td>
</tr>
</tbody>
</table>
<h3 class="trace-h3">Double-report guard</h3>
<p class="trace-p">Each error gets a non-enumerable <code>__sentryReported</code> flag the first time it's captured. <code>withCapture</code>, <code>OtelLogger</code>, and <code>RecordingLogger</code> all check the flag and bail if it's set. So an error bubbling repo → use-case → controller surfaces in the logger <strong>exactly once</strong>, with the inner-most layer's tags. Helper lives in <code>core-shared/instrumentation/reported-flag.ts</code>.</p>
<h3 class="trace-h3">PII rules (R31R38, non-negotiable)</h3>
<ul class="pii-rules">
<li><code>sendDefaultPii: false</code> — every <code>Sentry.init()</code>. CI grep gate.</li>
<li>Replay <strong>default-masks all text + inputs + media</strong>. Allowlist starts empty.</li>
<li><strong>Server-side:</strong> <code>PiiScrubSpanProcessor</code> + <code>PiiScrubLogRecordProcessor</code> run FIRST in the OTel processor chain — attribute-key substring match strips email / password / token / cookie / authorization / ipaddress keys before the Sentry exporter sees the data (R32, R33, ADR-017 §7).</li>
<li><strong>Browser-side:</strong> <code>beforeSend</code> / <code>beforeSendTransaction</code> hooks in <code>init-client*.ts</code> strip the same PII keys (browser does not use the OTel pipeline).</li>
<li><code>setUser</code> accepts only <code>{ id }</code>. No email/username (R36).</li>
<li>IPv4/IPv6 redacted to <code>[redacted-ip]</code> in browser-side scrubbers.</li>
</ul>
</section>
<!-- ─── 07 ─ VERDICT ─────────────────────────────────────────────────── -->
<section id="verdict" style="border-bottom: 0;">
<div class="section-head">
<div class="section-num">§ 07</div>
<div>
<h2 class="section-title">Do we <em>need</em> them?</h2>
</div>
</div>
<div class="verdict-block">
<h3>Short answer: <em>yes</em>, both.<br>Long answer below.</h3>
<p><strong>Contracts</strong> earn their keep the day a real implementation drifts from its mock — when a Payload field name changes, when a return shape mutates, when null vs undefined gets blurred. The contract suite catches the divergence at unit-test time instead of in production. Cost: ~50 lines per repo. Payoff: every behavioral guarantee gets tested twice (mock + real) for free.</p>
<p><strong>Factories</strong> earn theirs by the third test. Inline fixtures grow to a noisy 810 lines that obscure what the test is actually checking. <code>articleFactory.build({ slug: "x" })</code> says "I need a valid article and I only care about the slug." Nothing else.</p>
<p>Honest tradeoff: small upfront cost (one factory + one contract per feature). Large compounding payoff once you have ≥3 tests touching the entity, or any time you add a second impl behind the same interface. They are not optional ceremony — they are the thing that lets you trust your mocks.</p>
<div class="badge-row">
<div class="badge"><strong>360 tests</strong>across 15 suites · contracts run 2× per repo</div>
<div class="badge"><strong>R25 + R26</strong>output-validation + error-mapping (Plan 9)</div>
<div class="badge"><strong>defineFactory · defineContractSuite</strong>both live in @repo/core-testing</div>
</div>
</div>
</section>
</main>
<footer class="frame">
<div class="foot-grid">
<div class="foot-col">
<h5>Sibling explainer</h5>
<a href="di-explainer.html">di-explainer.html</a>
<a href="audit-and-compliance-explainer.html">audit-and-compliance-explainer.html</a>
</div>
<div class="foot-col">
<h5>Architecture</h5>
<a href="overview.md">overview.md</a>
<a href="dependency-flow.md">dependency-flow.md</a>
<a href="vertical-feature-spec.md">vertical-feature-spec.md</a>
</div>
<div class="foot-col">
<h5>Decisions</h5>
<a href="../decisions/adr-008-per-feature-di-containers.md">adr-008 · per-feature DI</a>
<a href="../decisions/adr-011-tdd-foundation.md">adr-011 · TDD foundation</a>
<a href="../decisions/adr-012-lazar-conformance.md">adr-012 · Lazar conformance</a>
<a href="../decisions/adr-013-input-output-unification.md">adr-013 · I/O unification</a>
</div>
<div class="foot-col">
<h5>Refactor logs</h5>
<a href="../superpowers/refactor-logs/2026-05-05-lazar-pattern-conformance.md">2026-05-05 · Plan 8</a>
<a href="../superpowers/refactor-logs/2026-05-06-input-output-unification.md">2026-05-06 · Plan 9</a>
</div>
<div class="foot-col">
<h5>Guides</h5>
<a href="../guides/adding-a-feature.md">adding-a-feature.md</a>
<a href="../guides/tdd-workflow.md">tdd-workflow.md</a>
<a href="../guides/testing-strategy.md">testing-strategy.md</a>
</div>
</div>
<div class="colophon">
Set in Fraunces &amp; JetBrains Mono. Single-file HTML, no build step. Open it in any browser.
</div>
</footer>
<script>
// ─── Layer card data (anatomy) ───────────────────────────────────────
const LAYERS = {
entities: {
tag: "domain",
title: "Entities — the <em>nouns</em>",
body: "Pure domain shapes with zero outside knowledge. Every model is a Zod schema with a co-located <code>z.infer</code> type. Errors live one folder over and set <code>this.name = \"&lt;ClassName&gt;\"</code> in the constructor — that's R6 from Plan 9.",
meta: "Examples · <strong>Article, User, Cookie, Page, Header, Media · ArticleNotFoundError, AuthenticationError, InputParseError</strong>"
},
application: {
tag: "rules",
title: "Application — the <em>verbs</em>",
body: "Use cases live here, plus the interfaces they consume. After Plan 9 every use case is a factory: <code>(deps) ⇒ async (input) ⇒ result</code>. The file owns its <code>xInputSchema</code>, its <code>xOutputSchema</code>, and the runtime <code>.parse(...)</code> call before returning. Repository and service interfaces sit next door, blissfully unaware of who implements them.",
meta: "Examples · <strong>getArticlesUseCase, signInUseCase, deleteMediaUseCase</strong> · IArticlesRepository, IAuthenticationService"
},
infrastructure: {
tag: "implementations",
title: "Infrastructure — the <em>real world</em>",
body: "Two siblings per repository: <code>&lt;x&gt;.repository.ts</code> (real, Payload-backed, takes config in the constructor) and <code>&lt;x&gt;.repository.mock.ts</code> (in-memory, used by default in DI). Both are <code>@injectable</code>; both implement the same interface; both run through the same contract suite to prove they behave identically.",
meta: "Examples · <strong>ArticlesRepository · MockArticlesRepository · AuthenticationService · MockAuthenticationService</strong>"
},
adapters: {
tag: "transport boundary",
title: "Interface adapters — <em>controllers</em>",
body: "One controller per use case (Lazar's rule). It takes <code>unknown</code> input, runs <code>safeParse</code> against the use-case's schema, throws <code>InputParseError</code> on failure, calls the use case, then runs the result through a top-level <code>function presenter(value)</code> defined in the same file. Identity is fine — <code>return value;</code> — but the function form always exists so adding a transform later is one edit.",
meta: "Examples · <strong>getArticlesController · signInController · deleteMediaController</strong> (void return — no presenter)"
},
di: {
tag: "wiring",
title: "DI — <em>per-feature</em> container",
body: "Symbols are <code>Symbol.for(\"&lt;feature&gt;:I&lt;X&gt;\")</code> keys. The <code>ContainerModule</code> wires them: the repository to its mock by default; use cases and controllers via <code>.toDynamicValue</code> so their factory functions are constructed on every <code>container.get()</code>. Three binders sit alongside: <code>bind-production.ts</code> swaps the mock for the real Payload impl, <code>bind-dev-seed.ts</code> swaps the empty mock for a populated one. App boot's <code>bindAll()</code> picks one based on <code>USE_DEV_SEED</code> + <code>NODE_ENV</code>. See the dedicated <a href=\"di-explainer.html\">DI explainer page</a> for the full lifecycle.",
meta: "Files · symbols.ts · module.ts · container.ts · bind-production.ts · bind-dev-seed.ts"
},
seeds: {
tag: "dev ergonomics",
title: "<em>__seeds__/</em> — dev-mode data",
body: "A lazy <code>buildDev&lt;Entities&gt;()</code> function per feature, built on top of the feature's existing factory. The dev-seed binder calls it to populate a <code>MockXRepository</code> at app boot when <code>USE_DEV_SEED=true</code> or <code>NODE_ENV ≠ 'production'</code>. Tests never touch this file — they construct mocks and seed via factories per-test.",
meta: "Powered by · the feature's __factories__ · consumed by di/bind-dev-seed.ts"
},
integrations: {
tag: "outside world",
title: "Integrations — <em>tRPC + CMS</em>",
body: "Where the feature meets the framework. <code>integrations/api/procedures.ts</code> (Plan 9) builds an <code>xProcedure</code> with <code>defineErrorMiddleware</code> applied — it owns the feature's domain-error → <code>TRPCError</code> mapping. <code>router.ts</code> uses that procedure plus <code>.input(xInputSchema)</code> imported from the use case. <code>cms/</code> exports Payload collection / global definitions consumed by <code>core-cms</code>.",
meta: "api/procedures.ts · api/router.ts · cms/collections/&lt;x&gt;.ts · cms/globals/&lt;x&gt;.ts"
},
ui: {
tag: "frontend surface",
title: "UI — <em>queries &amp; components</em>",
body: "Plan 9 split the public surface: feature root (<code>@repo/blog</code>) exports <em>contracts only</em> — types, errors, schemas, IUseCase aliases, router type, constants. <code>./ui</code> exports React Query option builders and (eventually) components. Apps import schemas from the root for forms, queries from <code>./ui</code> for hooks. No mixing.",
meta: "ui/index.ts · ui/query.ts · @repo/&lt;feature&gt;/ui subpath in package.json"
},
testing: {
tag: "test ergonomics",
title: "<em>__factories__</em> &amp; <em>__contracts__</em>",
body: "Two test-only utilities. Factories build valid entities with one-line overrides (<code>articleFactory.build({ slug: \"x\" })</code>). Contracts are portable behavioral suites that run against any implementation of a repository interface — same suite passes against the mock and the real Payload impl, or one of them fails and you've found a bug. See §04.",
meta: "Powered by · @repo/core-testing/factory · @repo/core-testing/contract"
},
public: {
tag: "package.json exports",
title: "<em>index.ts</em> — the public surface",
body: "Six subpaths per feature: <code>.</code> (contracts: types, errors, schemas, IUseCase / IController aliases, router type, constants), <code>./ui</code> (queries + components), <code>./api</code> (the tRPC router — only <code>core-api</code> consumes it), <code>./cms</code> (Payload collections — only <code>core-cms</code> consumes), <code>./di/bind-production</code> (production binder, called by app boot), <code>./di/bind-dev-seed</code> (dev-seed binder, also called by app boot or by storybook). Anything else is private.",
meta: "Six subpaths · enforced by ESLint boundaries + Turborepo + the package.json exports map"
}
};
const tree = document.getElementById('tree');
const layerCard = document.getElementById('layerCard');
function renderLayer(layer) {
const data = LAYERS[layer] || LAYERS.entities;
layerCard.innerHTML = `
<div class="layer-tag">${data.tag}</div>
<h3>${data.title}</h3>
<p>${data.body}</p>
<div class="meta">${data.meta}</div>
`;
layerCard.classList.remove('fade-in');
void layerCard.offsetWidth;
layerCard.classList.add('fade-in');
}
tree.querySelectorAll('.tree-row').forEach(row => {
row.addEventListener('click', () => {
const layer = row.dataset.layer;
tree.querySelectorAll('.tree-row').forEach(r => r.classList.toggle('is-active', r.dataset.layer === layer));
renderLayer(layer);
});
});
// initialize
document.querySelectorAll('.tree-row[data-layer="application"]').forEach(r => r.classList.add('is-active'));
renderLayer('application');
// ─── Feature data ─────────────────────────────────────────────────────
const FEATURES = {
blog: {
name: "blog",
useCase: "getArticles",
procedure: "blogProcedure",
container: "blogContainer",
symbols: "BLOG_SYMBOLS",
controllerSymbol: "IGetArticlesController",
inputSchemaName: "getArticlesInputSchema",
outputSchemaName: "getArticlesOutputSchema",
repoInterface: "IArticlesRepository",
repoMock: "MockArticlesRepository",
repoReal: "ArticlesRepository",
notFound: "ArticleNotFoundError",
errorMap: [
["InputParseError", "BAD_REQUEST"],
["ArticleNotFoundError", "NOT_FOUND"],
],
presenterReturns: "the array unchanged (identity presenter)",
presenterCode: `function presenter(value: GetArticlesOutput) {\n return value;\n}`,
isVoidPresenter: false,
queryName: "listArticlesQuery",
queryArg: "{ status: \"published\" }",
},
auth: {
name: "auth",
useCase: "signIn",
procedure: "authProcedure",
container: "authContainer",
symbols: "AUTH_SYMBOLS",
controllerSymbol: "ISignInController",
inputSchemaName: "signInInputSchema",
outputSchemaName: "signInOutputSchema",
repoInterface: "IUsersRepository",
repoMock: "MockUsersRepository",
repoReal: "UsersRepository",
notFound: "AuthenticationError",
errorMap: [
["InputParseError", "BAD_REQUEST"],
["AuthenticationError", "UNAUTHORIZED"],
["UnauthenticatedError", "UNAUTHORIZED"],
["UnauthorizedError", "FORBIDDEN"],
],
presenterReturns: "value.cookie (drops session, returns just the cookie)",
presenterCode: `function presenter(value: SignInOutput) {\n return value.cookie;\n}`,
isVoidPresenter: false,
queryName: "(no query — sign-in is a mutation)",
queryArg: "{ username, password }",
},
"marketing-pages": {
name: "marketing-pages",
useCase: "getPageBySlug",
procedure: "marketingPagesProcedure",
container: "marketingPagesContainer",
symbols: "MARKETING_PAGES_SYMBOLS",
controllerSymbol: "IGetPageBySlugController",
inputSchemaName: "getPageBySlugInputSchema",
outputSchemaName: "getPageBySlugOutputSchema",
repoInterface: "IPagesRepository",
repoMock: "MockPagesRepository",
repoReal: "PagesRepository",
notFound: "PageNotFoundError",
errorMap: [
["InputParseError", "BAD_REQUEST"],
["PageNotFoundError", "NOT_FOUND"],
],
presenterReturns: "the page (identity); use case returns undefined for missing slug — semantics preserved",
presenterCode: `function presenter(value: GetPageBySlugOutput) {\n return value;\n}`,
isVoidPresenter: false,
queryName: "pageBySlugQuery",
queryArg: "{ slug: \"about\" }",
},
navigation: {
name: "navigation",
useCase: "getHeader",
procedure: "navigationProcedure",
container: "navigationContainer",
symbols: "NAVIGATION_SYMBOLS",
controllerSymbol: "IGetHeaderController",
inputSchemaName: "getHeaderInputSchema",
outputSchemaName: "getHeaderOutputSchema",
repoInterface: "IHeaderRepository",
repoMock: "MockHeaderRepository",
repoReal: "HeaderRepository",
notFound: "HeaderNotFoundError",
errorMap: [
["InputParseError", "BAD_REQUEST"],
["HeaderNotFoundError", "NOT_FOUND"],
],
presenterReturns: "the header (identity); use case throws HeaderNotFoundError on missing data",
presenterCode: `function presenter(value: GetHeaderOutput) {\n return value;\n}`,
isVoidPresenter: false,
queryName: "headerQuery",
queryArg: "{} (void input — z.object({}).strict())",
},
media: {
name: "media",
useCase: "deleteMedia",
procedure: "mediaProcedure",
container: "mediaContainer",
symbols: "MEDIA_SYMBOLS",
controllerSymbol: "IDeleteMediaController",
inputSchemaName: "deleteMediaInputSchema",
outputSchemaName: null,
repoInterface: "IMediaRepository",
repoMock: "MockMediaRepository",
repoReal: "MediaRepository",
notFound: "MediaNotFoundError",
errorMap: [
["InputParseError", "BAD_REQUEST"],
["MediaNotFoundError", "NOT_FOUND"],
],
presenterReturns: "nothing — controller returns Promise<void> with no presenter (R12)",
presenterCode: `// no presenter — void output\nasync (input: unknown): Promise<void> => {\n ...\n await deleteMediaUseCase(parsed.data);\n}`,
isVoidPresenter: true,
queryName: "(no query — deleteMedia is a mutation)",
queryArg: "{ id: \"media-123\" }",
},
};
// ─── Stages ───────────────────────────────────────────────────────────
function makeStages(F) {
const useCaseFile = `application/use-cases/${kebab(F.useCase)}.use-case.ts`;
const controllerFile = `interface-adapters/controllers/${kebab(F.useCase)}.controller.ts`;
const stages = [
{
id: "client",
title: "Frontend query",
meta: `@repo/${F.name}/ui`,
file: `packages/${F.name}/src/ui/query.ts`,
tag: "client",
prose: `<code>${F.queryName}</code> on the client builds a typed React Query option object. The args type is inferred all the way back from the use case's <code>${F.inputSchemaName}</code>, exported from the feature root. <em>Note: <code>useTRPC()</code> is provided by <code>@repo/core-trpc</code> — an optional package. Scaffold it with <code>pnpm turbo gen core-package trpc</code> if not yet present.</em>`,
code: F.queryName.startsWith("(") ?
`// auth procedures are mutations — invoked via useMutation, not useQuery
const trpc = useTRPC();
const signIn = useMutation(trpc.${F.name}.${F.useCase}.mutationOptions());
signIn.mutate(${F.queryArg});` :
`import { ${F.queryName} } from "@repo/${F.name}/ui";
const trpc = useTRPC();
const { data } = useQuery(${F.queryName}(trpc, ${F.queryArg}));`,
lang: "typescript // app code",
kind: "request",
},
{
id: "trpc",
title: "tRPC procedure",
meta: `${F.procedure}.input(${F.inputSchemaName})`,
file: `packages/${F.name}/src/integrations/api/router.ts`,
tag: "boundary",
prose: `tRPC parses the input through <code>${F.inputSchemaName}</code> — the <em>same schema</em> the use case will use. If parse fails here, tRPC throws <code>TRPCError(BAD_REQUEST)</code> natively. The mutation handler resolves the controller from the feature container and calls it.`,
code: `${F.useCase}: ${F.procedure}
.input(${F.inputSchemaName})
.${F.useCase.startsWith("get") || F.useCase === "list" || F.useCase === "getHeader" ? "query" : "mutation"}(({ input }) => {
const ctrl = ${F.container}.get<${F.controllerSymbol}>(
${F.symbols}.${F.controllerSymbol},
);
return ctrl(input);
})`,
lang: "typescript // integrations/api/router.ts",
kind: "request",
},
{
id: "middleware",
title: "defineErrorMiddleware",
meta: `${F.procedure} = t.procedure.use(...)`,
file: `packages/${F.name}/src/integrations/api/procedures.ts`,
tag: "wrap",
prose: `Every <code>${F.procedure}</code> call passes through this middleware, which wraps the handler in a try/catch (well — actually <code>result.ok</code> inspection because tRPC v11). On the way IN, it does nothing; on the way OUT (or on a throw), it inspects the cause against the feature's error tuples and remaps to <code>TRPCError</code>. <em>core-shared</em> never knows your error class names — you pass them in.`,
code: `import { defineErrorMiddleware } from "@repo/core-shared/trpc/define-error-middleware";
export const ${F.procedure} = t.procedure.use(
defineErrorMiddleware([
${F.errorMap.map(([err, code]) => ` [${err}, "${code}"],`).join("\n")}
]),
);`,
lang: "typescript // integrations/api/procedures.ts",
kind: "request",
},
{
id: "controller-parse",
title: "Controller · safeParse",
meta: `${kebab(F.useCase)}.controller.ts`,
file: `packages/${F.name}/src/${controllerFile}`,
tag: "validate",
prose: `The controller takes <code>input: unknown</code> and runs <code>${F.inputSchemaName}.safeParse(input)</code>. If it fails, throws <code>InputParseError</code> with the Zod issue as <code>cause</code> — the middleware will catch this and map it to <code>BAD_REQUEST</code>. Yes the schema runs twice on the tRPC path. That's fine — the cost is trivial and the controller stays callable from non-tRPC entry points (CLI, cron, server actions).`,
code: F.isVoidPresenter ?
`export const ${F.useCase}Controller =
(${F.useCase}UseCase: I${capitalize(F.useCase)}UseCase) =>
async (input: unknown): Promise<void> => {
const parsed = ${F.inputSchemaName}.safeParse(input);
if (!parsed.success) {
throw new InputParseError(
"Invalid ${F.useCase} input",
{ cause: parsed.error },
);
}
await ${F.useCase}UseCase(parsed.data);
};` :
`export const ${F.useCase}Controller =
(${F.useCase}UseCase: I${capitalize(F.useCase)}UseCase) =>
async (input: unknown): Promise<ReturnType<typeof presenter>> => {
const parsed = ${F.inputSchemaName}.safeParse(input);
if (!parsed.success) {
throw new InputParseError(
"Invalid ${F.useCase} input",
{ cause: parsed.error },
);
}
const result = await ${F.useCase}UseCase(parsed.data);
return presenter(result);
};`,
lang: "typescript // interface-adapters/controllers/...",
kind: "request",
},
{
id: "usecase",
title: "Use case",
meta: F.outputSchemaName ? `body ends with ${F.outputSchemaName}.parse(...)` : `void output — no output schema`,
file: `packages/${F.name}/src/${useCaseFile}`,
tag: "business logic",
prose: F.outputSchemaName ?
`The use case is a curried factory — <code>(deps) ⇒ async (input) ⇒ result</code>. It calls the repository, then runs <code>${F.outputSchemaName}.parse(result)</code> before returning. That last <code>.parse</code> is a Plan 9 contract: the use case <em>guarantees</em> what comes out, so a misbehaving repo fails loudly here, not silently downstream.` :
`Void-output use case: takes input, calls the repository, returns nothing. No <code>xOutputSchema</code> means no <code>parse</code> at the end — there's nothing to validate. The presenter rule (R11) carves out an exception for these: void in, void out, no presenter.`,
code: F.outputSchemaName ?
`export const ${F.useCase}UseCase =
(${F.repoInterface[1].toLowerCase() + F.repoInterface.slice(2)}: ${F.repoInterface}) =>
async (input: ${capitalize(F.useCase)}Input): Promise<${capitalize(F.useCase)}Output> => {
const result = await ${F.repoInterface[1].toLowerCase() + F.repoInterface.slice(2)}.${F.useCase}(input);
return ${F.outputSchemaName}.parse(result);
};` :
`export const ${F.useCase}UseCase =
(mediaRepository: ${F.repoInterface}) =>
async (input: ${capitalize(F.useCase)}Input): Promise<void> => {
const existing = await mediaRepository.getMedia(input.id);
if (!existing) {
throw new ${F.notFound}(\`Media with id "\${input.id}" not found\`);
}
await mediaRepository.deleteMedia(input.id);
};`,
lang: "typescript // application/use-cases/...",
kind: "request",
},
{
id: "repository",
title: "Repository",
meta: `infrastructure · ${F.repoMock} (default) / ${F.repoReal} (prod)`,
file: `packages/${F.name}/src/infrastructure/repositories/...`,
tag: "data",
prose: `In dev / test, the in-memory <code>${F.repoMock}</code> answers. In production, the container has been rebound to a fresh <code>${F.repoReal}</code> instance constructed with the Payload config — same interface, same contract suite, different storage. The use case can't tell which one it's talking to.`,
code: `// 1) default binding (mock — used for tests + dev)
bind<${F.repoInterface}>(${F.symbols}.${F.repoInterface}).to(${F.repoMock});
// 2) production override (called from app boot)
${F.container}
.bind(${F.symbols}.${F.repoInterface})
.toConstantValue(new ${F.repoReal}(config));`,
lang: "typescript // di/module.ts + di/bind-production.ts",
kind: "request",
},
{
id: "payload",
title: "Payload Local API",
meta: "third-party · CMS",
file: "(payload package)",
tag: "external",
prose: `The real repository calls <code>getPayload({ config })</code> and uses Payload's local API to read/write Postgres. The mock just operates on a private array. Both implement the same interface, both pass the same contract suite.`,
code: `async getMedia(id: string): Promise<Media | undefined> {
const payload = await getPayload({ config: this.config });
const result = await payload.findByID({
collection: "media",
id,
overrideAccess: true,
});
return result ? mediaSchema.parse(this.toDomain(result)) : undefined;
}`,
lang: "typescript // infrastructure/repositories/<x>.repository.ts",
kind: "request",
},
{
id: "fork",
title: "(success or error?)",
meta: "this is the fork",
file: "—",
tag: "branching",
prose: `From here the request walks back up the stack. If the use case returned cleanly, the result threads back through the controller's presenter. If anything along the way threw a domain error, tRPC's middleware catches the cause via <code>defineErrorMiddleware</code> and remaps it to a <code>TRPCError</code> with the right code.`,
code: `// success: data flows back through presenter -> controller return -> tRPC response
// error: throw -> tRPC catch -> middleware checks instanceof on cause -> TRPCError(code)
// Same procedure call, two paths — picked by what the use case did.`,
lang: "outcome",
kind: "fork",
},
{
id: "presenter",
title: "Presenter (success path)",
meta: F.isVoidPresenter ? "void return — no presenter" : "controller's top-level function presenter()",
file: `packages/${F.name}/src/${controllerFile}`,
tag: "shape",
prose: F.isVoidPresenter ?
`For void use cases, there's no presenter and the controller resolves to <code>Promise&lt;void&gt;</code>. The tRPC procedure simply returns nothing — the client treats it as an ack.` :
`Co-located with the controller in the same file. Returns <code>${F.presenterReturns}</code>. The controller's full return type is <code>Promise&lt;ReturnType&lt;typeof presenter&gt;&gt;</code> — a literal copy of whatever the presenter outputs. tRPC's response type follows.`,
code: F.presenterCode,
lang: "typescript // top of the controller file",
kind: "success",
},
{
id: "trpc-error",
title: "Middleware (error path)",
meta: "defineErrorMiddleware translates",
file: `packages/${F.name}/src/integrations/api/procedures.ts`,
tag: "translate",
prose: `When the use case or controller threw a domain error, the middleware sees it on <code>result.error.cause</code>. It scans the registered tuples by <code>instanceof</code>; the first match wins. Throws a fresh <code>TRPCError({ code, cause })</code> with the original error preserved on <code>cause</code> — the client gets a structured error with both the code and the message.`,
code: `// inside defineErrorMiddleware (core-shared)
if (e instanceof Error) {
for (const [Ctor, code] of map) {
if (e instanceof Ctor) {
throw new TRPCError({ code, message: e.message, cause: e });
}
}
}
throw e; // unmapped → tRPC default INTERNAL_SERVER_ERROR`,
lang: "typescript // core-shared/trpc/define-error-middleware.ts",
kind: "error",
},
{
id: "response",
title: "tRPC response",
meta: "wire · superjson serialization",
file: "—",
tag: "client",
prose: `tRPC serializes the response through superjson (Date and other rich types survive the round-trip). On the client, React Query unwraps it into your typed <code>data</code>. On the server, the cycle is done.`,
code: `// on the client:
const { data } = useQuery(${F.queryName.startsWith("(") ? "..." : F.queryName + "(trpc, ...)"});
// data: ${F.outputSchemaName ? capitalize(F.useCase) + "Output (after presenter shape)" : "void"}
// fully typed end to end through inferRouterOutputs<AppRouter>`,
lang: "typescript // app code",
kind: "success",
},
];
return stages;
}
function kebab(s) { return s.replace(/[A-Z]/g, c => "-" + c.toLowerCase()); }
function capitalize(s) { return s.charAt(0).toUpperCase() + s.slice(1); }
// ─── Render flow ──────────────────────────────────────────────────────
const flowPipeline = document.getElementById('flowPipeline');
const stageDetail = document.getElementById('stageDetail');
const codesTable = document.getElementById('codesTable');
const stageProgress = document.getElementById('stageProgress');
const playBtn = document.getElementById('playStage');
const prevBtn = document.getElementById('prevStage');
const nextBtn = document.getElementById('nextStage');
let currentFeature = "blog";
let currentStages = makeStages(FEATURES[currentFeature]);
let currentStageIdx = 0;
let playing = false;
let playTimer = null;
function renderPipeline() {
// pop existing stage rows but keep controls
flowPipeline.querySelectorAll('.flow-stage, .flow-fork-divider').forEach(el => el.remove());
const ctrl = flowPipeline.querySelector('.flow-controls');
let insertedFork = false;
currentStages.forEach((stage, idx) => {
if (stage.kind === "fork" && !insertedFork) {
const div = document.createElement('div');
div.className = "flow-fork-divider";
div.textContent = "fork";
flowPipeline.insertBefore(div, ctrl);
insertedFork = true;
}
const row = document.createElement('div');
row.className = "flow-stage";
if (idx === currentStageIdx) row.classList.add('is-active');
if (stage.kind === "success") row.classList.add('fork-success');
if (stage.kind === "error") row.classList.add('fork-error');
row.dataset.idx = idx;
row.innerHTML = `
<div class="stage-num">${String(idx + 1).padStart(2, '0')}</div>
<div class="stage-body">
<div class="stage-name">${stage.title}</div>
<div class="stage-meta">${stage.meta}</div>
</div>
`;
row.addEventListener('click', () => {
currentStageIdx = idx;
stopPlay();
renderStage();
updatePipelineActive();
});
flowPipeline.insertBefore(row, ctrl);
});
updatePipelineActive();
}
function updatePipelineActive() {
flowPipeline.querySelectorAll('.flow-stage').forEach(row => {
row.classList.toggle('is-active', Number(row.dataset.idx) === currentStageIdx);
});
stageProgress.textContent = `${currentStageIdx + 1} / ${currentStages.length}`;
prevBtn.disabled = currentStageIdx === 0;
nextBtn.disabled = currentStageIdx === currentStages.length - 1;
}
function renderStage() {
const stage = currentStages[currentStageIdx];
const html = `
<div class="detail-tag">${stage.tag}</div>
<h3>${stage.title}</h3>
<div class="file-path">${stage.file}</div>
<p class="detail-prose">${stage.prose}</p>
<pre class="code" data-lang="${stage.lang}">${escapeHtml(stage.code)}</pre>
`;
stageDetail.innerHTML = html;
stageDetail.classList.remove('fade-in');
void stageDetail.offsetWidth;
stageDetail.classList.add('fade-in');
updatePipelineActive();
}
function escapeHtml(s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function renderCodesTable() {
const F = FEATURES[currentFeature];
const rows = F.errorMap.map(([err, code]) =>
`<div class="codes-row"><span class="err">${err}</span><span class="code"><span class="arrow">→</span> ${code}</span></div>`
).join('');
codesTable.innerHTML = `
<div class="codes-table-head">
<span>${F.procedure} · domain error → tRPC code</span>
<span style="text-align: right;">tuple count: ${F.errorMap.length}</span>
</div>
${rows}
`;
}
// ─── Feature picker ──────────────────────────────────────────────────
document.querySelectorAll('#featurePicker button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('#featurePicker button').forEach(b => b.setAttribute('aria-pressed', 'false'));
btn.setAttribute('aria-pressed', 'true');
currentFeature = btn.dataset.feature;
currentStages = makeStages(FEATURES[currentFeature]);
currentStageIdx = 0;
stopPlay();
renderPipeline();
renderStage();
renderCodesTable();
});
});
// ─── Controls ────────────────────────────────────────────────────────
prevBtn.addEventListener('click', () => {
if (currentStageIdx > 0) {
currentStageIdx--;
stopPlay();
renderStage();
}
});
nextBtn.addEventListener('click', () => {
if (currentStageIdx < currentStages.length - 1) {
currentStageIdx++;
stopPlay();
renderStage();
}
});
function startPlay() {
playing = true;
playBtn.textContent = "❚❚ pause";
playBtn.classList.remove('primary');
if (currentStageIdx >= currentStages.length - 1) currentStageIdx = 0;
renderStage();
playTimer = setInterval(() => {
if (currentStageIdx >= currentStages.length - 1) {
stopPlay();
return;
}
currentStageIdx++;
renderStage();
}, 2400);
}
function stopPlay() {
playing = false;
playBtn.textContent = "▶ play";
playBtn.classList.add('primary');
if (playTimer) { clearInterval(playTimer); playTimer = null; }
}
playBtn.addEventListener('click', () => playing ? stopPlay() : startPlay());
// ─── DI mode toggle ──────────────────────────────────────────────────
const diMockBtn = document.getElementById('diMockBtn');
const diProdBtn = document.getElementById('diProdBtn');
const repoNode = document.getElementById('repoNode');
const bridgeRepo = document.getElementById('bridgeRepo');
function setDiMode(mode) {
if (mode === 'prod') {
diMockBtn.setAttribute('aria-pressed', 'false');
diProdBtn.setAttribute('aria-pressed', 'true');
repoNode.classList.remove('is-mock');
repoNode.classList.add('is-real');
repoNode.querySelector('.node-kind').textContent = 'class · real (Payload)';
repoNode.querySelector('.node-name').textContent = 'ArticlesRepository';
bridgeRepo.textContent = '.toConstantValue(new ArticlesRepository(config))';
} else {
diMockBtn.setAttribute('aria-pressed', 'true');
diProdBtn.setAttribute('aria-pressed', 'false');
repoNode.classList.remove('is-real');
repoNode.classList.add('is-mock');
repoNode.querySelector('.node-kind').textContent = 'class · mock';
repoNode.querySelector('.node-name').textContent = 'MockArticlesRepository';
bridgeRepo.textContent = '.to(MockArticlesRepository)';
}
}
diMockBtn.addEventListener('click', () => setDiMode('mock'));
diProdBtn.addEventListener('click', () => setDiMode('prod'));
// ─── Initialize ──────────────────────────────────────────────────────
renderPipeline();
renderStage();
renderCodesTable();
// Smooth-scroll anchors
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', e => {
const target = document.querySelector(a.getAttribute('href'));
if (target) {
e.preventDefault();
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
});
});
</script>
</body>
</html>