docs: BindContext binder shape across architecture + per-feature AGENTS
This commit is contained in:
60
AGENTS.md
60
AGENTS.md
@@ -320,36 +320,52 @@ Each app (`web-next`, `web-tanstack`, `cms`) imports both binders per feature an
|
||||
|
||||
```typescript
|
||||
// apps/web-next/src/server/bind-production.ts
|
||||
import { bindProductionBlog } from "@repo/blog/di/bind-production";
|
||||
import { bindProductionAuth } from "@repo/auth/di/bind-production";
|
||||
import { bindProductionMarketingPages } from "@repo/marketing-pages/di/bind-production";
|
||||
import { bindProductionNavigation } from "@repo/navigation/di/bind-production";
|
||||
import { bindProductionMedia } from "@repo/media/di/bind-production";
|
||||
import { bindDevSeedBlog } from "@repo/blog/di/bind-dev-seed";
|
||||
import { bindDevSeedAuth } from "@repo/auth/di/bind-dev-seed";
|
||||
import { bindDevSeedMarketingPages } from "@repo/marketing-pages/di/bind-dev-seed";
|
||||
import { bindDevSeedNavigation } from "@repo/navigation/di/bind-dev-seed";
|
||||
import { bindDevSeedMedia } from "@repo/media/di/bind-dev-seed";
|
||||
import config from "@repo/core-cms";
|
||||
import type { BindProductionContext, BindContext } from "@repo/core-shared/di";
|
||||
import type { IEventBus } from "@repo/core-events";
|
||||
import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry } from "@repo/core-realtime";
|
||||
|
||||
export async function bindAll(): Promise<void> {
|
||||
if (process.env.USE_DEV_SEED === "true") return bindAllDevSeed();
|
||||
if (process.env.NODE_ENV === "production") return bindAllProduction();
|
||||
return bindAllDevSeed();
|
||||
export async function bindAllProduction(deps: BindAllDeps): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation();
|
||||
const { bus, queue } = await resolveEventsAndJobsProduction();
|
||||
const resolvedConfig = await config;
|
||||
const { realtime, realtimeRegistry } = deps;
|
||||
|
||||
const ctx: BindProductionContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
|
||||
config: resolvedConfig,
|
||||
tracer,
|
||||
logger,
|
||||
bus,
|
||||
queue,
|
||||
realtime,
|
||||
realtimeRegistry,
|
||||
};
|
||||
|
||||
bindProductionAuth(ctx);
|
||||
bindProductionBlog(ctx);
|
||||
bindProductionMarketingPages(ctx);
|
||||
bindProductionNavigation(ctx);
|
||||
bindProductionMedia(ctx);
|
||||
}
|
||||
|
||||
export async function bindAllProduction(): Promise<void> {
|
||||
const resolvedConfig = await config;
|
||||
bindProductionAuth(resolvedConfig);
|
||||
bindProductionBlog(resolvedConfig);
|
||||
bindProductionMarketingPages(resolvedConfig);
|
||||
bindProductionNavigation(resolvedConfig);
|
||||
bindProductionMedia(resolvedConfig);
|
||||
export async function bindAllDevSeed(deps: BindAllDeps): Promise<void> {
|
||||
const { tracer, logger } = resolveInstrumentation();
|
||||
const { bus, queue } = resolveEventsAndJobsDevSeed();
|
||||
const { realtime, realtimeRegistry } = deps;
|
||||
|
||||
const ctx: BindContext<IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry> = {
|
||||
tracer, logger, bus, queue, realtime, realtimeRegistry,
|
||||
};
|
||||
|
||||
await bindDevSeedAuth(ctx);
|
||||
await bindDevSeedBlog(ctx);
|
||||
// ... (same for marketing-pages, navigation, media)
|
||||
}
|
||||
```
|
||||
|
||||
Actual function names: `bindProductionAuth`, `bindProductionBlog`, `bindProductionMarketingPages`, `bindProductionNavigation`, `bindProductionMedia`.
|
||||
|
||||
Each feature binder signature is `(ctx: BindProductionContext): void` for production and `(ctx: BindContext): Promise<void>` for dev-seed. Required ctx fields: `tracer`, `logger`. Production-only: `config`. Optional: `bus`, `queue`, `realtime`, `realtimeRegistry`.
|
||||
|
||||
---
|
||||
|
||||
### Cross-feature events and background jobs (Plan 10, ADR-015)
|
||||
|
||||
@@ -56,6 +56,7 @@ Turborepo + pnpm monorepo organized by vertical features. Each feature (`auth`,
|
||||
- **Public surface split** — Feature root (`.`) exports contracts only: types, errors, schemas, IUseCase / IController aliases, router type, constants. UI artifacts (query builders, components) live behind `./ui` (`src/ui/index.ts`). Apps import queries from `@repo/<feature>/ui`, schemas/types from `@repo/<feature>`
|
||||
- **Payload repositories via constructor** — Feature packages receive Payload config at constructor time, not as a direct dependency
|
||||
- **Three binding modes per feature** — Each feature exports two binders: `./di/bind-production` (real Payload) and `./di/bind-dev-seed` (populated mock). The app's `bindAll()` dispatcher in `apps/web-next/src/server/bind-production.ts` picks one by env: `USE_DEV_SEED="true"` → dev seed; `NODE_ENV="production"` → production; otherwise → dev seed (developer default so `pnpm dev` boots without Payload). Dev seed lives in `src/__seeds__/dev.ts` as a lazy `buildDev<Entities>()` function that uses the feature's existing factory
|
||||
- **Binders take a `ctx` arg from `core-shared/di`** — `bindProductionX(ctx: BindProductionContext)` for production binders; `bindDevSeedX(ctx: BindContext)` for dev-seed. Required fields: `tracer`, `logger`, plus `config` for production. Optional fields: `bus`, `queue`, `realtime`, `realtimeRegistry` (correspond to optional core packages — guard with `?.` when used, or cast to the full interface if the feature unconditionally requires the dep). Aggregator builds one ctx object and passes it to all feature binders
|
||||
- **App bootstrap** — Each app calls `bindAll()` from a server entry point (page server component, route handler) before resolving any feature controller. The dispatcher is idempotent
|
||||
- **Instrumentation lives in `core-shared/instrumentation/`** — Two interfaces (`ITracer`, `ILogger`), three implementations (`NoopTracer`/`NoopLogger`, `SentryTracer`/`SentryLogger`, and `RecordingTracer`/`RecordingLogger` from `core-testing`). Feature packages MUST NOT import `@sentry/*` directly (R40, eslint-enforced)
|
||||
- **Spans + capture composed at DI bind time** — Use cases + controllers wrapped via `withSpan(tracer, spanOpts, withCapture(logger, tags, factory(deps)))` inside `bind-production` / `bind-dev-seed`. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow. Repository methods are different — they call `this.tracer.startSpan(...)` and `this.logger.captureException(...)` inline per method because they own per-call attributes (R41, R42)
|
||||
|
||||
@@ -115,7 +115,11 @@ apps/web-next/src/server/bind-production.ts (bindAll)
|
||||
│ server.ts → SocketIORealtimeBroadcaster + RealtimeHandlerRegistry (passed in from server.ts)
|
||||
│ page/test → InMemoryRealtimeBroadcaster + RealtimeHandlerRegistry (defaults)
|
||||
│ ↓
|
||||
├─ bindProductionBlog(config, tracer, logger, bus, queue, realtime, realtimeRegistry)
|
||||
├─ build ctx: BindProductionContext = { config, tracer, logger, bus, queue, realtime, realtimeRegistry }
|
||||
│ Required: tracer, logger, config (production only)
|
||||
│ Optional: bus, queue, realtime, realtimeRegistry (guard with ?. when used)
|
||||
│ ↓
|
||||
├─ bindProductionBlog(ctx: BindProductionContext)
|
||||
│ │
|
||||
│ ├─ blogContainer.bind(TRACER).toConstantValue(tracer)
|
||||
│ ├─ blogContainer.bind(LOGGER).toConstantValue(logger)
|
||||
@@ -127,9 +131,23 @@ apps/web-next/src/server/bind-production.ts (bindAll)
|
||||
│ ├─ // <gen:jobs> queue.register(...) in dev-seed; Payload task in prod
|
||||
│ └─ // <gen:realtime-handlers> realtimeRegistry.register(...) (ADR-016, gen realtime handler)
|
||||
│
|
||||
└─ (same for auth, marketing-pages, navigation, media)
|
||||
└─ (same for auth, marketing-pages, navigation, media — each receives the same ctx)
|
||||
```
|
||||
|
||||
**`BindContext` shape (from `@repo/core-shared/di`):**
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `tracer` | `ITracer` | always | Resolved by Rule 0 (Sentry vs Noop) |
|
||||
| `logger` | `ILogger` | always | Resolved by Rule 0 |
|
||||
| `config` | `SanitizedConfig` | production only | Present in `BindProductionContext`, absent in `BindContext` |
|
||||
| `bus` | `EventBusProtocol?` | optional | `IEventBus` at the aggregator; protocol surface at binders |
|
||||
| `queue` | `IJobQueue?` | optional | Present when `core-shared/jobs` is wired |
|
||||
| `realtime` | `RealtimeBroadcasterProtocol?` | optional | `IRealtimeBroadcaster` at the aggregator |
|
||||
| `realtimeRegistry` | `RealtimeRegistryProtocol?` | optional | `IRealtimeHandlerRegistry` at the aggregator |
|
||||
|
||||
Feature binders destructure `ctx` and use optional fields with `?.` or cast to the full interface when the feature unconditionally requires them (e.g. `bus as IEventBus` when a use case always needs the event bus).
|
||||
|
||||
**Why per-feature containers also get the binding:** lets internal DI-resolved code in a feature pull TRACER/LOGGER without going through the app dispatcher. In practice, only repository classes and feature-internal services would use this — controllers and use cases receive instrumentation via the bind-time wrapper.
|
||||
|
||||
**Why the shared container exists at all:** isolates Rule 0 resolution from feature containers. Feature containers don't need to know if Sentry is on or off — they just receive an `ITracer` instance.
|
||||
|
||||
@@ -693,8 +693,8 @@ Invoke the `superpowers:writing-plans` skill to produce a detailed, executable i
|
||||
|
||||
- `infrastructure/repositories/<entity>.repository.ts` — constructor takes `(config, tracer, logger)` with Noop defaults; every public method's body is wrapped in `tracer.startSpan(...)` and any `catch` block calls `logger.captureException(err, { tags: { feature, repo, method } })` before re-throwing.
|
||||
- `infrastructure/repositories/<entity>.repository.mock.ts` — same constructor/wrapping shape (no catch — mocks don't originate infra errors).
|
||||
- `di/bind-production.ts` — signature `(config, tracer, logger)`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured.
|
||||
- `di/bind-dev-seed.ts` — signature `(tracer, logger)`. Same wrapping as bind-production but with the populated mock.
|
||||
- `di/bind-production.ts` — signature `(ctx: BindProductionContext)` (from `@repo/core-shared/di`). Destructures `{ config, tracer, logger, bus, queue, realtime, realtimeRegistry }` from `ctx`. Binds TRACER + LOGGER to the feature container; constructs the real repo with tracer/logger; wraps every use case + controller via `withSpan(withCapture(factory(deps)))` at bind time. `withSpan` is outermost so an errored span's timing reflects the capture-and-rethrow; `withCapture` honours the `__sentryReported` flag so a bubbled error from the repo isn't re-captured. Optional fields (`bus`, `queue`, `realtime`, `realtimeRegistry`) are guarded with `?.` or cast to the full interface when the feature unconditionally requires them.
|
||||
- `di/bind-dev-seed.ts` — signature `(ctx: BindContext)` (no `config`). Same wrapping as bind-production but with the populated mock.
|
||||
|
||||
**Required exports (per feature root):** unchanged.
|
||||
|
||||
|
||||
@@ -31,8 +31,8 @@ Users collection + authentication use cases (sign-in, sign-up, sign-out). Provid
|
||||
| `./ui` | Placeholder — extend here when auth gains React Query builders, never re-add to root |
|
||||
| `./api` | `authRouter` (tRPC router) |
|
||||
| `./cms` | Payload Users collection definition |
|
||||
| `./di/bind-production` | `bindProductionAuth(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedAuth(tracer, logger, bus, queue)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
| `./di/bind-production` | `bindProductionAuth(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedAuth(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
| `./di/container` | `authContainer` — the per-feature inversify container (consumed by e2e tests + production Payload event-tasks) |
|
||||
| `./di/symbols` | `AUTH_SYMBOLS` — DI symbol registry (consumed by e2e tests + production Payload event-tasks) |
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ Articles collection + content use cases (get articles, get article by slug, crea
|
||||
| `./ui` | `articleBySlugQuery`, `listArticlesQuery` — React Query option builders |
|
||||
| `./api` | `blogRouter` (tRPC router) |
|
||||
| `./cms` | Payload Articles collection definition |
|
||||
| `./di/bind-production` | `bindProductionBlog(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedBlog(tracer, logger, bus, queue)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
| `./di/bind-production` | `bindProductionBlog(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedBlog(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
|
||||
## Use-case + controller patterns
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ Pages collection + SiteSettings global for site-wide metadata. Provides marketin
|
||||
| `./ui` | `pageBySlugQuery`, `siteSettingsQuery` — React Query option builders |
|
||||
| `./api` | `marketingPagesRouter` (tRPC router) |
|
||||
| `./cms` | Payload Pages collection + SiteSettings global |
|
||||
| `./di/bind-production` | `bindProductionMarketingPages(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedMarketingPages(tracer, logger, bus, queue)` — replaces the default empty mocks with populated ones for dev / Storybook |
|
||||
| `./di/bind-production` | `bindProductionMarketingPages(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedMarketingPages(ctx: BindContext)` — replaces the default empty mocks with populated ones for dev / Storybook |
|
||||
| `./di/container` | `marketingPagesContainer` — the per-feature inversify container (consumed by e2e tests + production Payload event-tasks) |
|
||||
| `./di/symbols` | `MARKETING_PAGES_SYMBOLS` — DI symbol registry (consumed by e2e tests + production Payload event-tasks) |
|
||||
| `./services/mailer` | `IMailerService` — interface for the welcome-email seam (ADR-015 proof-of-life) |
|
||||
|
||||
@@ -33,8 +33,8 @@ Media upload collection (images, PDFs, etc.) and media-related use cases (get, l
|
||||
| `./ui` | Placeholder — extend here when media gains React Query builders, never re-add to root |
|
||||
| `./api` | `mediaRouter` (tRPC router) |
|
||||
| `./cms` | Payload Media collection definition |
|
||||
| `./di/bind-production` | `bindProductionMedia(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedMedia(tracer, logger, bus, queue)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
| `./di/bind-production` | `bindProductionMedia(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedMedia(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
|
||||
## Use-case + controller patterns
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ Header global for main site navigation. Provides the Header Payload global and t
|
||||
| `./ui` | `headerQuery` — React Query option builder |
|
||||
| `./api` | `navigationRouter` (tRPC router) |
|
||||
| `./cms` | Payload Header global definition |
|
||||
| `./di/bind-production` | `bindProductionNavigation(config, tracer, logger, bus, queue)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedNavigation(tracer, logger, bus, queue)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
| `./di/bind-production` | `bindProductionNavigation(ctx: BindProductionContext)` — swaps mock impls for real Payload-backed ones at app boot |
|
||||
| `./di/bind-dev-seed` | `bindDevSeedNavigation(ctx: BindContext)` — replaces the default empty mock with a populated one for dev / Storybook |
|
||||
|
||||
## Use-case + controller patterns
|
||||
|
||||
|
||||
Reference in New Issue
Block a user