- Rename docs/decisions/adr-012-lazar-conformance.md → adr-012-feature-conventions.md - Strip "Lazar", "Plan 8/9/10/11", "refactor-logs" refs from all ADRs, architecture docs, HTML explainers, and feature/core AGENTS.md files - Update all incoming links in docs/, packages/*/AGENTS.md, HTML explainers Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1386 lines
71 KiB
HTML
1386 lines
71 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=1280">
|
|
<title>di-folder / 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;
|
|
}
|
|
|
|
* { 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);
|
|
}
|
|
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' opacity='0.25'><filter id='n'><feTurbulence baseFrequency='0.95' 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 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); }
|
|
|
|
.frame { max-width: 1280px; margin: 0 auto; padding: 0 56px; }
|
|
|
|
/* ─── 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-2); 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-2);
|
|
}
|
|
.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(7, 1fr); gap: 16px;
|
|
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-2); display: block; font-size: 11px; margin-bottom: 6px; letter-spacing: 0.18em; }
|
|
ol.contents li:hover .num { color: var(--accent); }
|
|
|
|
/* ─── Sections ───────────────────────────────────────────────────── */
|
|
|
|
main { padding-top: 56px; padding-bottom: 80px; }
|
|
section { padding: 80px 0; border-bottom: 1px solid var(--rule); }
|
|
.section-head {
|
|
display: grid; grid-template-columns: 110px 1fr; gap: 32px;
|
|
align-items: baseline; margin-bottom: 48px;
|
|
}
|
|
.section-num {
|
|
font-family: "JetBrains Mono", monospace; font-size: 11px;
|
|
letter-spacing: 0.22em; text-transform: uppercase;
|
|
color: var(--accent-2); padding-top: 12px;
|
|
}
|
|
.section-title {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 96, "SOFT" 0, "wght" 360;
|
|
font-size: clamp(40px, 5vw, 60px); 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-2);
|
|
}
|
|
.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;
|
|
}
|
|
p { line-height: 1.6; text-wrap: pretty; }
|
|
code { font-family: "JetBrains Mono", monospace; font-size: 12.5px; background: var(--paper-2); padding: 1px 5px; border-radius: 2px; }
|
|
|
|
/* ─── 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);
|
|
}
|
|
|
|
/* ─── Cast section (file cards) ──────────────────────────────────── */
|
|
|
|
.cast-grid {
|
|
display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px;
|
|
}
|
|
.cast-card {
|
|
border: 1px solid var(--rule-strong);
|
|
background: var(--paper-3);
|
|
padding: 28px 32px; border-radius: 4px;
|
|
position: relative;
|
|
}
|
|
.cast-card .cast-tag {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 10.5px; letter-spacing: 0.22em; text-transform: uppercase;
|
|
color: var(--accent-2); margin-bottom: 10px;
|
|
}
|
|
.cast-card h3 {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-weight: 500; font-size: 17px;
|
|
margin: 0 0 14px; color: var(--ink);
|
|
}
|
|
.cast-card .role {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 14, "SOFT" 40;
|
|
font-style: italic; font-size: 14px;
|
|
color: var(--ink-3); margin: 0 0 14px;
|
|
}
|
|
.cast-card p {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 16, "SOFT" 40;
|
|
font-size: 15.5px; line-height: 1.55;
|
|
color: var(--ink-2); margin: 0 0 12px;
|
|
}
|
|
.cast-card .runs-when {
|
|
margin-top: 16px; 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;
|
|
}
|
|
.cast-card .runs-when strong { color: var(--accent-2); font-weight: 500; }
|
|
|
|
/* ─── Lifecycle / sequence ───────────────────────────────────────── */
|
|
|
|
.lifecycle-stage {
|
|
display: grid; grid-template-columns: 60px 1fr 1fr;
|
|
gap: 32px; padding: 24px 0;
|
|
border-top: 1px solid var(--rule);
|
|
align-items: start;
|
|
}
|
|
.lifecycle-stage:first-child { border-top: 0; }
|
|
.lifecycle-stage:last-child { border-bottom: 1px solid var(--rule); }
|
|
.lifecycle-stage .step-num {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 22px; font-variation-settings: "wght" 500;
|
|
color: var(--accent-2);
|
|
font-feature-settings: "tnum";
|
|
}
|
|
.lifecycle-stage .step-body h4 {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 24, "SOFT" 30, "wght" 460;
|
|
font-size: 22px; margin: 0 0 8px; letter-spacing: -0.005em;
|
|
}
|
|
.lifecycle-stage .step-body h4 em {
|
|
font-style: italic;
|
|
font-variation-settings: "opsz" 24, "SOFT" 100, "wght" 460;
|
|
color: var(--accent-2);
|
|
}
|
|
.lifecycle-stage .step-body .when {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 10.5px; letter-spacing: 0.18em;
|
|
text-transform: uppercase; color: var(--ink-3);
|
|
margin-bottom: 10px;
|
|
}
|
|
.lifecycle-stage .step-body p {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 16, "SOFT" 40;
|
|
font-size: 15.5px; line-height: 1.55;
|
|
color: var(--ink-2); margin: 0 0 8px;
|
|
}
|
|
|
|
/* ─── Binding kinds ──────────────────────────────────────────────── */
|
|
|
|
.bindings-grid {
|
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px;
|
|
}
|
|
.binding-card {
|
|
border: 1.5px solid var(--ink);
|
|
background: var(--paper);
|
|
padding: 24px 26px; border-radius: 4px;
|
|
display: flex; flex-direction: column;
|
|
}
|
|
.binding-card.dynamic {
|
|
border-color: var(--accent-2);
|
|
border-style: dashed;
|
|
}
|
|
.binding-card.constant {
|
|
border-color: var(--accent);
|
|
}
|
|
.binding-card .kind {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-weight: 500; font-size: 15px;
|
|
color: var(--ink); margin-bottom: 12px;
|
|
letter-spacing: 0.005em;
|
|
}
|
|
.binding-card.dynamic .kind { color: var(--accent-2); }
|
|
.binding-card.constant .kind { color: var(--accent); }
|
|
.binding-card .when {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 10px; letter-spacing: 0.22em;
|
|
text-transform: uppercase; color: var(--ink-3);
|
|
margin-bottom: 14px;
|
|
}
|
|
.binding-card p {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 15, "SOFT" 40;
|
|
font-size: 14.5px; line-height: 1.5;
|
|
color: var(--ink-2); margin: 0 0 10px;
|
|
flex: 1;
|
|
}
|
|
.binding-card .example {
|
|
margin-top: 12px;
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 11px;
|
|
background: var(--paper-3);
|
|
padding: 10px 12px;
|
|
border-radius: 3px;
|
|
color: var(--ink);
|
|
white-space: pre-wrap;
|
|
border: 1px dashed var(--rule-strong);
|
|
}
|
|
|
|
/* ─── Modes (interactive) ────────────────────────────────────────── */
|
|
|
|
.modes-controls {
|
|
display: flex; align-items: center; gap: 12px;
|
|
margin-bottom: 32px; flex-wrap: wrap;
|
|
}
|
|
.modes-controls .label {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 11px; letter-spacing: 0.22em;
|
|
text-transform: uppercase; color: var(--ink-3);
|
|
margin-right: 12px;
|
|
}
|
|
.pillbar {
|
|
display: inline-flex; gap: 0;
|
|
border: 1px solid var(--ink);
|
|
background: var(--paper-3);
|
|
padding: 4px; border-radius: 999px;
|
|
}
|
|
.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;
|
|
text-transform: lowercase;
|
|
}
|
|
.pillbar button:hover { color: var(--ink); }
|
|
.pillbar button[aria-pressed="true"] { background: var(--ink); color: var(--paper); }
|
|
|
|
.modes-board {
|
|
background: var(--paper-3);
|
|
border: 1px solid var(--rule-strong);
|
|
border-radius: 4px;
|
|
padding: 36px 40px;
|
|
position: relative;
|
|
}
|
|
.modes-board-head {
|
|
display: flex; justify-content: space-between; align-items: end;
|
|
margin-bottom: 28px; padding-bottom: 16px;
|
|
border-bottom: 1px solid var(--rule); gap: 20px;
|
|
}
|
|
.modes-board-head h3 {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 36, "SOFT" 30, "wght" 380;
|
|
font-size: 26px; margin: 0; letter-spacing: -0.005em;
|
|
}
|
|
.modes-board-head h3 em {
|
|
font-style: italic;
|
|
font-variation-settings: "opsz" 36, "SOFT" 100, "wght" 380;
|
|
color: var(--accent-2);
|
|
}
|
|
.modes-board-head .meta {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 10.5px; color: var(--ink-3);
|
|
letter-spacing: 0.06em;
|
|
}
|
|
|
|
.modes-state-grid {
|
|
display: grid;
|
|
grid-template-columns: 280px 1fr;
|
|
gap: 20px; align-items: start;
|
|
}
|
|
.modes-narrative {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 16, "SOFT" 40;
|
|
font-size: 15.5px; line-height: 1.55;
|
|
color: var(--ink); padding-right: 20px;
|
|
}
|
|
.modes-narrative .scenario-tag {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 10.5px; letter-spacing: 0.22em;
|
|
text-transform: uppercase; color: var(--accent);
|
|
margin-bottom: 8px;
|
|
}
|
|
.modes-narrative h4 {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 24, "SOFT" 30, "wght" 460;
|
|
font-size: 22px; line-height: 1.05;
|
|
letter-spacing: -0.005em;
|
|
margin: 0 0 12px;
|
|
}
|
|
.modes-narrative p { margin: 0 0 10px; }
|
|
|
|
.bindings-list {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 12.5px; line-height: 1.7;
|
|
}
|
|
.bindings-list .binding-row {
|
|
display: grid;
|
|
grid-template-columns: 1fr auto 1fr;
|
|
gap: 14px; align-items: center;
|
|
padding: 10px 14px; border-radius: 3px;
|
|
background: var(--paper);
|
|
border: 1px solid var(--rule);
|
|
margin-bottom: 6px;
|
|
}
|
|
.bindings-list .binding-row.changed {
|
|
border-color: var(--accent);
|
|
background: #fff7e8;
|
|
}
|
|
.bindings-list .binding-row .symbol { color: var(--ink); }
|
|
.bindings-list .binding-row .arrow { color: var(--ink-3); font-size: 11px; }
|
|
.bindings-list .binding-row .impl { color: var(--accent-2); font-size: 12px; }
|
|
.bindings-list .binding-row.changed .impl { color: var(--accent); font-weight: 500; }
|
|
.bindings-list .binding-row .kind-badge {
|
|
font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase;
|
|
color: var(--ink-3); display: block; margin-top: 2px;
|
|
}
|
|
|
|
/* ─── Conditions table ───────────────────────────────────────────── */
|
|
|
|
.conditions-table {
|
|
margin-top: 12px;
|
|
border: 1px solid var(--rule-strong);
|
|
background: var(--paper-3);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
}
|
|
.conditions-head {
|
|
display: grid;
|
|
grid-template-columns: 220px 1fr 220px;
|
|
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;
|
|
}
|
|
.conditions-row {
|
|
display: grid;
|
|
grid-template-columns: 220px 1fr 220px;
|
|
padding: 16px 22px;
|
|
border-top: 1px solid var(--rule);
|
|
align-items: baseline;
|
|
font-size: 14px;
|
|
}
|
|
.conditions-row code { font-size: 12px; }
|
|
.conditions-row .scenario {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 14, "SOFT" 40;
|
|
font-style: italic;
|
|
color: var(--ink-2); font-size: 14px;
|
|
}
|
|
.conditions-row .trigger {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 12px;
|
|
color: var(--ink);
|
|
}
|
|
.conditions-row .outcome {
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 12px;
|
|
color: var(--accent-2);
|
|
}
|
|
|
|
/* ─── Connection diagram ─────────────────────────────────────────── */
|
|
|
|
.connection-diagram {
|
|
position: relative;
|
|
margin: 32px auto;
|
|
max-width: 760px;
|
|
background: var(--paper-3);
|
|
border: 1px solid var(--rule-strong);
|
|
border-radius: 4px;
|
|
padding: 36px;
|
|
}
|
|
.connection-row {
|
|
display: grid;
|
|
gap: 8px;
|
|
grid-template-columns: 1fr;
|
|
align-items: stretch;
|
|
}
|
|
.connection-pair {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 14px;
|
|
}
|
|
.cnode {
|
|
border: 1px solid var(--ink);
|
|
background: var(--paper);
|
|
padding: 14px 18px;
|
|
border-radius: 3px;
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 12.5px;
|
|
position: relative;
|
|
}
|
|
.cnode .file-tag {
|
|
font-size: 9.5px; letter-spacing: 0.22em;
|
|
text-transform: uppercase; color: var(--ink-3);
|
|
margin-bottom: 4px;
|
|
}
|
|
.cnode strong {
|
|
display: block;
|
|
color: var(--ink);
|
|
font-weight: 500;
|
|
margin-bottom: 4px;
|
|
}
|
|
.cnode .what {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 13, "SOFT" 40;
|
|
font-style: italic;
|
|
font-size: 12.5px;
|
|
color: var(--ink-2);
|
|
}
|
|
.cnode.imports-via {
|
|
border-left: 4px solid var(--accent-2);
|
|
}
|
|
.cnode.imports-from {
|
|
border-left: 4px solid var(--accent);
|
|
}
|
|
.cnode.imported-by {
|
|
background: var(--paper-2);
|
|
}
|
|
.connector {
|
|
display: flex; justify-content: center; align-items: center;
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 9.5px; letter-spacing: 0.22em;
|
|
text-transform: uppercase; color: var(--ink-3);
|
|
padding: 8px 0;
|
|
position: relative;
|
|
}
|
|
.connector::before, .connector::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 50%;
|
|
width: 1px;
|
|
height: 8px;
|
|
background: var(--rule-strong);
|
|
transform: translateX(-0.5px);
|
|
}
|
|
.connector::before { top: 0; }
|
|
.connector::after { bottom: 0; }
|
|
|
|
/* ─── Tests bypass section ───────────────────────────────────────── */
|
|
|
|
.bypass-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 28px;
|
|
margin-top: 16px;
|
|
}
|
|
.bypass-card {
|
|
border: 1px solid var(--rule-strong);
|
|
background: var(--paper-3);
|
|
padding: 28px 32px;
|
|
border-radius: 4px;
|
|
}
|
|
.bypass-card.with-di {
|
|
border-color: var(--accent-2);
|
|
border-style: dashed;
|
|
}
|
|
.bypass-card.no-di {
|
|
border-color: var(--ok);
|
|
}
|
|
.bypass-card h4 {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 24, "SOFT" 30, "wght" 460;
|
|
font-size: 22px;
|
|
margin: 0 0 12px;
|
|
letter-spacing: -0.005em;
|
|
}
|
|
.bypass-card h4 em {
|
|
font-style: italic;
|
|
font-variation-settings: "opsz" 24, "SOFT" 100, "wght" 460;
|
|
}
|
|
.bypass-card.with-di h4 em { color: var(--accent-2); }
|
|
.bypass-card.no-di h4 em { color: var(--ok); }
|
|
.bypass-card p {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 15, "SOFT" 40;
|
|
font-size: 14.5px; line-height: 1.5;
|
|
color: var(--ink-2); margin: 0 0 12px;
|
|
}
|
|
|
|
/* ─── Boundary card ──────────────────────────────────────────────── */
|
|
|
|
.boundary-card {
|
|
background: var(--ink);
|
|
color: var(--paper);
|
|
padding: 48px 56px;
|
|
border-radius: 4px;
|
|
position: relative;
|
|
overflow: hidden;
|
|
}
|
|
.boundary-card::before {
|
|
content: "";
|
|
position: absolute; inset: 0;
|
|
background-image:
|
|
radial-gradient(circle at 90% 10%, rgba(31, 61, 74, 0.6), transparent 50%),
|
|
radial-gradient(circle at 5% 90%, rgba(125, 40, 40, 0.4), transparent 50%);
|
|
pointer-events: none;
|
|
}
|
|
.boundary-card h3 {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 72, "SOFT" 0, "wght" 360;
|
|
font-size: 44px; line-height: 1.0;
|
|
letter-spacing: -0.018em;
|
|
margin: 0 0 20px; position: relative;
|
|
max-width: 18ch;
|
|
}
|
|
.boundary-card h3 em {
|
|
font-style: italic;
|
|
font-variation-settings: "opsz" 72, "SOFT" 100, "wght" 360;
|
|
color: var(--accent-3);
|
|
}
|
|
.boundary-card p {
|
|
font-family: "Fraunces", serif;
|
|
font-variation-settings: "opsz" 17, "SOFT" 40;
|
|
font-size: 16.5px; line-height: 1.6;
|
|
max-width: 60ch; margin: 0 0 14px;
|
|
position: relative; color: rgba(244, 236, 216, 0.85);
|
|
}
|
|
.boundary-card p strong { color: var(--paper); font-weight: 500; }
|
|
.boundary-card pre.code {
|
|
position: relative;
|
|
margin-top: 24px;
|
|
background: rgba(0, 0, 0, 0.45);
|
|
border-color: rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
/* ─── 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 h5 {
|
|
font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase;
|
|
margin: 0 0 12px; color: var(--ink-3); font-weight: 500;
|
|
}
|
|
footer 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;
|
|
}
|
|
|
|
/* ─── §08 Instrumentation symbols ──────────────────────────────── */
|
|
|
|
.instrumentation-grid {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 36px;
|
|
margin-top: 24px;
|
|
}
|
|
.trace-h3 {
|
|
font-family: "Fraunces", serif;
|
|
font-weight: 500;
|
|
font-size: 1.05rem;
|
|
margin: 24px 0 12px;
|
|
color: var(--paper);
|
|
}
|
|
.trace-h3:first-child { margin-top: 0; }
|
|
.trace-p {
|
|
margin: 0 0 16px;
|
|
max-width: 60ch;
|
|
font-size: 0.95rem;
|
|
}
|
|
.trace-tree {
|
|
background: var(--code-bg);
|
|
color: var(--code-paper);
|
|
padding: 1rem 1.25rem;
|
|
border-radius: 4px;
|
|
overflow-x: auto;
|
|
font-family: "JetBrains Mono", monospace;
|
|
font-size: 0.82rem;
|
|
line-height: 1.55;
|
|
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);
|
|
}
|
|
|
|
/* ─── Responsive ─────────────────────────────────────────────────── */
|
|
|
|
@media (max-width: 1100px) {
|
|
.frame { padding: 0 32px; }
|
|
.cast-grid, .bindings-grid, .modes-state-grid, .bypass-grid, .instrumentation-grid { grid-template-columns: 1fr; }
|
|
ol.contents { grid-template-columns: repeat(2, 1fr); }
|
|
.title-block { grid-template-columns: 1fr; gap: 32px; }
|
|
.conditions-head, .conditions-row { grid-template-columns: 1fr; gap: 4px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<header class="masthead frame">
|
|
<div class="folio-row">
|
|
<span class="folio-label"><span class="folio-mark"></span> template-vertical / di-folder / explainer</span>
|
|
<span>2026-05-06</span>
|
|
</div>
|
|
<div class="title-block">
|
|
<h1 class="title">The <em>di/</em> folder,<br>file by file.</h1>
|
|
<p class="lede">Every feature has six files in <code>src/di/</code> that wire its repositories, services, use cases and controllers into the InversifyJS container. Here is what each file does, when its code runs, what conditions select which binding mode, and how tests bypass it entirely.</p>
|
|
</div>
|
|
<div class="contents-row">
|
|
<div class="contents-label">Contents</div>
|
|
<ol class="contents">
|
|
<li><a href="#cast"><span class="num">01</span>The cast</a></li>
|
|
<li><a href="#connections"><span class="num">02</span>Connections</a></li>
|
|
<li><a href="#lifecycle"><span class="num">03</span>Lifecycle</a></li>
|
|
<li><a href="#bindings"><span class="num">04</span>Binding kinds</a></li>
|
|
<li><a href="#modes"><span class="num">05</span>Three modes</a></li>
|
|
<li><a href="#conditions"><span class="num">06</span>Conditions</a></li>
|
|
<li><a href="#tests"><span class="num">07</span>Tests bypass</a></li>
|
|
<li><a href="#instrumentation"><span class="num">08</span>Instrumentation symbols</a></li>
|
|
</ol>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="frame">
|
|
|
|
<!-- ─── 01 ─ THE CAST ──────────────────────────────────────────────── -->
|
|
<section id="cast">
|
|
<div class="section-head">
|
|
<div class="section-num">§ 01</div>
|
|
<div>
|
|
<h2 class="section-title">The <em>cast</em>.</h2>
|
|
<p class="section-blurb">Six files (per feature) live in <code>src/di/</code>. Three are loaded automatically when the container module is imported; three are dispatched explicitly from app boot or from tests. Reading them in this order is the right mental model.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cast-grid">
|
|
<div class="cast-card">
|
|
<div class="cast-tag">file 01 · keys</div>
|
|
<h3>symbols.ts</h3>
|
|
<p class="role">The address book. Plain object whose values are <code>Symbol.for(...)</code> keys, one per binding the container holds.</p>
|
|
<p>Symbols are the type-erased hooks the container indexes by. Every <code>bind</code> call on the container references one of these symbols; every <code>container.get</code> call passes one back in. Without symbols you would need either string keys (collision-prone) or class references (forces eager imports). <code>Symbol.for("blog:IGetArticlesUseCase")</code> is namespaced to the feature so two features can both have an <code>IGetArticlesUseCase</code> binding without colliding.</p>
|
|
<div class="runs-when"><strong>When it runs:</strong> module load. Pure constants — zero behavior.</div>
|
|
</div>
|
|
|
|
<div class="cast-card">
|
|
<div class="cast-tag">file 02 · the wiring</div>
|
|
<h3>module.ts</h3>
|
|
<p class="role">The default binding map. Imports every concrete class and factory function in the feature, registers each one under its symbol.</p>
|
|
<p>Exports a <code>ContainerModule</code> built with <code>new ContainerModule((bind) => { ... })</code>. Inside the callback, three kinds of bindings are registered: <code>.to(Class)</code> for repositories that have <code>@injectable</code> classes (the mock impl), and <code>.toDynamicValue((ctx) => factory(ctx.container.get(...)))</code> for use-case and controller factory functions. The module is just a description of bindings — nothing executes until something asks the container to resolve a symbol.</p>
|
|
<div class="runs-when"><strong>When it runs:</strong> module load (registration only). The callback inside <code>ContainerModule</code> only runs when the module is loaded onto a container.</div>
|
|
</div>
|
|
|
|
<div class="cast-card">
|
|
<div class="cast-tag">file 03 · the singleton</div>
|
|
<h3>container.ts</h3>
|
|
<p class="role">Constructs the singleton container, loads the module, and exports the container instance.</p>
|
|
<p>Three lines: <code>import "reflect-metadata"</code> (required by inversify's decorator metadata), <code>new Container({ defaultScope: "Singleton" })</code>, <code>container.load(BlogModule)</code>. <em>Now</em> the module's binding callback runs — every <code>bind()</code> call inside it executes and registers its symbol. The container does not yet construct any of the bound implementations. Resolution is lazy.</p>
|
|
<div class="runs-when"><strong>When it runs:</strong> first <code>import { blogContainer } from "./container"</code>. Module-level side effects. Once per process (Node caches imports).</div>
|
|
</div>
|
|
|
|
<div class="cast-card">
|
|
<div class="cast-tag">file 04 · prod swap</div>
|
|
<h3>bind-production.ts</h3>
|
|
<p class="role">Replaces the mock repository binding with a real Payload-backed one at app boot.</p>
|
|
<p>Exports <code>bindProductionBlog(ctx: BindProductionContext)</code>. Destructures <code>{ config, tracer, logger, bus, queue, realtime, realtimeRegistry }</code> from <code>ctx</code>. Function body: <code>blogContainer.unbind(symbol)</code> if already bound, then <code>.bind(symbol).toConstantValue(new ArticlesRepository(config, tracer, logger))</code>. Use cases and controllers are wrapped via <code>withSpan(withCapture(factory(deps)))</code> at bind time so they inherit instrumentation without changing their factory bodies. The optional <code>bus</code> and <code>queue</code> fields come from the app's <code>resolveEventsAndJobs*</code> step (ADR-015) and feed the <code>// <gen:event-handlers></code> / <code>// <gen:jobs></code> injection sites. <code>BindProductionContext</code> is imported from <code>@repo/core-shared/di</code> — features never import the optional packages directly for the binder signature.</p>
|
|
<div class="runs-when"><strong>When it runs:</strong> called from app boot (<code>apps/web-next/src/server/bind-production.ts</code>) when <code>USE_DEV_SEED ≠ "true"</code> AND Payload config is resolvable.</div>
|
|
</div>
|
|
|
|
<div class="cast-card">
|
|
<div class="cast-tag">file 05 · seeded mock</div>
|
|
<h3>bind-dev-seed.ts</h3>
|
|
<p class="role">Replaces the empty mock with a populated mock so the running app shows realistic data without Payload.</p>
|
|
<p>Exports <code>bindDevSeedBlog(ctx)</code>. Same shape as <code>bind-production</code> — unbind, rebind. The difference: it constructs a fresh <code>MockArticlesRepository</code>, seeds it via <code>buildDevArticles()</code> from <code>src/__seeds__/dev.ts</code>, and binds the populated instance via <code>.toConstantValue(repo)</code>. Mutually exclusive with <code>bindProductionBlog</code> — both operate on the same symbol.</p>
|
|
<div class="runs-when"><strong>When it runs:</strong> called from app boot when <code>USE_DEV_SEED === "true"</code>. Storybook stories that need data may call this directly.</div>
|
|
</div>
|
|
|
|
<div class="cast-card">
|
|
<div class="cast-tag">file 06 · proof</div>
|
|
<h3>container.test.ts</h3>
|
|
<p class="role">Unit test that proves every symbol resolves and that the default binding is the mock.</p>
|
|
<p>Tests run with <code>blogContainer.unbindAll()</code> in <code>beforeEach</code> and <code>blogContainer.load(BlogModule)</code> to start from a clean slate. Then assertions like <code>expect(repo).toBeInstanceOf(MockArticlesRepository)</code> and <code>expect(typeof ctrl).toBe("function")</code>. Cheap insurance against typos in <code>module.ts</code>.</p>
|
|
<div class="runs-when"><strong>When it runs:</strong> <code>pnpm test --filter @repo/blog</code>. Never in production.</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 02 ─ CONNECTIONS ──────────────────────────────────────────── -->
|
|
<section id="connections">
|
|
<div class="section-head">
|
|
<div class="section-num">§ 02</div>
|
|
<div>
|
|
<h2 class="section-title">How they <em>connect</em>.</h2>
|
|
<p class="section-blurb">Module imports point in one direction; the loading sequence flows in another. Here is who imports who, and who runs first.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="connection-diagram">
|
|
<div class="connection-row">
|
|
<div class="cnode">
|
|
<div class="file-tag">file 01</div>
|
|
<strong>symbols.ts</strong>
|
|
<div class="what">Pure constants — exports BLOG_SYMBOLS. No imports from other di/ files.</div>
|
|
</div>
|
|
<div class="connector">imported by ↓</div>
|
|
<div class="cnode imports-from">
|
|
<div class="file-tag">file 02</div>
|
|
<strong>module.ts</strong>
|
|
<div class="what">Imports symbols.ts + every impl class + every factory function. Builds ContainerModule.</div>
|
|
</div>
|
|
<div class="connector">imported by ↓</div>
|
|
<div class="cnode imports-from">
|
|
<div class="file-tag">file 03</div>
|
|
<strong>container.ts</strong>
|
|
<div class="what">Imports symbols.ts + module.ts. Constructs Container, calls .load(BlogModule). Exports the singleton.</div>
|
|
</div>
|
|
<div class="connector">imported by ↓</div>
|
|
<div class="connection-pair">
|
|
<div class="cnode imports-via">
|
|
<div class="file-tag">file 04 · prod path</div>
|
|
<strong>bind-production.ts</strong>
|
|
<div class="what">Imports container.ts + symbols.ts + the real ArticlesRepository class. App calls it at boot.</div>
|
|
</div>
|
|
<div class="cnode imports-via">
|
|
<div class="file-tag">file 05 · dev path</div>
|
|
<strong>bind-dev-seed.ts</strong>
|
|
<div class="what">Imports container.ts + symbols.ts + MockArticlesRepository + buildDevArticles. App calls it at boot when USE_DEV_SEED.</div>
|
|
</div>
|
|
</div>
|
|
<div class="connector">tested by ↓ (separate import chain)</div>
|
|
<div class="cnode imported-by">
|
|
<div class="file-tag">file 06 · only at test time</div>
|
|
<strong>container.test.ts</strong>
|
|
<div class="what">Imports container.ts + symbols.ts + module.ts + MockArticlesRepository. Asserts every symbol resolves correctly.</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p style="font-family: 'Fraunces', serif; font-variation-settings: 'opsz' 16, 'SOFT' 40; font-size: 15.5px; line-height: 1.6; color: var(--ink-2); max-width: 70ch; margin: 32px 0 0;"><strong style="color: var(--ink); font-weight: 500;">Key fact:</strong> nothing inside <code>di/</code> imports the app. The relationship is one-way: the app imports <code>./di/bind-production</code> (or <code>./di/bind-dev-seed</code>), the feature exports those binders. The feature has no idea what app is running it. That is what keeps <code>core-shared</code> and the feature packages boundary-clean while still letting Payload config flow in.</p>
|
|
</section>
|
|
|
|
<!-- ─── 03 ─ LIFECYCLE ────────────────────────────────────────────── -->
|
|
<section id="lifecycle">
|
|
<div class="section-head">
|
|
<div class="section-num">§ 03</div>
|
|
<div>
|
|
<h2 class="section-title">The <em>loading sequence</em>.</h2>
|
|
<p class="section-blurb">Eight things happen between <code>node</code> starting and the first request reaching a controller. Some run once at module-load time, some run per-process at app boot, some run per-request.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="lifecycle-stages">
|
|
<div class="lifecycle-stage">
|
|
<div class="step-num">01</div>
|
|
<div class="step-body">
|
|
<div class="when">Module load · once per process</div>
|
|
<h4>Some code path imports <em>blogContainer</em>.</h4>
|
|
<p>Could be the tRPC router, could be a feature test, could be the app's bind-production module. Whatever it is, the import triggers Node to evaluate <code>packages/blog/src/di/container.ts</code>.</p>
|
|
</div>
|
|
<pre class="code" data-lang="typescript // packages/blog/src/integrations/api/router.ts"><span class="k">import</span> { <span class="n">blogContainer</span> } <span class="k">from</span> <span class="s">"../../di/container"</span>;</pre>
|
|
</div>
|
|
|
|
<div class="lifecycle-stage">
|
|
<div class="step-num">02</div>
|
|
<div class="step-body">
|
|
<div class="when">Module load · same evaluation</div>
|
|
<h4>container.ts <em>runs top-to-bottom</em>.</h4>
|
|
<p>Three statements: <code>import "reflect-metadata"</code>, <code>new Container({ defaultScope: "Singleton" })</code>, <code>blogContainer.load(BlogModule)</code>. The container instance is now memoized on the module; future imports reuse it.</p>
|
|
</div>
|
|
<pre class="code" data-lang="typescript // packages/blog/src/di/container.ts"><span class="k">import</span> <span class="s">"reflect-metadata"</span>;
|
|
<span class="k">import</span> { <span class="t">Container</span> } <span class="k">from</span> <span class="s">"inversify"</span>;
|
|
<span class="k">import</span> { <span class="n">BlogModule</span> } <span class="k">from</span> <span class="s">"./module"</span>;
|
|
|
|
<span class="k">export const</span> <span class="n">blogContainer</span> = <span class="k">new</span> <span class="t">Container</span>({ <span class="n">defaultScope</span>: <span class="s">"Singleton"</span> });
|
|
<span class="n">blogContainer</span>.<span class="n">load</span>(<span class="n">BlogModule</span>);</pre>
|
|
</div>
|
|
|
|
<div class="lifecycle-stage">
|
|
<div class="step-num">03</div>
|
|
<div class="step-body">
|
|
<div class="when">Module load · cascading import</div>
|
|
<h4>module.ts <em>evaluates</em>.</h4>
|
|
<p>Imports BLOG_SYMBOLS, the mock repo class, every use-case + controller factory. Defines <code>BlogModule</code> by passing a callback into <code>new ContainerModule((bind) => { ... })</code>. The callback does <em>not</em> run yet — it is stored on the module.</p>
|
|
</div>
|
|
<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="c">// callback body — stored, not executed yet</span>
|
|
<span class="n">bind</span><<span class="t">IArticlesRepository</span>>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IArticlesRepository</span>).<span class="n">to</span>(<span class="t">MockArticlesRepository</span>);
|
|
<span class="n">bind</span><<span class="t">IGetArticlesUseCase</span>>(<span class="n">BLOG_SYMBOLS</span>.<span class="n">IGetArticlesUseCase</span>).<span class="n">toDynamicValue</span>(<span class="c">/* ... */</span>);
|
|
});</pre>
|
|
</div>
|
|
|
|
<div class="lifecycle-stage">
|
|
<div class="step-num">04</div>
|
|
<div class="step-body">
|
|
<div class="when">Module load · `.load()` triggers</div>
|
|
<h4>The module callback <em>runs</em>; bindings register.</h4>
|
|
<p>This is where <code>bind<IArticlesRepository>(symbol).to(MockArticlesRepository)</code> actually executes. The container records the binding kind for each symbol. Still nothing constructed — the mock repo is a class reference, the use-case factory is a closure, both inert.</p>
|
|
</div>
|
|
<pre class="code" data-lang="container internal state · after step 04"><span class="c">// Internally, the container now holds a Map<Symbol, Binding>:</span>
|
|
<span class="t">Map</span> {
|
|
<span class="n">Symbol.for</span>(<span class="s">"blog:IArticlesRepository"</span>) => { <span class="n">kind</span>: <span class="s">"class"</span>, <span class="n">target</span>: <span class="t">MockArticlesRepository</span> },
|
|
<span class="n">Symbol.for</span>(<span class="s">"blog:IGetArticlesUseCase"</span>) => { <span class="n">kind</span>: <span class="s">"dynamicValue"</span>, <span class="n">factory</span>: <span class="n">fn</span> },
|
|
<span class="c">// ... 5 more</span>
|
|
}</pre>
|
|
</div>
|
|
|
|
<div class="lifecycle-stage">
|
|
<div class="step-num">05</div>
|
|
<div class="step-body">
|
|
<div class="when">App boot · once per server start</div>
|
|
<h4>app calls <em>bindAll()</em>.</h4>
|
|
<p>The web app's server entry point runs <code>bindAll()</code>, which checks <code>process.env.USE_DEV_SEED</code> and dispatches to either <code>bindAllProduction()</code> or <code>bindAllDevSeed()</code>. Each calls every feature's binder.</p>
|
|
</div>
|
|
<pre class="code" data-lang="typescript // apps/web-next/src/server/bind-production.ts"><span class="k">export async function</span> <span class="n">bindAll</span>(): <span class="t">Promise</span><<span class="t">void</span>> {
|
|
<span class="c">// 1. Explicit override wins, regardless of NODE_ENV.</span>
|
|
<span class="k">if</span> (<span class="n">process</span>.<span class="n">env</span>.<span class="n">USE_DEV_SEED</span> === <span class="s">"true"</span>) {
|
|
<span class="k">await</span> <span class="n">bindAllDevSeed</span>();
|
|
<span class="k">return</span>;
|
|
}
|
|
<span class="c">// 2. Production env → real Payload.</span>
|
|
<span class="k">if</span> (<span class="n">process</span>.<span class="n">env</span>.<span class="n">NODE_ENV</span> === <span class="s">"production"</span>) {
|
|
<span class="k">await</span> <span class="n">bindAllProduction</span>();
|
|
<span class="k">return</span>;
|
|
}
|
|
<span class="c">// 3. Default: dev seed, so `pnpm dev` boots without Payload.</span>
|
|
<span class="k">await</span> <span class="n">bindAllDevSeed</span>();
|
|
}</pre>
|
|
</div>
|
|
|
|
<div class="lifecycle-stage">
|
|
<div class="step-num">06</div>
|
|
<div class="step-body">
|
|
<div class="when">App boot · per feature</div>
|
|
<h4>Each binder <em>swaps the repo binding</em>.</h4>
|
|
<p>Whichever binder runs, it does the same thing for the repository symbol: <code>unbind</code> the old binding, <code>bind</code> a new <code>.toConstantValue(impl)</code>. Use case and controller bindings stay untouched — they keep their <code>.toDynamicValue</code> closures and will pick up the new repo on next resolve.</p>
|
|
</div>
|
|
<pre class="code" data-lang="typescript // packages/blog/src/di/bind-production.ts (or bind-dev-seed.ts)"><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">// or new MockArticlesRepository() with seed data</span></pre>
|
|
</div>
|
|
|
|
<div class="lifecycle-stage">
|
|
<div class="step-num">07</div>
|
|
<div class="step-body">
|
|
<div class="when">Per request · on demand</div>
|
|
<h4>tRPC handler calls <em>container.get(SYMBOL)</em>.</h4>
|
|
<p>A request hits a procedure. The handler asks the container for the controller. The container looks up the symbol, finds <code>kind: "dynamicValue"</code>, runs the factory closure. The closure calls <code>ctx.container.get</code> for the use case symbol, which itself runs another factory closure, which fetches the (now-rebound) repo. A controller closure is returned, ready to call.</p>
|
|
</div>
|
|
<pre class="code" data-lang="typescript // packages/blog/src/integrations/api/router.ts"><span class="n">listArticles</span>: <span class="n">blogProcedure</span>
|
|
.<span class="n">input</span>(<span class="n">getArticlesInputSchema</span>)
|
|
.<span class="n">query</span>(({ <span class="n">input</span> }) => {
|
|
<span class="k">const</span> <span class="n">ctrl</span> = <span class="n">blogContainer</span>.<span class="n">get</span><<span class="t">IGetArticlesController</span>>(
|
|
<span class="n">BLOG_SYMBOLS</span>.<span class="n">IGetArticlesController</span>, <span class="c">// ← lazy: factory closures fire here</span>
|
|
);
|
|
<span class="k">return</span> <span class="n">ctrl</span>(<span class="n">input</span>); <span class="c">// ← then we invoke the closure</span>
|
|
})</pre>
|
|
</div>
|
|
|
|
<div class="lifecycle-stage">
|
|
<div class="step-num">08</div>
|
|
<div class="step-body">
|
|
<div class="when">Singleton scope · cache</div>
|
|
<h4>Subsequent requests <em>get the same instance</em>.</h4>
|
|
<p>Because the container was constructed with <code>defaultScope: "Singleton"</code>, the controller closure (and every dependency it captured) is cached after the first <code>get()</code>. Subsequent calls within the same process reuse it. To get a fresh closure you must <code>unbind</code> and rebind, which is exactly what <code>bindProductionBlog</code> does for the repo symbol.</p>
|
|
</div>
|
|
<pre class="code" data-lang="implication"><span class="c">// First request:</span>
|
|
<span class="n">blogContainer</span>.<span class="n">get</span>(<span class="t">SYMBOL</span>); <span class="c">// runs factory, caches result</span>
|
|
|
|
<span class="c">// Second request:</span>
|
|
<span class="n">blogContainer</span>.<span class="n">get</span>(<span class="t">SYMBOL</span>); <span class="c">// returns cached value, no factory call</span>
|
|
|
|
<span class="c">// To get a fresh wiring (e.g. after rebinding the repo):</span>
|
|
<span class="n">blogContainer</span>.<span class="n">unbind</span>(<span class="t">SYMBOL</span>);
|
|
<span class="n">blogContainer</span>.<span class="n">bind</span>(<span class="t">SYMBOL</span>).<span class="n">toDynamicValue</span>(<span class="c">/* ... */</span>);</pre>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 04 ─ BINDING KINDS ────────────────────────────────────────── -->
|
|
<section id="bindings">
|
|
<div class="section-head">
|
|
<div class="section-num">§ 04</div>
|
|
<div>
|
|
<h2 class="section-title">Three <em>binding kinds</em>.</h2>
|
|
<p class="section-blurb">The container supports many binding modes; this codebase uses three. Each fits a different shape of dependency. Knowing which to reach for is most of "how do I add a thing to the container?"</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bindings-grid">
|
|
<div class="binding-card">
|
|
<div class="kind">.to(Class)</div>
|
|
<div class="when">use for · @injectable classes</div>
|
|
<p>The container will <code>new Class()</code> the first time the symbol is resolved (or use cached instance after, since scope is Singleton). Class must be decorated with <code>@injectable</code>. Constructor params are resolved via <code>@inject(SYMBOL)</code> decorators on the parameters.</p>
|
|
<div class="example">bind<IArticlesRepository>(SYM).to(MockArticlesRepository);</div>
|
|
</div>
|
|
|
|
<div class="binding-card dynamic">
|
|
<div class="kind">.toDynamicValue((ctx) => ...)</div>
|
|
<div class="when">use for · factory functions</div>
|
|
<p>The container runs the callback when the symbol is resolved. The callback receives a context object with <code>container.get</code> available, so you can fetch dependencies and pass them into the factory. Returns a closure (the wired-up function). Used for every use case and every controller in this codebase, because they are factory-style: <code>(deps) => async (input) => result</code>.</p>
|
|
<div class="example">bind<IGetArticlesUseCase>(SYM).toDynamicValue((ctx) =>
|
|
getArticlesUseCase(
|
|
ctx.container.get(BLOG_SYMBOLS.IArticlesRepository),
|
|
),
|
|
);</div>
|
|
</div>
|
|
|
|
<div class="binding-card constant">
|
|
<div class="kind">.toConstantValue(instance)</div>
|
|
<div class="when">use for · pre-constructed instances</div>
|
|
<p>You give the container an already-built object; it returns the same reference every time. Used by <code>bindProduction*(config)</code> because the real Payload-backed repository takes <code>config</code> as a constructor argument and the container has no way to provide that on its own. Also used by <code>bindDevSeed*</code> because the populated mock requires async <code>createArticle</code> calls during construction.</p>
|
|
<div class="example">blogContainer
|
|
.bind(SYM)
|
|
.toConstantValue(new ArticlesRepository(config));</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p style="font-family: 'Fraunces', serif; font-variation-settings: 'opsz' 16, 'SOFT' 40; font-size: 15.5px; line-height: 1.6; color: var(--ink-2); max-width: 70ch; margin: 32px 0 0;">The progression is significant. <code>.to</code> is what inversify was originally built for — class-based DI, decorator-driven. <code>.toDynamicValue</code> opens the door to functional DI, which is what every use case and controller in this repo actually wants. <code>.toConstantValue</code> is the escape hatch for "I built this thing myself, just remember it for me." All three coexist in the same container without conflict.</p>
|
|
</section>
|
|
|
|
<!-- ─── 05 ─ THREE MODES ──────────────────────────────────────────── -->
|
|
<section id="modes">
|
|
<div class="section-head">
|
|
<div class="section-num">§ 05</div>
|
|
<div>
|
|
<h2 class="section-title">Three <em>modes</em>, one container.</h2>
|
|
<p class="section-blurb">Same set of symbols, three possible states the container can be in. Pick a mode below to see which bindings change and which stay put.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modes-controls">
|
|
<span class="label">mode</span>
|
|
<div class="pillbar" id="modePicker">
|
|
<button data-mode="default" aria-pressed="true">default · empty mock</button>
|
|
<button data-mode="seed" aria-pressed="false">dev seed · populated mock</button>
|
|
<button data-mode="prod" aria-pressed="false">production · real Payload</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="modes-board">
|
|
<div class="modes-board-head">
|
|
<h3>blogContainer · <em>state</em></h3>
|
|
<div class="meta" id="modeFlag">USE_DEV_SEED ≠ "true" · no bindProduction* called yet</div>
|
|
</div>
|
|
|
|
<div class="modes-state-grid">
|
|
<div class="modes-narrative" id="modeNarrative">
|
|
<!-- populated by JS -->
|
|
</div>
|
|
<div class="bindings-list" id="bindingsList">
|
|
<!-- populated by JS -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 06 ─ CONDITIONS ───────────────────────────────────────────── -->
|
|
<section id="conditions">
|
|
<div class="section-head">
|
|
<div class="section-num">§ 06</div>
|
|
<div>
|
|
<h2 class="section-title">When does <em>which mode</em> run?</h2>
|
|
<p class="section-blurb">Six concrete scenarios that select a mode. The trigger is always the same: which binder did the entry point call?</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="conditions-table">
|
|
<div class="conditions-head">
|
|
<span>scenario</span>
|
|
<span>trigger</span>
|
|
<span>resulting mode</span>
|
|
</div>
|
|
<div class="conditions-row">
|
|
<div class="scenario">Just opened in your editor; nothing imported the container yet</div>
|
|
<div class="trigger">no import</div>
|
|
<div class="outcome">— (no container)</div>
|
|
</div>
|
|
<div class="conditions-row">
|
|
<div class="scenario">Test file imports the container directly</div>
|
|
<div class="trigger">import "@/di/container"</div>
|
|
<div class="outcome">default · empty mock</div>
|
|
</div>
|
|
<div class="conditions-row">
|
|
<div class="scenario">Server (any env) with USE_DEV_SEED=true · explicit override wins</div>
|
|
<div class="trigger">bindAll() → bindAllDevSeed()</div>
|
|
<div class="outcome">dev seed · populated mock</div>
|
|
</div>
|
|
<div class="conditions-row">
|
|
<div class="scenario">NODE_ENV=production, USE_DEV_SEED unset</div>
|
|
<div class="trigger">bindAll() → bindAllProduction(config)</div>
|
|
<div class="outcome">production · real Payload</div>
|
|
</div>
|
|
<div class="conditions-row">
|
|
<div class="scenario">pnpm dev (NODE_ENV=development or unset), no env flag</div>
|
|
<div class="trigger">bindAll() → bindAllDevSeed() (default)</div>
|
|
<div class="outcome">dev seed · populated mock</div>
|
|
</div>
|
|
<div class="conditions-row">
|
|
<div class="scenario">Storybook story that wants populated data</div>
|
|
<div class="trigger">await bindDevSeedBlog(ctx)</div>
|
|
<div class="outcome">dev seed · populated mock</div>
|
|
</div>
|
|
<div class="conditions-row">
|
|
<div class="scenario">Use-case unit test (skips DI entirely)</div>
|
|
<div class="trigger">new MockArticlesRepository()</div>
|
|
<div class="outcome">— (no container resolved)</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 07 ─ TESTS BYPASS ─────────────────────────────────────────── -->
|
|
<section id="tests">
|
|
<div class="section-head">
|
|
<div class="section-num">§ 07</div>
|
|
<div>
|
|
<h2 class="section-title">How tests <em>bypass</em> all of this.</h2>
|
|
<p class="section-blurb">The DI lifecycle above describes the runtime. Most tests do not run any of it — they construct mocks directly and call factory functions. The two patterns coexist; the container is only one of them.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="bypass-grid">
|
|
<div class="bypass-card with-di">
|
|
<h4>Tests that <em>do</em> use the container</h4>
|
|
<p>Two kinds: <code>container.test.ts</code> (proves bindings resolve), and <code>integrations/api/router.test.ts</code> (proves the tRPC router resolves the right controller through DI). Both rebind the repo symbol via <code>blogContainer.unbind</code> + <code>.toConstantValue(mock)</code> in <code>beforeEach</code>, then run the tRPC procedure or look up the symbol directly.</p>
|
|
<p>Why these and not others? Because the tRPC router calls <code>container.get</code> internally — there is no way to test the procedure without going through DI.</p>
|
|
<pre class="code" data-lang="typescript // router.test.ts"><span class="n">beforeEach</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">MockArticlesRepository</span>());
|
|
});</pre>
|
|
</div>
|
|
|
|
<div class="bypass-card no-di">
|
|
<h4>Tests that <em>do not</em></h4>
|
|
<p>Use-case and controller tests skip the container entirely. They construct the mock with <code>new MockArticlesRepository()</code> and pass it directly into the factory function. No <code>container.load</code>, no symbols, no rebinding. Just three lines of setup, then assertions.</p>
|
|
<p>This is the default — it is what every non-router test in the repo does. The same <code>MockArticlesRepository</code> class that the container binds by default is also the class these tests instantiate. One artifact, two paths to it.</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> () => {
|
|
<span class="k">const</span> <span class="n">repo</span> = <span class="k">new</span> <span class="t">MockArticlesRepository</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>
|
|
</div>
|
|
|
|
<div class="boundary-card" style="margin-top: 48px;">
|
|
<h3>The <em>punchline</em>: DI is for runtime, not tests.</h3>
|
|
<p>The container exists because at runtime there is no other way for the tRPC router to find a controller without compile-time-knowing which one. Tests <em>do</em> know — they import the factory directly. So tests do not need DI, and using DI in tests would just couple them to the container's state.</p>
|
|
<p><strong>That is the design:</strong> the mock implementation is reachable from both worlds (DI binds it as default; tests construct it directly), the contract suite verifies the mock and the real impl behave the same, and the container is the runtime-only mechanism that lets the same code resolve to different implementations depending on which binder ran at boot.</p>
|
|
<pre class="code" data-lang="summary"><span class="c">// Three places construct MockArticlesRepository:</span>
|
|
|
|
<span class="c">// 1. The container, by default (production binding swaps it).</span>
|
|
<span class="n">bind</span><<span class="t">IArticlesRepository</span>>(<span class="t">SYM</span>).<span class="n">to</span>(<span class="t">MockArticlesRepository</span>);
|
|
|
|
<span class="c">// 2. bind-dev-seed, populated for dev mode.</span>
|
|
<span class="k">const</span> <span class="n">repo</span> = <span class="k">new</span> <span class="t">MockArticlesRepository</span>();
|
|
<span class="k">for</span> (<span class="k">const</span> <span class="n">a</span> <span class="k">of</span> <span class="n">buildDevArticles</span>()) <span class="k">await</span> <span class="n">repo</span>.<span class="n">createArticle</span>(<span class="n">a</span>);
|
|
<span class="n">blogContainer</span>.<span class="n">bind</span>(<span class="t">SYM</span>).<span class="n">toConstantValue</span>(<span class="n">repo</span>);
|
|
|
|
<span class="c">// 3. Tests, directly. No container involved.</span>
|
|
<span class="k">const</span> <span class="n">repo</span> = <span class="k">new</span> <span class="t">MockArticlesRepository</span>();</pre>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ─── 08 ─ INSTRUMENTATION SYMBOLS ──────────────────────────────────── -->
|
|
<section id="instrumentation">
|
|
<div class="section-head">
|
|
<div class="section-num">§ 08</div>
|
|
<div>
|
|
<h2 class="section-title">Instrumentation <em>symbols</em>.</h2>
|
|
<p class="section-blurb">ADR-014 + ADR-017 added instrumentation symbols to the per-feature container — <code>ITracer</code>, <code>ILogger</code>, and <code>IMetrics</code> — bound by a separate Rule 0 in <code>bindAll()</code> that's <strong>orthogonal</strong> to the repo binding mode. The DSN env var decides OTel+Sentry vs Noop; <code>USE_DEV_SEED</code> / <code>NODE_ENV</code> decide real vs mock repos. <strong>Substrate: OpenTelemetry SDK.</strong> Sentry is the exporter via <code>@sentry/opentelemetry</code>. ADR-018 adds <code>IAuditLog</code> as a parallel channel with different durability and redaction contracts — see <a href="audit-and-compliance-explainer.html">audit-and-compliance-explainer.html</a>.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="instrumentation-grid">
|
|
<div>
|
|
<h3 class="trace-h3"><code>INSTRUMENTATION_SYMBOLS.ITracer</code></h3>
|
|
<p class="trace-p">Bound by either <code>bindNoopInstrumentation</code> or <code>bindOtelInstrumentation</code> to <code>NoopTracer</code> or <code>OtelTracer</code>. Decided by Rule 0: DSN env present → OTel SDK + Sentry exporter; otherwise Noop. <code>OtelTracer</code> emits via <code>@opentelemetry/api</code>; spans flow to Sentry via <code>SentrySpanProcessor</code>.</p>
|
|
|
|
<h3 class="trace-h3"><code>INSTRUMENTATION_SYMBOLS.ILogger</code></h3>
|
|
<p class="trace-p">Same rule, same lifecycle. <code>NoopLogger</code> in the absence of a DSN; <code>OtelLogger</code> when DSN is set. <code>OtelLogger</code> emits log records via <code>@opentelemetry/api-logs</code>; errors flow to Sentry via <code>SentryLogRecordProcessor</code>. The <code>__sentryReported</code> double-report guard is applied before emitting.</p>
|
|
|
|
<h3 class="trace-h3"><code>INSTRUMENTATION_SYMBOLS.IMetrics</code></h3>
|
|
<p class="trace-p">Bound to <code>NoopMetrics</code> or <code>OtelMetrics</code>. <code>OtelMetrics</code> uses the OTel metrics API (<code>counter</code>, <code>histogram</code>, <code>gauge</code>). Per-feature adoption is opportunistic — no feature call sites required.</p>
|
|
|
|
<h3 class="trace-h3"><code>IAuditLog</code> (ADR-018, optional)</h3>
|
|
<p class="trace-p">Not a symbol — passed through <code>ctx.auditLog</code>, not bound on the per-feature container. Present only when <code>@repo/core-audit</code> is scaffolded via <code>pnpm turbo gen core-package audit</code>. Pre-wrapped in <code>TraceIdEnrichingAuditLog</code> so callers don't supply <code>correlationId</code> — it's read from the active OTel span at sink time. Parallel channel: OTel is sampled/pruned best-effort observability; audit is append-only and retained for compliance. Same <code>traceId</code> lives in both, which is the debugging bridge.</p>
|
|
</div>
|
|
|
|
<div>
|
|
<h3 class="trace-h3">Wiring path</h3>
|
|
<pre class="trace-tree"><code>bindAll()
|
|
└─ resolveInstrumentation() ← Rule 0 (DSN check)
|
|
└─ bindOtelInstrumentation() ← OTel SDK init + Sentry exporter + PII scrub processors
|
|
OR bindNoopInstrumentation() ← all Noop
|
|
└─ resolveEventsAndJobs*() ← ADR-015 (env-driven bus + queue)
|
|
└─ Payload-backed in prod, in-memory in dev-seed
|
|
└─ resolveRealtime() ← ADR-016 (Socket.IO or in-memory)
|
|
└─ resolveAudit() ← ADR-018 (TraceIdEnriching( MultiSink([Stdout, Payload]) ))
|
|
└─ absent → ctx.auditLog is undefined; record() calls become no-ops via ?.
|
|
└─ build ctx: BindProductionContext = { config, tracer, logger, metrics?, bus, queue, realtime, realtimeRegistry, auditLog }
|
|
└─ required: tracer, logger, config | optional: metrics, bus, queue, realtime, realtimeRegistry, auditLog
|
|
└─ bindProductionX(ctx) ← single ctx object passed to each feature binder
|
|
└─ feature container also binds TRACER + LOGGER
|
|
└─ withSpan(withCapture(...)) at every use case + controller
|
|
└─ // <gen:event-handlers> / // <gen:jobs> / // <gen:realtime-handlers> / // <gen:audit-hooks> injection sites</code></pre>
|
|
|
|
<p class="trace-p"><strong>Why per-feature containers also get the binding:</strong> repository classes resolve TRACER/LOGGER through the container; controllers and use cases receive instrumentation via the bind-time wrapper instead.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 class="trace-h3" style="margin-top: 36px;">Two wrappers, applied as a sandwich</h3>
|
|
<p class="trace-p"><code>withSpan</code> and <code>withCapture</code> are higher-order functions that take a <code>(args) => Promise<R></code> and return the same shape. The binders compose them: <code>withSpan(withCapture(factory(deps)))</code>. Span is outermost so an errored span's timing reflects the capture-and-rethrow.</p>
|
|
<table class="capture-rules">
|
|
<thead>
|
|
<tr><th>Wrapper</th><th>What it does</th><th>Where it fires</th></tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td><code>withSpan(tracer, opts, fn)</code></td>
|
|
<td>Calls <code>tracer.startSpan(opts, () => fn(...))</code>. Pure delegation — no error handling of its own; status-on-error logic lives in the tracer impl.</td>
|
|
<td>Around every use case + controller, at DI bind time</td>
|
|
</tr>
|
|
<tr>
|
|
<td><code>withCapture(logger, tags, fn)</code></td>
|
|
<td>On throw: checks <code>__sentryReported</code>; if not set, calls <code>logger.captureException(err, { tags })</code>, marks the flag, re-throws. If already set, just re-throws.</td>
|
|
<td>Around every use case + controller, inside the span wrapper</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<p class="trace-p">Repositories are different — they call <code>this.tracer.startSpan</code> + <code>this.logger.captureException</code> inline per method, because they own the per-call attributes (count, IDs, slugs) that the wrapper has no way to know.</p>
|
|
</section>
|
|
|
|
</main>
|
|
|
|
<footer class="frame">
|
|
<div class="foot-grid">
|
|
<div>
|
|
<h5>Sibling explainer</h5>
|
|
<a href="data-flow-explainer.html">data-flow-explainer.html</a>
|
|
<a href="audit-and-compliance-explainer.html">audit-and-compliance-explainer.html</a>
|
|
</div>
|
|
<div>
|
|
<h5>Decisions</h5>
|
|
<a href="../decisions/adr-008-per-feature-di-containers.md">adr-008 · per-feature DI</a>
|
|
<a href="../decisions/adr-012-feature-conventions.md">adr-012 · feature conventions</a>
|
|
<a href="../decisions/adr-013-input-output-unification.md">adr-013 · I/O unification</a>
|
|
</div>
|
|
<div>
|
|
<h5>Code</h5>
|
|
<a href="../../packages/blog/src/di/symbols.ts">symbols.ts</a>
|
|
<a href="../../packages/blog/src/di/module.ts">module.ts</a>
|
|
<a href="../../packages/blog/src/di/container.ts">container.ts</a>
|
|
<a href="../../packages/blog/src/di/bind-production.ts">bind-production.ts</a>
|
|
<a href="../../packages/blog/src/di/bind-dev-seed.ts">bind-dev-seed.ts</a>
|
|
</div>
|
|
<div>
|
|
<h5>Specs</h5>
|
|
<a href="vertical-feature-spec.md">vertical-feature-spec.md</a>
|
|
<a href="overview.md">overview.md</a>
|
|
<a href="dependency-flow.md">dependency-flow.md</a>
|
|
</div>
|
|
</div>
|
|
<div class="colophon">
|
|
Set in Fraunces & JetBrains Mono. Sister page to <code>data-flow-explainer.html</code>. Single file, no build step.
|
|
</div>
|
|
</footer>
|
|
|
|
<script>
|
|
// ─── Three modes interactive ─────────────────────────────────────
|
|
|
|
const MODES = {
|
|
default: {
|
|
flag: 'USE_DEV_SEED ≠ "true" · no bindProduction* called yet',
|
|
scenarioTag: 'when this happens',
|
|
title: 'Just imported the container',
|
|
narrative: [
|
|
"Test files that import @/di/container land here. Same with storybook stories that don't seed. The default bindings from BlogModule are all you have.",
|
|
"The repo symbol resolves to MockArticlesRepository, freshly constructed on first .get(), then cached. No data inside it — empty array.",
|
|
"Use cases and controllers resolve through their .toDynamicValue closures, fetch the empty mock, and return wired-up factory functions."
|
|
],
|
|
bindings: [
|
|
{ sym: "IArticlesRepository", arrow: "→", impl: "MockArticlesRepository", kind: ".to(class)", changed: false },
|
|
{ sym: "IGetArticlesUseCase", arrow: "→", impl: "getArticlesUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "ICreateArticleUseCase", arrow: "→", impl: "createArticleUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticleBySlugUseCase", arrow: "→", impl: "getArticleBySlugUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticlesController", arrow: "→", impl: "getArticlesController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "ICreateArticleController", arrow: "→", impl: "createArticleController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticleBySlugController", arrow: "→", impl: "getArticleBySlugController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
],
|
|
},
|
|
seed: {
|
|
flag: 'USE_DEV_SEED="true" OR NODE_ENV ≠ "production" (default) · bindAllDevSeed() ran',
|
|
scenarioTag: 'when this happens',
|
|
title: 'Dev seed binder ran at app boot',
|
|
narrative: [
|
|
"App's bindAll() saw USE_DEV_SEED=true and dispatched to bindAllDevSeed(), which called bindDevSeedBlog(ctx).",
|
|
"That binder unbound IArticlesRepository, constructed a fresh MockArticlesRepository, awaited createArticle for each entity from buildDevArticles(), and rebound the symbol with .toConstantValue(repo).",
|
|
"Use case + controller bindings did not change — they still resolve through their .toDynamicValue closures, but now the closure fetches the populated mock and threads it through. The dev server now shows the welcome article, the work-in-progress draft, etc."
|
|
],
|
|
bindings: [
|
|
{ sym: "IArticlesRepository", arrow: "→", impl: "MockArticlesRepository (3 articles seeded)", kind: ".toConstantValue", changed: true },
|
|
{ sym: "IGetArticlesUseCase", arrow: "→", impl: "getArticlesUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "ICreateArticleUseCase", arrow: "→", impl: "createArticleUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticleBySlugUseCase", arrow: "→", impl: "getArticleBySlugUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticlesController", arrow: "→", impl: "getArticlesController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "ICreateArticleController", arrow: "→", impl: "createArticleController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticleBySlugController", arrow: "→", impl: "getArticleBySlugController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
],
|
|
},
|
|
prod: {
|
|
flag: 'NODE_ENV="production", USE_DEV_SEED unset · bindAllProduction(deps) ran',
|
|
scenarioTag: 'when this happens',
|
|
title: 'Production binder ran at app boot',
|
|
narrative: [
|
|
"App's bindAll() did not see USE_DEV_SEED=true, so it dispatched to bindAllProduction(). That resolved instrumentation (Rule 0), resolved a Payload-backed bus + queue (ADR-015), awaited the Payload config, and called bindProductionBlog(ctx) where ctx carries { config, tracer, logger, bus?, queue?, realtime?, realtimeRegistry? }.",
|
|
"The binder unbound IArticlesRepository and rebound it with .toConstantValue(new ArticlesRepository(config, tracer, logger)) — the real Payload-backed implementation that will hit Postgres on every method call.",
|
|
"Use cases and controllers still resolve through their .toDynamicValue closures, but the closures now fetch the real repo. Callers cannot tell the difference — same interface, different storage."
|
|
],
|
|
bindings: [
|
|
{ sym: "IArticlesRepository", arrow: "→", impl: "ArticlesRepository(config) — Payload-backed", kind: ".toConstantValue", changed: true },
|
|
{ sym: "IGetArticlesUseCase", arrow: "→", impl: "getArticlesUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "ICreateArticleUseCase", arrow: "→", impl: "createArticleUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticleBySlugUseCase", arrow: "→", impl: "getArticleBySlugUseCase(repo)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticlesController", arrow: "→", impl: "getArticlesController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "ICreateArticleController", arrow: "→", impl: "createArticleController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
{ sym: "IGetArticleBySlugController", arrow: "→", impl: "getArticleBySlugController(useCase)", kind: ".toDynamicValue", changed: false },
|
|
],
|
|
},
|
|
};
|
|
|
|
const modeFlag = document.getElementById('modeFlag');
|
|
const modeNarrative = document.getElementById('modeNarrative');
|
|
const bindingsList = document.getElementById('bindingsList');
|
|
|
|
function escapeHtml(s) {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
function renderMode(modeKey) {
|
|
const M = MODES[modeKey];
|
|
modeFlag.textContent = M.flag;
|
|
modeNarrative.innerHTML = `
|
|
<div class="scenario-tag">${M.scenarioTag}</div>
|
|
<h4>${M.title}</h4>
|
|
${M.narrative.map(p => `<p>${p}</p>`).join('')}
|
|
`;
|
|
bindingsList.innerHTML = M.bindings.map(b => `
|
|
<div class="binding-row${b.changed ? ' changed' : ''}">
|
|
<span class="symbol">BLOG_SYMBOLS.${escapeHtml(b.sym)}</span>
|
|
<span class="arrow">${escapeHtml(b.arrow)}</span>
|
|
<span class="impl">
|
|
${escapeHtml(b.impl)}
|
|
<span class="kind-badge">${escapeHtml(b.kind)}</span>
|
|
</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
document.querySelectorAll('#modePicker button').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('#modePicker button').forEach(b => b.setAttribute('aria-pressed', 'false'));
|
|
btn.setAttribute('aria-pressed', 'true');
|
|
renderMode(btn.dataset.mode);
|
|
});
|
|
});
|
|
|
|
renderMode('default');
|
|
|
|
// Smooth-scroll
|
|
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>
|