Skip to content

A file-tree convention for slices, generated like TanStack Router's route tree #71

Description

@btravers

From a discussion of whether a TanStack Router-style file-tree convention fits here. Follows on from #59 (scaffolding) — that issue solves creating a slice, this one solves keeping the root honest afterwards.

The tree already exists

All three example apps independently converged on the same shape, and the piece's filename already encodes its transport:

src/slices/<name>/module.ts
src/slices/<name>/controller.ts    # order-api        (http)
src/slices/<name>/handler.ts       # order-amqp-worker    (amqp)
src/slices/<name>/activities.ts    # order-temporal-worker (temporal)
src/module.ts                      # the root, kept in sync BY HAND

Nothing enforces it and nothing reads it.

Why this is compatible with the thesis, unlike decorators

TanStack Router does not scan at runtime. A plugin watches the tree and writes routeTree.gen.ts, a plain typed TypeScript module the application imports. Inference survives completely because the output is source code, not metadata. That is the opposite of the reflection this repo rules out, and it is the only reason the idea is worth considering here.

The inversion to avoid

This repo is contract-first: orderContract declares what exists and HttpRouter(contract)(controllers) is exact against it. TanStack Router is file-first: the files declare the routes. Adopting that direction wholesale would invert the thesis.

The resolution: files declare the wiring, the contract keeps declaring the surface. The two then check each other — the generated tree supplies the pieces, the contract's own coverage gate (["UNCOVERED HANDLERS", K] / ["UNCOVERED ACTIVITIES", K]) fires when one is missing, and a slice naming a key the contract does not declare fails at its own AmqpHandler(contract, key) call. Neither side becomes the single source of truth.

What it would generate

About twenty lines:

// slices.gen.ts — generated, committed
export const slices = [NotificationsSlice, AuditSlice] as const;
export const pieces = [orderNotifications, orderAudit] as const;
export const OrderAmqpWorker = AmqpModule("OrderAmqpWorker")({
  contract: orderContract,
  handlers: AmqpHandlers(orderContract)(pieces),
  imports: [...slices, OrderApplicationModule, OrderPersistenceModule, observability()],
  provides: [relayConfig, outboxRelay],
  exports: [PlaceOrder, OrderRepository, Outbox, Logger],
});

The actual argument for it

It closes the one mistake this type system cannot catch. #59 names it: di's flatten discovers providers through imports and provides, never through a provider's own deps — so a slice that exists but was never imported fails at start with a runtime WiringDefect, not a compile error. PR #49 hit exactly this twice, once in a package spec and once in the recording fixture.

For a repo whose whole claim is that wiring mistakes are compile errors, structurally removing the single wiring mistake that is not one is worth more than the typing convenience.

Do NOT map directory names to contract keys

Measured across the three apps — the mapping does not hold, and forcing it would produce worse names:

slice directory contract key
orders, customers orders, customers (match)
notifications, audit orderNotifications, orderAudit (no)
fulfillment, billing fulfillOrder, chargeOrder (no)

slices/fulfillOrder/ reads worse than slices/fulfillment/. Codegen does not need the key anyway — it only collects modules and pieces; the keys stay in the piece files where they already are, and the existing coverage gate does the checking.

Costs, stated plainly

  • A staleness window. The generated file can drift. TanStack handles it with a watcher, a committed output, and a CI check; the equivalent here is generate && git diff --exit-code, which is a new gate.
  • Fixed export names. There are 12 bespoke ones today (AuditSlice, orderAudit, ordersController, FulfillmentSlice, …). Codegen wants a convention — export const slice / export const piece, TanStack's Route pattern — which reads worse at the definition site and better everywhere else. This is the part most likely to be judged not worth it.
  • It is a build step, in a repo that has been proud of needing no magic.

The precedent and most of the wiring already exist: turbo.json has a generate task with outputs: ["src/generated/**"], and typecheck, build and test all depend on generate and ^generate. The root CLAUDE.md also documents the footgun to avoid — two generators for the same output running concurrently produce EEXIST, which is why one generator ordered by the task graph is the shape.

Acceptance

  • A decision first, on the two questions that decide whether this is worth building at all: are fixed export names acceptable, and is a committed generated file acceptable? If either is no, close this.
  • If built: the generated file is typed source, committed, and checked in CI by regenerating and diffing.
  • The contract stays the source of truth for the surface; the tree only supplies wiring.
  • No directory-name-to-contract-key mapping.
  • A slice added to the tree but absent from the generated root is impossible by construction — that is the whole point.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Wanted, not urgentdecisionNeeds a choice, not codeenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions