6-phase design for slimming the default template to a minimal kernel and providing turbo generators that scaffold optional core packages (realtime, events, trpc, ui) back when needed. Phase 0 introduces a BindContext object in core-shared with bounded generics over minimal protocol types; optional packages keep their full interfaces but extends-link to the protocols. This lets feature binders take a single ctx arg and lets optional packages disappear without breaking typechecks. Phases 1-5 ship the generator framework + per- package templates + per-package removal from main. Companion ADR will be assigned at implementation time (expected ADR-017). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
21 KiB
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/bindDevSeedXtakes a 7-arg positional signature includingbus, 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.
IEventBusandIRealtimeBroadcasterare 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
- Introduce a
BindContexttype incore-sharedwith required (tracer,logger,config) and optional (bus,queue,realtime,realtimeRegistry) fields. Optional fields use bounded generics over minimal protocol types (EventBusProtocol,RealtimeBroadcasterProtocol,RealtimeRegistryProtocol) defined incore-shared. - Optional packages keep their full interfaces (
IEventBus,IRealtimeBroadcaster, etc.) but declare themextendsthe corresponding protocol — typechecks fail if a refactor breaks the protocol surface. - Convert all 5 features'
bindProduction<X>/bindDevSeed<X>from positional args to single-arg(ctx: BindContext). App aggregator binders construct the context once. - Add
pnpm turbo gen core-packagegenerator with anameprompt 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'transpilePackageslist, and patchescore-eslint/base.jsfor any rules that need re-adding. - Once a generator works, remove that package from
mainalong with its consuming-app wiring and its ESLint rules (per the user's preference for "remove + re-add"). Themaintemplate ends in a slimmed state. - Per-package documentation (ADR-016, realtime guide, etc.) stays in
docs/permanently with a "Status: optional, scaffolded viagen 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
// 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
// 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
// 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 */ }
// 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 */ }
// 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:
export function bindProductionAuth(
config: SanitizedConfig,
tracer: ITracer,
logger: ILogger,
bus: IEventBus,
queue: IJobQueue,
realtime: IRealtimeBroadcaster,
realtimeRegistry: IRealtimeHandlerRegistry,
): void { /* ... */ }
After:
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:
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.
// 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— confirmscore-packageregistered with emptychoices(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:
-
Capture template — copy
packages/<pkg>/**intoturbo/generators/templates/core-package/<pkg>/as.hbssiblings. Files are verbatim;.hbsextension is mechanical (plop requirement). Package name is not parameterized — these are concrete preserved templates. -
Generator action — push entry into Phase 1's dispatch table. Each entry returns the actions array:
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(), ], -
Remove from main — delete the package; remove the consuming-app wiring (
transpilePackages, appserver.tsbootstrap, etc.); remove the package's ESLint rules (per "remove + re-add" choice). Anchors stay inbase.jseven when empty. -
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
.hbsextension 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:
- 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). - Generator e2e test — programmatically runs
turbo gen core-package <name>against a tmp workspace, then runspnpm install + lint + typecheck + test + boundariesfrom the tmp workspace. ~10-15s per package; runs in CI. - 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 thatIEventBus,IRealtimeBroadcaster,IRealtimeHandlerRegistryare assignable to their respective protocolsapps/web-next/src/server/bind-production.test.ts— updated to call binders withctxarg- Each feature's
bind-dev-seed.test.ts— updated to constructctxand pass
Phase 1 specifically:
turbo/generators/config.test.ts— assertscore-packagegenerator registered with emptychoices(baseline for later phases)
End-to-end acceptance test (final, after Phase 5):
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.mdandAGENTS.mdre-checked for broken references
9. Out of scope
Explicitly NOT in this design:
- A
core-package removegenerator. Phases 2-5 do removal once each. Downstream forks remove what they don't need by editing files directly. - Generator templates for new core packages of arbitrary shape (the
kind-prompted generic generator). Captured here for posterity; future ADR. - Slimming the must-have set further.
core-shared,core-eslint,core-typescript,core-testing,core-cms,core-apiremain unconditionally present. - Slimming the feature set. All 5 features stay; making them opt-in is a separate (much bigger) design.
- Per-app generators. This work doesn't touch
apps/cmsorapps/web-tanstackbeyond transpile-package consistency. - 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/ILoggerlive 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