docs: refresh explainers + adding-a-feature with full binder signature

di-explainer and data-flow-explainer narratives still showed the
pre-ADR-014 1-arg bindProductionBlog(config) form. Updated both
explainers to (config, tracer, logger, bus, queue), and adjusted the
narrative arc so the production-swap step mentions the
resolveEventsAndJobs* preamble.

adding-a-feature's Step 16 sketch was the same 1-arg shape; replaced
with the canonical 5-arg signature and a short note that the bus/queue
params are accept-and-forward until gen event consume / gen job
generators inject usage at the anchors. Pointer to a real feature's
bind-production.ts for the complete reference.

Final sweep is clean — no stale (container, config) or 1-arg
bindProduction signatures remain in docs/.
This commit is contained in:
2026-05-08 17:54:11 +02:00
parent 5a2234f7ad
commit 69c445de26
3 changed files with 32 additions and 8 deletions

View File

@@ -1664,7 +1664,7 @@ footer .colophon {
</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(config)</code> unbinds the symbol and rebinds it to <code>new ArticlesRepository(config)</code>. Use cases and controllers don't notice — they get whatever the symbol currently resolves to.</p>
<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(config, tracer, logger, bus, queue)</code> unbinds the symbol and rebinds it to <code>new ArticlesRepository(config, tracer, logger)</code>. Use cases and controllers don't notice — they get whatever the symbol currently resolves to.</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>
@@ -1773,7 +1773,7 @@ footer .colophon {
<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(config)</code> swaps it for the real Payload-backed one. See §03.</li>
<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(config, tracer, logger, bus, queue)</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>
@@ -1786,7 +1786,7 @@ footer .colophon {
<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(config)</code> later replaces only this one line at app boot — use case + controller bindings stay put.</p>
<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(config, tracer, logger, bus, queue)</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>

View File

@@ -756,7 +756,7 @@ footer .colophon {
<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(config: SanitizedConfig)</code>. Function body: <code>blogContainer.unbind(symbol)</code> if already bound, then <code>.bind(symbol).toConstantValue(new ArticlesRepository(config))</code>. Use cases and controllers stay bound to their factory bindings — they will fetch the new repo through the container automatically because their factories call <code>ctx.container.get(...)</code> at every resolution.</p>
<p>Exports <code>bindProductionBlog(config, tracer, logger, bus, queue)</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 <code>bus</code> and <code>queue</code> params come from the app's <code>resolveEventsAndJobs*</code> step (ADR-015) and feed the <code>// &lt;gen:event-handlers&gt;</code> / <code>// &lt;gen:jobs&gt;</code> injection sites.</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>
@@ -1302,8 +1302,8 @@ const MODES = {
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(config). That awaited the resolved Payload config and called bindProductionBlog(config).",
"The binder unbound IArticlesRepository and rebound it with .toConstantValue(new ArticlesRepository(config)) — the real Payload-backed implementation that will hit Postgres on every method call.",
"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(config, tracer, logger, bus, queue).",
"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: [

View File

@@ -926,20 +926,44 @@ describe("CommentsRepository", () => {
### Step 16: `bind-production.ts`
The full canonical shape includes instrumentation (ADR-014) and the
event-bus + job-queue parameters (ADR-015) — see any feature's
`bind-production.ts` for a complete reference. Minimal sketch:
```typescript
// src/di/bind-production.ts
import type { SanitizedConfig } from "payload";
import {
withSpan,
withCapture,
type ITracer,
type ILogger,
} from "@repo/core-shared/instrumentation";
import type { IEventBus } from "@repo/core-events";
import type { IJobQueue } from "@repo/core-shared/jobs";
import { commentsContainer } from "./container";
import { COMMENTS_SYMBOLS } from "./symbols";
import { CommentsRepository } from "../infrastructure/repositories/comments.repository";
export function bindProductionComments(config: SanitizedConfig): void {
export function bindProductionComments(
config: SanitizedConfig,
tracer: ITracer,
logger: ILogger,
bus: IEventBus,
queue: IJobQueue,
): void {
if (commentsContainer.isBound(COMMENTS_SYMBOLS.ICommentsRepository)) {
commentsContainer.unbind(COMMENTS_SYMBOLS.ICommentsRepository);
}
const repo = new CommentsRepository(config, tracer, logger);
commentsContainer
.bind(COMMENTS_SYMBOLS.ICommentsRepository)
.toConstantValue(new CommentsRepository(config));
.toConstantValue(repo);
// Use cases + controllers are wrapped with withSpan(withCapture(...))
// at bind time. bus + queue are accept-and-forward until you add an
// event handler or job — at which point the gen event consume / gen job
// generators inject usage at the // <gen:event-handlers> / // <gen:jobs>
// anchors at the end of the function body.
}
```