Split a router into controllers
How-to. For an API that has outgrown one
sync. For the shape of a single-slice router, see Serve an oRPC contract over HTTP.
HttpRouter(contract)(deps, { sync }) puts every procedure's implementation in one function. That is right for a small API and wrong for a large one: a fifty-procedure contract would mean fifty injected services in one sync, one slice's typo failing the whole router's type-check, and no way to serve one slice without the rest. A controller is the fix: an ordinary di provider over one fragment of the contract, minted its own port, composed by the root through a keyed HttpRouter(contract)(controllers) call. Everything below is lifted from examples/order-api, which serves an orders slice and a customers slice this way.
Step 1 — a fragment per slice
A slice's contract is a plain RouterContract — the same shape the positional form already takes, just smaller — and the root contract is a record of them:
const ordersContract = {
place: oc
.input(type<{ readonly id: string; readonly quantity: number }>())
.output(type<OrderView>())
.errors({
INVALID_QUANTITY: { data: type<OrderRef>() },
CONFLICT: { data: type<OrderRef>() },
}),
find: oc
.input(type<OrderRef>())
.output(type<OrderView>())
.errors({ NOT_FOUND: { data: type<OrderRef>() } }),
};
const customersContract = {
find: oc
.input(type<{ readonly id: string }>())
.output(type<CustomerView>())
.errors({ NOT_FOUND: { data: type<{ readonly id: string }>() } }),
};
export const contract = {
orders: ordersContract,
customers: customersContract,
};The fragments stay module-private; contract is the only export, and every consumer below reaches a fragment through it — contract.orders, contract.customers.
Step 2 — a controller per slice
HttpController(name, fragment)([deps], { sync }) is HttpRouter's own shape, aimed at one fragment: the first call fixes the fragment's type and mints a port under name; the second is di's Provider(port)(deps, { sync }), so sync's return is typed by the fragment at the call — a typo'd or missing procedure is a compile error inside the controller itself, not at the root:
export const ordersController = HttpController(
"OrdersController",
contract.orders,
)([PlaceOrder, FindOrder], {
sync: (place, find) => ({
place: ({ errors }, input) =>
place
.execute(input.id, input.quantity)
.map(view)
.mapErrCases((matcher) =>
matcher
.with(P.tag("InvalidQuantity"), (error) =>
errors.INVALID_QUANTITY({
message: error.message,
data: { id: error.id },
}),
)
.with(P.tag("DuplicateOrder"), (error) =>
errors.CONFLICT({
message: error.message,
data: { id: error.id },
}),
),
),
find: ({ errors }, input) =>
find
.execute(input.id)
.map(view)
.mapErrCases((matcher) =>
matcher.with(P.tag("OrderNotFound"), (error) =>
errors.NOT_FOUND({
message: error.message,
data: { id: error.id },
}),
),
),
}),
});The controller does no oRPC work of its own — it stores a plain record, and HttpRouter wraps each leaf in .result(...) when it composes the router. HttpController mints the port and carries it back on .port, which the keyed form reads to order this controller's construction before the router's — there is nothing to name by hand. A slice ships its controller as a module that imports the vertical it needs and exports only that controller, the same privacy di already gives any provider:
export const OrdersSlice = Module("OrdersSlice")({
imports: [OrderApplicationModule, OrderPersistenceModule],
provides: [ordersController],
exports: [ordersController],
});exports takes the provider itself, not ordersController.port: the port was minted inside HttpController, so there is no class to spell back off it. Importing the vertical here rather than leaving PlaceOrder and FindOrder as needs for the root is what makes the slice a unit — the reason to open this directory is the whole reason it exists. A vertical is a pair of modules of its own — the customers slice imports CustomerApplicationModule and CustomerPersistenceModule — so importing one slice's vertical brings none of another's. Where slices do converge, on the internal database module both persistence modules import, it is a diamond and not duplication: di flattens the module tree into a Set keyed by provider reference, so one database is built.
Step 3 — the keyed root
HttpRouter(contract)(controllers) — a record keyed by the contract's own top-level keys, one HttpController per key — replaces the positional (deps, { sync }) call at the root:
export const orderRouter = HttpRouter(contract)({
orders: ordersController,
customers: customersController,
});The composition root is then a list of slices, plus whatever no slice owns:
export const OrderApi = HttpModule("OrderApi")({
router: orderRouter,
imports: [OrdersSlice, CustomersSlice, observability()],
exports: [Logger],
});observability() is here because every slice's layers write to its Logger and none of them owns it; Logger is exported because the per-request module reads it. Nothing else about what a slice needs is spelled at the root.
This form is exact: a key the record above is missing, a key the contract does not declare, and a controller wired under the wrong key are all compile errors at the HttpRouter(contract)({...}) call, not runtime surprises the first time a client hits the missing slice.
Step 4 — lifting a slice into its own process
Because a fragment is itself a valid RouterContract, a slice can be served alone — and none of the files above change. The lifted root declares the controller's own port as its single dependency and hands back what that controller built:
export const ordersRouter = HttpRouter(contract.orders)(
[ordersController.port],
{
sync: (implementation) => implementation,
},
);
export const OrdersApi = HttpModule("OrdersApi")({
router: ordersRouter,
imports: [OrdersSlice, observability()],
});OrdersSlice is the very module the modulith imported and ordersController the very provider it composed — not a copy, not a rewritten sync. Extraction is a new composition root and one fewer import, and the slice itself is untouched. packages/http/src/controller.test-d.ts pins that call as its fifth gate: the property is marked do-not-break, and it is what makes composing slices into one router a starting point rather than a trap.
See also
- Serve an oRPC contract over HTTP — the positional form, and everything the starter itself decides.
@btravstack/http—HttpControllerandHttpRouter's full signatures.- Order API (HTTP) — the two-slice example these samples come from.