Skip to content

Controllers and dependencies

A controller groups the routes of one part of an API and gives them the services they depend on.

controller(name, build) takes a name and a function from dependencies to routes, and returns a function that takes the same dependencies. Declare the dependencies as the function’s parameter, usually with an interface:

import { controller, httpError, route } from "@tetsujs/core";
export interface OrdersDeps {
readonly
orders: OrderService
orders
: OrderService;
}
export const ordersController = controller("Orders", ({
orders: OrderService
orders
}: OrdersDeps) => ({
list: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/orders"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/orders",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Order[]

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () =>
orders: OrderService
orders
.all() }),
get: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/orders/:id"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/orders/:id",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}) => Order

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
) => {
const
const order: Order | undefined
order
=
orders: OrderService
orders
.find(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {
id: string;
};
}
ctx
.
params: {
id: string;
}
params
.
id: string
id
);
if (!
const order: Order | undefined
order
) throw httpError(404, "ORDER_NOT_FOUND");
return
const order: Order
order
;
},
}),
}));

Calling it with the dependencies gives a plain object whose fields are the routes, tagged with the controller’s name. The application reads those fields and nothing else. A controller without dependencies is called with no arguments.

Because the result is plain data, you can unit-test a handler by calling it: ordersController(fakes).get.handler(testCtx({ params: { id: "1" } })). Testing covers testCtx().

@tetsujs/openapi builds every operationId from the controller’s name and the route’s field: list in "Orders" becomes ordersList. A generated client names its methods after these, so changing the name changes the client. Renaming the variable does not. To set an id by hand, use docs: { operationId } on the route.

The name is also what ctx.route.controller reports in logs and metrics.

Two controllers in one application cannot share a name, and createApp() refuses it at startup. To serve two versions of an API from one function, declare it twice under two names: controller("UsersV1", users) and controller("UsersV2", users), each mounted under its own group.

Wire the application by hand, in one place. Build every service there once and pass it to the controllers that need it. There is no container and no registration.

import type { Database } from "bun:sqlite";
import { createApp, group } from "@tetsujs/core";
import { requestId } from "@tetsujs/request-id";
import { accessLog } from "@tetsujs/request-log";
export function buildApp(
db: Database
db
: Database,
tokens: ReadonlyMap<string, User>
tokens
: ReadonlyMap<string, User>) {
const
const notes: NoteStore
notes
= new NoteStore(
db: Database
db
);
const
const sessions: Sessions
sessions
= new Sessions(
tokens: ReadonlyMap<string, User>
tokens
);
return createApp({
hooks: { beforeParse: [requestId()], afterResponse: [accessLog()] },
routes: group("/api", { children: [notesController({
notes: NoteStore
notes
,
sessions: Sessions
sessions
})] }),
});
}

buildApp takes the database instead of opening it, so main.ts can open a file and the tests an in-memory one. The full example is examples/app, a small notes API on bun:sqlite.

To get a controller’s dependency type without importing the interface, use Parameters<typeof notesController>[0].

A hook that needs a service is built inside the controller, from the dependency it received:

import { controller, hook, HttpError, route } from "@tetsujs/core";
const authenticate = (
sessions: Sessions
sessions
: Sessions) =>
hook.beforeParse((ctx) => {
const
const token: string | undefined
token
= ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
.
headers: Headers

The headers read-only property of the with the request.

MDN Reference

headers
.get("authorization")?.replace(/^Bearer /, "");
const
const user: User | undefined
user
=
const token: string | undefined
token
?
sessions: Sessions
sessions
.find(
const token: string
token
) : undefined;
if (!
const user: User | undefined
user
) throw new HttpError(401);
return {
user: User
user
};
});
export const notesController = controller(
"Notes",
({
notes: NoteStore
notes
,
sessions: Sessions
sessions
}: {
notes: NoteStore
notes
: NoteStore;
sessions: Sessions
sessions
: Sessions }) => {
const signedIn = authenticate(
sessions: Sessions
sessions
);
return {
list: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/notes"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes",
hooks: { beforeParse: [signedIn] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}) => Note[]

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: (
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}
ctx
) =>
notes: NoteStore
notes
.list(
ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
user: User;
}
ctx
.
user: User
user
.
id: string
id
),
}),
};
},
);

A hook whose state several controllers must share is different. One rate limit for the whole API is one rateLimit() instance; made inside each controller, it would be a separate limit per controller. Make such a hook once in the composition root and pass it in like a service:

