Split into slices
Tutorial. The fourth hands-on lesson. It assumes you finished Protect the API and have the marked contract,
auth.tsand the router from it. We keep explanation to a minimum here and link out to it.
By the end, the service will have two verticals — greetings and farewells — each owning its piece of the contract, its controller and its dependencies, composed by a root that is a list of slices. The router becomes an array: one piece per contract fragment, minted from the path it serves, and a path the contract does not declare refuses to compile — at the piece's own mint. This is the shape an application keeps as it grows, and the reason composing slices is a starting point rather than a trap.
Step 1 — Split the contract into fragments
No new install. Group the existing procedures under a greetings key and add a farewells fragment beside it:
contract.ts
import { authenticated } from "@btravstack/contract";
import { oc } from "@orpc/contract";
import { z } from "zod";
const greetings = {
hello: oc
.input(z.object({ name: z.string() }))
.output(z.object({ message: z.string() })),
greetMe: authenticated({ user: [] })(
oc.output(z.object({ message: z.string() })),
),
};
const farewells = {
goodbye: oc
.input(z.object({ name: z.string() }))
.output(z.object({ message: z.string() })),
};
export const contract = { greetings, farewells };A fragment is itself a valid contract — a client can import contract.farewells alone — which is what lets a slice lift out into its own process later without its controller changing (the property).
Step 2 — A controller per fragment
api.OrpcController(contract, path) is api.OrpcRouter's slice-sized form: the same inject record, the same sync arm, typed by one node of the contract tree — and it mints a port from the path itself, so there is no class to name:
slices/greetings/controller.ts
import { OkAsync } from "unthrown";
import { api } from "../../auth.js";
import { contract } from "../../contract.js";
import { Greeter } from "../../greeter.js";
export const greetingsController = api.OrpcController(
contract,
"greetings",
)({
inject: { greeter: Greeter },
sync: ({ greeter }) => ({
hello: (_helpers, input) => OkAsync({ message: greeter.greet(input.name) }),
greetMe: ({ context }) =>
OkAsync({ message: greeter.greet(context.principal.name) }),
}),
});slices/farewells/controller.ts
import { OkAsync } from "unthrown";
import { api } from "../../auth.js";
import { contract } from "../../contract.js";
import { Greeter } from "../../greeter.js";
export const farewellsController = api.OrpcController(
contract,
"farewells",
)({
inject: { greeter: Greeter },
sync: ({ greeter }) => ({
goodbye: (_helpers, input) =>
OkAsync({ message: `${greeter.greet(input.name)} And goodbye.` }),
}),
});The bodies moved, unchanged, out of router.ts. greetMe's principal is still typed — the fragment carries its mark with it.
Step 3 — A module per slice
Each slice ships as an ordinary di module that provides its controller and exports it — and states, out loud, where its dependencies come from. Two shapes, both legitimate:
slices/greetings/module.ts
import { Module } from "@btravstack/di";
import { GreetingModule } from "../../greeter.js";
import { greetingsController } from "./controller.js";
export const GreetingsSlice = Module("GreetingsSlice")({
imports: [GreetingModule],
provides: [greetingsController],
exports: [greetingsController],
});slices/farewells/module.ts
import { Module } from "@btravstack/di";
import { Greeter } from "../../greeter.js";
import { farewellsController } from "./controller.js";
export const FarewellsSlice = Module("FarewellsSlice")({
// "Some root supplies this": the slice has no vertical of its own, so it
// names what it expects instead of importing one.
needs: [Greeter],
provides: [farewellsController],
exports: [farewellsController],
});GreetingsSlice imports the vertical it owns. FarewellsSlice owns none — it borrows Greeter — so it declares the need instead; leave needs off and the Module(...) call refuses to compile, naming the port (di's NeedsGate). Either way, a slice directory is readable on its own: which ports come from outside, without naming who supplies them.
Step 4 — The composed router and the new root
The router composes the controllers from an array, each already carrying its own path — exact against the contract's procedures. The root becomes a list of slices, plus what no slice owns:
app.ts
import { HttpModule } from "@btravstack/http-server";
import { api } from "./auth.js";
import { contract } from "./contract.js";
import { GreetingModule } from "./greeter.js";
import { farewellsController } from "./slices/farewells/controller.js";
import { FarewellsSlice } from "./slices/farewells/module.js";
import { greetingsController } from "./slices/greetings/controller.js";
import { GreetingsSlice } from "./slices/greetings/module.js";
export const greetingRouter = api.OrpcRouter(contract)([
greetingsController,
farewellsController,
]);
export const App = HttpModule("App")({
router: greetingRouter,
// GreetingModule appears twice in the tree — here, and inside
// GreetingsSlice. di dedupes by provider reference, so the diamond builds
// ONE Greeter — and this root-level import is what discharges
// FarewellsSlice's declared need.
imports: [GreetingsSlice, FarewellsSlice, GreetingModule],
});Delete router.ts — its job moved into the slices. main.ts has not changed since lesson one.
The composing form is exact both ways. Every procedure must be covered by some piece's path, and a path the contract never declared is refused at the mint — there is nothing to type the key by:
// @ts-expect-error — the contract declares no fragment under `ceremonies`
api.OrpcController(contract, "ceremonies");Step 5 — Run it
GREETING=Ahoy PORT=3000 npx tsx src/main.tscurl -X POST http://localhost:3000/rpc/greetings/hello \
-H 'content-type: application/json' -d '{"json":{"name":"world"}}'
curl -X POST http://localhost:3000/rpc/farewells/goodbye \
-H 'content-type: application/json' -d '{"json":{"name":"world"}}'The fragment keys became path segments — the contract's shape is the API's shape. greetMe still wants its bearer, exactly as in lesson three.
What you now have
src/contract.ts two fragments under one contract
src/auth.ts unchanged since lesson three
src/slices/greetings/controller.ts the fragment's implementation
src/slices/greetings/module.ts imports its vertical
src/slices/farewells/controller.ts the other fragment's implementation
src/slices/farewells/module.ts declares its need instead
src/app.ts composed router + a list of slicesGrowing the application is now additive: a new vertical is a new fragment, a new slice directory, and two lines in app.ts. The examples run this shape at full size — Order API is the same anatomy with a database behind it.
Where next
- The same application, a second runtime — the finale: boot
GreetingModuleunder a Temporal worker. - Split a router into controllers — the recipe, including lifting a slice into its own process.
- Modules and privacy — why a slice's internals stay invisible, and what
exportsactually withholds.