# 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<X>` / `bindDevSeed<X>` 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 <name>`" 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<T>(event: { name: string }, payload: T): Promise<void>;
  subscribe<T>(
    event: { name: string },
    consumer: string,
    handler: (payload: T) => Promise<void>,
  ): void;
};

export type RealtimeBroadcasterProtocol = {
  broadcast<T>(
    channel: { name: string; key?: unknown },
    payload: T,
  ): Promise<void>;
};

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<Bus, Realtime, RealtimeReg> & {
  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<IEventBus>` 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/<pkg>/**` into `turbo/generators/templates/core-package/<pkg>/` 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 <name>`. 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 `// <gen:realtime-rules-imports>` and `// <gen:realtime-rules>` remain |
| 3 | (No separate files) | E1 / J `no-restricted-syntax` blocks stripped; anchor `// <gen:events-rules>` 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 <name>` 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/<name>.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 <slimmed-template> /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