import { controller, createApp, route } from "@tetsujs/core";
import type { RateLimitHook } from "@tetsujs/rate-limit";
import { rateLimit } from "@tetsujs/rate-limit";
const notesController = controller("Notes", ({
notes: NoteStore
notes
,
limit: RateLimitHook
limit
}: {
notes: NoteStore
notes
: NoteStore;
limit: RateLimitHook
limit
: RateLimitHook }) => ({
list: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/notes"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/notes",
hooks: {
beforeParse: readonly [RateLimitHook]
beforeParse
: [
limit: RateLimitHook
limit
] },
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => Note[]

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () =>
notes: NoteStore
notes
.list(),
}),
}));
const limit = rateLimit({
limit: number

Hits allowed per window: an integer, 0 included — a limiter that refuses everything.

limit
: 100,
windowMs: number

Length of the window in milliseconds: a positive, finite number.

Anything else is refused when the limiter is made. NaN — what Number(process.env.RATE_WINDOW) reads as when the variable is not set — and 0 used to start a new window on every request, so nothing was ever refused while the headers went on reporting a budget.

windowMs
: 60_000,
key: (ctx) => ctx.
server: Bun.Server<unknown>

The server handling this request — the real Bun.Server, whole and unguarded, like req an escape hatch to the platform.

The way to reach connection- and server-level facts a Request does not carry: ctx.server.requestIP(ctx.req) for rate limiting by address, ctx.server.timeout(ctx.req, seconds) for a per-request idle timeout.

An address is in the form the socket reports it: a server listening on both stacks — Bun's default — reports an IPv4 client as ::ffff:203.0.113.7, which a comparison with 203.0.113.7 misses.

Deliberately not a facade: every hook is code the application author wrote or vetted, and hiding stop/reload from in-process code protects nothing. They are still process-level operations with no business inside a request — calling ctx.server.stop() from a hook takes the whole listener down.

upgrade is for the endpoints ws() declares, which the application upgrades itself. A socket upgraded by hand reaches the application's websocket handler with no endpoint on it: the connection drops, and each of its events is reported as a failure.

server
.requestIP(ctx.
req: Request & {
readonly cookies?: Bun.CookieMap;
}

The raw incoming request, always available as an escape hatch.

On a request that matched a route, Bun's router delivers its own request object carrying cookies — a Bun.CookieMap whose mutations are applied to the response as Set-Cookie automatically, with Bun's defaults (Path=/; SameSite=Lax). The field is optional because it does not exist where Bun's router was not involved: the 404 fallback and unit-tested handlers. ctx.out.headers.append("set-cookie", ...) is the fallback that works everywhere.

req
)?.
address: string | undefined

The IP address of the client.

address
,
});
createApp({ routes: notesController({
notes: NoteStore
notes
:
const store: NoteStore
store
,
limit: RateLimitHook
limit
}) });

See Groups and mounting for more on where hooks and their state live.

A route reads its hooks and schemas when it is declared. A function has its dependencies from its first line, so a hook built from one of them gets the real service.

In a class, fields are initialized before the constructor’s parameters are assigned. A route declared as a field that builds a hook from a constructor argument gets undefined. TypeScript reports the direct case as error TS2729. A class instance can still be mounted, and it is named after its class, but controller() avoids the problem.

Services can stay classes. A service is called by other code; a controller is a declaration made once at startup.

A controller that needs the built application, for example to list or document its routes, cannot get it as a dependency: the application does not exist yet. Give the controller a method under the onMount symbol. createApp() calls it once with the application, after the route table is built and before it returns:

import type { App } from "@tetsujs/core";
import { controller, onMount, route } from "@tetsujs/core";
export const routesController = controller("Routes", () => {
let
let mounted: App<RouteMap> | undefined
mounted
: App | undefined;
return {
[onMount]: (
app: App<RouteMap>
app
: App) => {
let mounted: App<RouteMap> | undefined
mounted
=
app: App<RouteMap>
app
;
},
list: route({
method: "GET"

The method this route answers.

Kept as a literal rather than widened to

Method

: the method is half of a route's identity, and an application that remembers its routes — for a generated client, for tooling — needs to know which one this is.

method
: "GET",
path: "/routes"

Route path with :param segments, e.g. "/orders/:id/cancel".

Must start with /, contain no empty segments and no trailing slash; a malformed literal is a compile error.

path
: "/routes",
handler: (ctx: {
readonly req: Request & {
readonly cookies?: Bun.CookieMap;
};
readonly server: Bun.Server<unknown>;
readonly out: Outgoing;
readonly route: RouteInfo;
readonly startedAt: number;
readonly params: {};
}) => string[]

The endpoint logic; ctx is fully inferred, never annotate it.

The return type is inferred rather than demanded, and checked twice over. Against the route's own contract, by the

HandlerResult

bound on R: answering with something response never declared is a compile error that says so, instead of a structural diff against Response. And against what the framework can serialize at all, by the intersected

ValidateResult

: a stream handed over bare is refused whether or not the route declared anything, because that is the case no contract covers — without a response schema HandlerResult is unknown and accepts every value there is.

handler
: () =>
let mounted: App<RouteMap> | undefined
mounted
?.
entries: readonly RouteTableEntry[]

The compiled route table, for diagnostics and tooling.

The runtime half of what the type parameter carries: entries is what the table found, Routes is what the types remember of the same walk — the method, full path and schemas of every route, keyed by "METHOD /full/path". A generated client reads the second; a route listing reads the first.

entries
.map((
entry: RouteTableEntry
entry
) => `${
entry: RouteTableEntry
entry
.
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
method
} ${
entry: RouteTableEntry
entry
.
path: string

Full path: group prefixes joined with the route's own path.

path
}`) ?? [],
}),
};
});

It runs at startup, so an error thrown there stops the process. This is how docs() from @tetsujs/openapi builds its document.