Skip to content

HttpController minted by contract key, HttpRouter composing an array — one shape, all three transports #112

Description

@btravers

From #108's review discussion (uniform DX across the three transports), and a prerequisite to merging it.

The asymmetry

The root CLAUDE.md claims one shape — a piece is a provider; a slice is a module; a modulith is several slice modules in one root — one shape, all three transports — and the two workers deliver it: AmqpHandler(contract, key) and TemporalWorkflowActivities(contract, key) mint the piece's port from the contract key, so AmqpHandlers(contract)([...]) / TemporalActivities(contract)([...]) compose an anonymous array, exact through the port ids against an UNCOVERED … marker. Adding a slice is: create the directory, run pnpm generate. Zero root edits.

HTTP is the odd one out. api.HttpController(name, fragment) takes a fragment value, not a key — the piece does not know which contract key it implements — so the composing form is a keyed record the root hand-writes: api.HttpRouter(contract)({ orders: ordersController, … }). The edit is compiler-forced (the record is exact), so HTTP never had the WiringDefect class; but the authoring flow is create, generate, then edit the root, and the teaching surface now shows two flows for one concept. #108's slice-tree generator makes the gap visible: workers consume a generated pieces array; HTTP needs named re-exports plus a hand edit.

The package's own spec already contains the observation that closes it: authenticatorPort(scheme) mints Port(`HttpAuthenticator:${scheme}`) — described in packages/http/CLAUDE.md as "the move AmqpHandler(contract, key) makes". This issue is that move, applied to the controller itself.

Proposed shape

// piece: minted by contract key, symmetric with AmqpHandler(orderContract, "orderNotifications")
export const piece = api.HttpController(contract, "orders")(
  { place: PlaceOrder, find: FindOrder, logger: Logger },
  { sync: ... },
);

// root: an array, symmetric with AmqpHandlers(orderContract)(pieces)
export const orderRouter = api.HttpRouter(contract)(pieces);
  • The port id carries the key (HttpController:orders-style), as HttpAuthenticator:<scheme> already does; the bespoke display name ("OrdersController") goes the way the workers' went.
  • The fragment's type is derived, not passed: Inherit<C[K], RequirementsOf<C>> — the exact computation the keyed record form's ControllerFor already performs, so a root-marked contract's requirements reach the piece at mint time and context.principal types as today.
  • Coverage: every contract key must be covered by exactly one piece in the array — the workers' UNCOVERED … marker pattern, including the named-missing-key trailing diagnostic once lengths match; two pieces claiming one key are di's duplicate-provider defect at build.
  • Runtime: the array reassembles into the record by port id, then the existing routerOf walk is unchanged.

With this, #108's generator loses its controller mode entirely: controller.ts pieces join the ordinary pieces array, slices.gen.ts has one shape in all three apps, and adding a slice is create the directory, run generate — everywhere.

Open questions the design must answer

  1. The do-not-break property (controller.test-d.ts's fifth gate): a slice lifts out of the modulith with its piece unchanged. A key-minted piece is keyed against the root contract, so the lifted root plausibly becomes api.HttpRouter({ orders: contract.orders })([piece]) — the fragment wrapped in a one-key record, the piece untouched. The property must survive in some spelling, pinned by the same gate, or this issue is rejected on that ground.
  2. Fate of the keyed record form. The repo prefers THE one way. Does HttpRouter(contract)({...}) survive as a second composing form, or does the array replace it? (The (deps, arm) bare form stays either way — it is the primitive.) Note HttpRouter currently disambiguates its three forms by arity plus whether sync holds a function; an array argument is trivially distinguishable, but four forms is an argument for retiring one.
  3. Scopes and marked fragments ride Inherit/Effective unchanged in principle — auth.test-d.ts's arms must all still fire, both directions, against key-minted pieces.
  4. Migration: examples/order-api, both split how-tos, docs/reference/http.md, the five controller.test-d.ts gates re-pinned against the array form, and feat: the slice tree is generated — slices.gen.ts, fixed slice/piece exports, drift as a spec #108 rebased to drop controller mode.

Acceptance

  • api.HttpController(contract, key) mints the piece from the key; api.HttpRouter(contract)([pieces]) composes with worker-grade exactness (uncovered key refused and named; duplicate key a build defect).
  • The lifted-slice property survives with the piece unchanged, pinned where it is pinned today.
  • examples/order-api reads identically to the two workers: same slice files, same generated slices.gen.ts shape, zero root edits to add a slice.
  • Blocks feat: the slice tree is generated — slices.gen.ts, fixed slice/piece exports, drift as a spec #108: that PR merges only after (and rebased on) this, or both are dropped together per the uniform-DX decision.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions