# Core-package generator + template slimming — Design **Date:** 2026-05-09 **Status:** Draft (pending user review) **Companion ADR:** TBD (numbering tracked at implementation time; expected ADR-017). **Builds on:** ADR-008 (per-feature DI containers), ADR-014 (instrumentation), ADR-015 (events and jobs), ADR-016 (realtime layer). --- ## 1. Context and motivation This monorepo is intended as a reusable starter template for new projects. Today every new project inherits **all** core packages whether or not it needs them — including `core-realtime` (Socket.IO), `core-events` (cross-feature event bus), `core-trpc` (tRPC server setup), and `core-ui` (design system). For projects that don't ship realtime, don't need cross-feature events, prefer plain server actions over tRPC, or import their design system from outside, those packages are dead weight. We want the template's defaults to be the **minimal viable kernel** and provide turbo generators that **scaffold the optional packages back** when a project needs them. The generators take their templates from the *current* shipped packages, so a generator-scaffolded `core-realtime` is byte-identical to today's `packages/core-realtime/`. Two architectural realities make this non-trivial: - **Feature binders thread cross-cutting deps positionally.** Every feature's `bindProductionX` / `bindDevSeedX` takes a 7-arg positional signature including `bus, queue, realtime, realtimeRegistry`. Removing core-events or core-realtime breaks every feature's signature. - **Optional packages own contracts that other code depends on at compile time.** `IEventBus` and `IRealtimeBroadcaster` are referenced in feature bind files. Their absence breaks typechecking unless something else owns the type. Phase 0 of this design solves both via a `BindContext` object with **minimal protocol types in `core-shared`** and **full interfaces remaining in optional packages**. After Phase 0, removing an optional package is a leaf operation: typing falls back to the protocol default, runtime guards (`ctx.realtime?.broadcast(...)`) handle absence. ## 2. Decision summary 1. Introduce a `BindContext` type in `core-shared` with required (`tracer`, `logger`, `config`) and optional (`bus`, `queue`, `realtime`, `realtimeRegistry`) fields. Optional fields use **bounded generics** over minimal protocol types (`EventBusProtocol`, `RealtimeBroadcasterProtocol`, `RealtimeRegistryProtocol`) defined in `core-shared`. 2. Optional packages keep their full interfaces (`IEventBus`, `IRealtimeBroadcaster`, etc.) but declare them `extends` the corresponding protocol — typechecks fail if a refactor breaks the protocol surface. 3. Convert all 5 features' `bindProduction` / `bindDevSeed` from positional args to single-arg `(ctx: BindContext)`. App aggregator binders construct the context once. 4. Add `pnpm turbo gen core-package` generator with a `name` prompt selecting one of `{realtime, events, trpc, ui}`. Each name maps to a verbatim template directory + a generator action that scaffolds the package, updates the consuming apps' `transpilePackages` list, and patches `core-eslint/base.js` for any rules that need re-adding. 5. Once a generator works, **remove that package from `main`** along with its consuming-app wiring and its ESLint rules (per the user's preference for "remove + re-add"). The `main` template ends in a slimmed state. 6. Per-package documentation (ADR-016, realtime guide, etc.) stays in `docs/` permanently with a "Status: optional, scaffolded via `gen core-package `" header. HTML explainers (`data-flow-explainer.html`, `di-explainer.html`) get conditional/dashed rendering for optional packages. ## 3. Architecture overview Five sequential, independently mergeable phases: | Phase | Ships | Verification | |---|---|---| | 0 | `BindContext` type, all binders + app aggregators on single-arg `ctx`, doc + HTML updates for the binder change | Behavior unchanged; all CI gates green | | 1 | `pnpm turbo gen core-package` framework — entry, prompt, dispatch table — but no templates yet | `core-package` registered with empty `choices`; framework no-op | | 2 | core-realtime: capture template → ship generator action → remove from `main` → ESLint rules excised | From slim tree, `gen core-package realtime` produces byte-identical reconstruction | | 3 | core-events: same lifecycle | same | | 4 | core-trpc: same | same | | 5 | core-ui: same | same | Plus a final cross-doc sweep + end-to-end "scaffold everything from slim main" acceptance test. ## 4. Phase 0 — BindContext refactor ### 4.1 Protocol types in core-shared ```ts // packages/core-shared/src/di/bind-protocols.ts export type EventBusProtocol = { publish(event: { name: string }, payload: T): Promise; subscribe( event: { name: string }, consumer: string, handler: (payload: T) => Promise, ): void; }; export type RealtimeBroadcasterProtocol = { broadcast( channel: { name: string; key?: unknown }, payload: T, ): Promise; }; export type RealtimeRegistryProtocol = { register(entry: { descriptor: unknown; handler: unknown }): void; registerChannel(descriptor: unknown): void; listChannels(): unknown[]; }; ``` Each protocol captures only what feature binders touch. Adapter-specific methods stay on the full interfaces in optional packages. ### 4.2 BindContext type ```ts // packages/core-shared/src/di/bind-context.ts import type { ITracer, ILogger } from "../instrumentation"; import type { IJobQueue } from "../jobs"; import type { EventBusProtocol, RealtimeBroadcasterProtocol, RealtimeRegistryProtocol, } from "./bind-protocols"; export type BindContextBase = { tracer: ITracer; logger: ILogger; }; export type BindContext< Bus extends EventBusProtocol = EventBusProtocol, Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, > = BindContextBase & { bus?: Bus; queue?: IJobQueue; realtime?: Realtime; realtimeRegistry?: RealtimeReg; }; // Production extends with config; dev-seed does not. export type BindProductionContext< Bus extends EventBusProtocol = EventBusProtocol, Realtime extends RealtimeBroadcasterProtocol = RealtimeBroadcasterProtocol, RealtimeReg extends RealtimeRegistryProtocol = RealtimeRegistryProtocol, > = BindContext & { config: SanitizedConfig; }; ``` `SanitizedConfig` comes from `payload` — already a transitive dependency of `core-shared`. ### 4.3 Optional packages adopt the protocol ```ts // packages/core-events/src/event-bus.interface.ts (1-line change) import type { EventBusProtocol } from "@repo/core-shared/di/bind-protocols"; export interface IEventBus extends EventBusProtocol { /* existing methods */ } ``` ```ts // packages/core-realtime/src/realtime-broadcaster.interface.ts (1-line change) import type { RealtimeBroadcasterProtocol } from "@repo/core-shared/di/bind-protocols"; export interface IRealtimeBroadcaster extends RealtimeBroadcasterProtocol { /* existing */ } ``` ```ts // packages/core-realtime/src/realtime-handler-registry.ts (1-line change to interface) import type { RealtimeRegistryProtocol } from "@repo/core-shared/di/bind-protocols"; export interface IRealtimeHandlerRegistry extends RealtimeRegistryProtocol { /* existing */ } ``` The `extends` link forces a typecheck failure if a future refactor breaks the protocol surface — the safety net behind keeping interfaces in optional packages. ### 4.4 Feature binder migration All 10 binders (5 features × `bindProduction` + `bindDevSeed`) get converted from positional to context arg: **Before:** ```ts export function bindProductionAuth( config: SanitizedConfig, tracer: ITracer, logger: ILogger, bus: IEventBus, queue: IJobQueue, realtime: IRealtimeBroadcaster, realtimeRegistry: IRealtimeHandlerRegistry, ): void { /* ... */ } ``` **After:** ```ts export function bindProductionAuth(ctx: BindProductionContext): void { const { config, tracer, logger, bus, realtime, realtimeRegistry } = ctx; // body unchanged otherwise; optional fields become guarded: bus?.subscribe(/* ... */); realtimeRegistry?.register(/* ... */); } ``` Same for `bindDevSeedAuth(ctx: BindContext)` (no `config` field). ### 4.5 App aggregator migration `apps/web-next/src/server/bind-production.ts` builds the context once: ```ts import type { IEventBus } from "@repo/core-events"; import type { IRealtimeBroadcaster, IRealtimeHandlerRegistry, } from "@repo/core-realtime"; const ctx: BindProductionContext< IEventBus, IRealtimeBroadcaster, IRealtimeHandlerRegistry > = { config: resolvedConfig, tracer, logger, bus, queue, realtime, realtimeRegistry, }; await bindProductionAuth(ctx); await bindProductionBlog(ctx); // ... all five ``` Same in `bindAllDevSeed` (no `config`). **Generic args evolve per phase.** The example above assumes core-events and core-realtime are present (Phase 0 state). After Phase 2 (core-realtime removal), the imports and the second/third generic args are dropped — `BindProductionContext` falls back to the protocol defaults for the missing slots. After Phase 3 (core-events removal), it's `BindProductionContext` with no generic args. The generator's per-phase actions edit this aggregator file accordingly. ### 4.6 Phase 0 file budget ~12-15 files touched: 2 new in core-shared, 3 minor `extends` changes in optional packages, 10 binder rewrites, 2 app aggregator updates, 1 test file update, doc + HTML refreshes. ## 5. Phase 1 — Generator framework Smallest possible scaffold; no templates yet. ```ts // turbo/generators/config.ts (additions) const CORE_PACKAGE_GENERATORS: Record< string, (a: { name: string }) => PlopTypes.ActionType[] > = { // Phases 2-5 each insert one entry here. }; plop.setGenerator("core-package", { description: "Scaffold an optional core package (realtime, events, trpc, ui)", prompts: [ { type: "list", name: "name", message: "Which optional core package?", choices: [], // Phases 2-5 push entries here. }, ], actions: (answers) => { const a = answers as { name: string }; const handler = CORE_PACKAGE_GENERATORS[a.name]; if (!handler) throw new Error(`No generator for core-package '${a.name}'`); return handler(a); }, }); ``` Plus: - `turbo/generators/templates/core-package/` directory (empty) - `turbo/generators/lib/core-package-utils.ts` — shared helpers: `assertOptionalPackageNotPresent(name)`, `addToTranspilePackages(nextConfigPath, pkgName)`, `splicePluginRulesAt(baseJsPath, anchorName, ruleBlock)` - `turbo/generators/config.test.ts` — confirms `core-package` registered with empty `choices` (regression baseline for phases 2-5) No anchor protocol added in this phase. Optional core packages don't have sub-generators; anchors aren't needed beyond what's already used in the existing feature/realtime/event/job generators. ## 6. Phases 2-5 — Per-package lifecycle Each per-package phase follows a 4-step lifecycle: 1. **Capture template** — copy `packages//**` into `turbo/generators/templates/core-package//` as `.hbs` siblings. Files are verbatim; `.hbs` extension is mechanical (plop requirement). Package name is **not** parameterized — these are concrete preserved templates. 2. **Generator action** — push entry into Phase 1's dispatch table. Each entry returns the actions array: ```ts realtime: () => [ () => assertOptionalPackageNotPresent("core-realtime"), ...emitTemplateTree("core-package/realtime", "packages/core-realtime"), () => addToTranspilePackages("apps/web-next/next.config.mjs", "@repo/core-realtime"), // Phase 2 only — explicit boundaries entry needed for mode: "folder": () => addBoundariesEntry("packages/core-realtime", { mode: "folder" }), // Phase 2 only — re-add ESLint rule files + base.js plugin block: ...emitRealtimeESLintRules(), () => splicePluginRulesAt("packages/core-eslint/base.js", "realtime-rules-imports", REALTIME_IMPORTS), () => splicePluginRulesAt("packages/core-eslint/base.js", "realtime-rules", REALTIME_PLUGIN_BLOCK), () => printRealtimeNextSteps(), ], ``` 3. **Remove from main** — delete the package; remove the consuming-app wiring (`transpilePackages`, app `server.ts` bootstrap, etc.); remove the package's ESLint rules (per "remove + re-add" choice). Anchors stay in `base.js` even when empty. 4. **Verify byte-identical reconstruction** — from the slim tree, run `pnpm turbo gen core-package `. Compare result against snapshot. Run all CI gates. Must be green. "Byte-identical" allows: plop's `.hbs` extension is stripped from emitted file paths; trailing-newline normalization (LF, single trailing newline). Disallows any other content drift. The snapshot stores sha256 of post-normalization content. ### 6.1 Per-package variations | Phase | Package | Special generator actions | Special removal actions | |---|---|---|---| | 2 | core-realtime | Add `mode: "folder"` boundaries entry (placed before the `packages/core-*` wildcard); emit ESLint rules; splice base.js | Strip ESLint rule files + base.js block; strip realtime bootstrap from `apps/web-next/server.ts`; strip realtime params from app aggregator | | 3 | core-events | Splice the inline `no-restricted-syntax` block into base.js for E1 + J | Strip the block from base.js; in `apps/web-next/src/server/bind-production.ts` drop the `bus` and `queue` construction and narrow the `BindProductionContext` generics | | 4 | core-trpc | Mount tRPC root in `apps/web-next` and `apps/web-tanstack` (manual print step — generator doesn't auto-edit app code) | Strip tRPC root mounting from both apps | | 5 | core-ui | Add to Storybook stories list; add to `apps/web-next/next.config.mjs` `transpilePackages`; add to `apps/web-tanstack/vite.config.ts` (workspace package handling) | Strip Storybook story registrations; remove from `apps/web-next/next.config.mjs` and `apps/web-tanstack/vite.config.ts` | ### 6.2 ESLint rule placement after each removal | Phase | After removal: rule files | After removal: base.js | |---|---|---| | 2 | `packages/core-eslint/rules/no-direct-socket-io.{js,test.js}` and `no-realtime-handler-reexport.{js,test.js}` deleted | Imports stripped; `repo-rules` plugin block at end of base.js stripped; anchors `// ` and `// ` remain | | 3 | (No separate files) | E1 / J `no-restricted-syntax` blocks stripped; anchor `// ` remains | | 4 | n/a | n/a (no trpc-specific lint rules) | | 5 | n/a | n/a (no ui-specific lint rules) | ### 6.3 Per-phase printed next-steps Each generator prints copy-paste-ready manual wiring steps after running. The realtime example: ``` ───────────────────────────────────────────────────────────── core-realtime scaffolded into packages/core-realtime/. Manual wiring required: 1. apps/web-next/server.ts — boot Socket.IO + pass into bindAll: import { Server as IOServer } from "socket.io"; import { RealtimeHandlerRegistry, SocketIORealtimeBroadcaster, SocketIORealtimeServer, } from "@repo/core-realtime"; const io = new IOServer(httpServer); const realtime = new SocketIORealtimeBroadcaster(io); const realtimeRegistry = new RealtimeHandlerRegistry(); await bindAll({ realtime, realtimeRegistry }); // ...authenticator + SocketIORealtimeServer.start() ... 2. Feature bind-production.ts files publishing/subscribing via realtime continue to read ctx.realtime / ctx.realtimeRegistry — no changes needed in features (Phase 0 already wired this). 3. Verify: pnpm install pnpm lint && pnpm typecheck && pnpm test pnpm turbo boundaries ───────────────────────────────────────────────────────────── ``` Equivalent print blocks for events / trpc / ui — each tailored to that package's bootstrap requirements. ## 7. Testing strategy Three layers, common to phases 2-5: 1. **Generator-emit unit test** — calls the generator action and asserts emitted file paths against the template tree. No plop integration required. Lives in `turbo/generators/config.test.ts` (per-phase additions). 2. **Generator e2e test** — programmatically runs `turbo gen core-package ` against a tmp workspace, then runs `pnpm install + lint + typecheck + test + boundaries` from the tmp workspace. ~10-15s per package; runs in CI. 3. **Byte-identical reconstruction test** — compares the post-generator package tree against a snapshot at `turbo/generators/__snapshots__/core-package/.snapshot.json` (file paths + sha256 of each file's contents). Generator output must match the snapshot. Snapshots are committed alongside templates so review catches unintended drift. Phase 0 specifically: - `core-shared/src/di/bind-protocols.test.ts` — assignability assertion that `IEventBus`, `IRealtimeBroadcaster`, `IRealtimeHandlerRegistry` are assignable to their respective protocols - `apps/web-next/src/server/bind-production.test.ts` — updated to call binders with `ctx` arg - Each feature's `bind-dev-seed.test.ts` — updated to construct `ctx` and pass Phase 1 specifically: - `turbo/generators/config.test.ts` — asserts `core-package` generator registered with empty `choices` (baseline for later phases) End-to-end acceptance test (final, after Phase 5): ```bash git clone /tmp/test-project cd /tmp/test-project pnpm turbo gen core-package realtime pnpm turbo gen core-package events pnpm turbo gen core-package trpc pnpm turbo gen core-package ui pnpm install && pnpm lint && pnpm typecheck && pnpm test && pnpm turbo boundaries # Result must match pre-slim main byte-identical (modulo lockfile order) ``` ## 8. Documentation & HTML updates Threaded into each phase, plus a final cross-doc sweep. | Phase | Doc updates | HTML updates | |---|---|---| | 0 | `docs/architecture/dependency-flow.md` (BindContext shape replaces positional args); `docs/architecture/vertical-feature-spec.md` (binder signature section); per-feature `AGENTS.md` (binder examples); `CLAUDE.md` (binder rule text) | `docs/architecture/di-explainer.html` (DI binder visualization), `docs/architecture/data-flow-explainer.html` (binder param flow) | | 1 | New `docs/scaffolding/core-package-generator.md`; `CLAUDE.md` Quick Start adds `pnpm turbo gen core-package`; `AGENTS.md` mentions the new generator alongside existing four | none | | 2 | ADR-016 gets "Status: optional, scaffolded via `gen core-package realtime`" header; `docs/guides/realtime.md` adds prerequisite note | `data-flow-explainer.html` realtime arrows marked conditional (dashed lines, "optional" tag) | | 3 | ADR-015 same treatment; `docs/guides/events-and-jobs.md` prerequisite note | events/bus arrows in `data-flow-explainer.html` marked conditional | | 4 | tRPC integration docs in feature `AGENTS.md` files note tRPC is optional | tRPC layer in `data-flow-explainer.html` marked conditional | | 5 | `core-ui` README + Storybook docs note ui is optional | UI layer in `data-flow-explainer.html` marked conditional | Final sweep (after Phase 5): - New file: `docs/architecture/template-tiers.md` — explains must-have / optional / generator-scaffolded model with the canonical list - `README.md` (project root) — short "Optional packages" section near Quick Start with the four scaffold commands - HTML explainers — final pass to confirm conditional/optional markers are visually clear and match the documented model - All "Read first" lists in `CLAUDE.md` and `AGENTS.md` re-checked for broken references ## 9. Out of scope Explicitly NOT in this design: 1. **A `core-package remove` generator.** Phases 2-5 do removal once each. Downstream forks remove what they don't need by editing files directly. 2. **Generator templates for new core packages of arbitrary shape** (the `kind`-prompted generic generator). Captured here for posterity; future ADR. 3. **Slimming the must-have set further.** `core-shared`, `core-eslint`, `core-typescript`, `core-testing`, `core-cms`, `core-api` remain unconditionally present. 4. **Slimming the feature set.** All 5 features stay; making them opt-in is a separate (much bigger) design. 5. **Per-app generators.** This work doesn't touch `apps/cms` or `apps/web-tanstack` beyond transpile-package consistency. 6. **Doc relocation automation.** ADRs and guides stay in `docs/` permanently with status headers; the generator does not move documentation files. ## 10. Related - ADR-008 — per-feature DI containers (binder shape rules) - ADR-014 — instrumentation (where `ITracer` / `ILogger` live in core-shared) - ADR-015 — events and jobs (introduces `IEventBus` / `IJobQueue`; this design moves the protocol into core-shared) - ADR-016 — realtime layer (introduces `IRealtimeBroadcaster`; this design moves the protocol into core-shared) - Memory: "Future capability — core-package generator" (`/Users/danijel/.claude/projects/-Users-danijel-Documents-Projects-template-vertical/memory/project_core_package_generator.md`) — original ask