Controllers are plain classes in app/Controllers/. Each public method is an
action bound to a route. They're resolved from the container,
so they get dependency injection and a fresh instance per request.
import type { Ctx } from "@shaferllc/keel/core";
import { json, param } from "@shaferllc/keel/core";
export class PostController {
index() {
return json({ posts: [] });
}
show() {
return json({ id: param("id") });
}
}
Bind actions in your routes with a [Controller, method] tuple:
router.get("/posts", [PostController, "index"]);
router.get("/posts/:id", [PostController, "show"]);
A controller action is just one of the three handler forms
the router accepts — the array form. The other two (a closure, or a ready-made
Response) live inline on the route; controllers are for anything with enough
weight to earn its own class.
The request context
Every action is called with the request context as its
first argument — the Ctx type, which is Hono's Context. You can read from it
directly, or ignore it and reach for the ambient request helpers (param,
query, body) that resolve the current request from async-context storage.
Both styles work; pick whichever reads better.
import type { Ctx } from "@shaferllc/keel/core";
import { param } from "@shaferllc/keel/core";
export class PostController {
// Take the context explicitly…
show(c: Ctx) {
return c.json({ id: c.req.param("id") });
}
// …or lean on the ambient helpers and drop the argument.
edit() {
return json({ id: param("id") });
}
}
The context is what Keel hands the action under the hood — the router resolves
your controller from the container, then calls
action.call(controller, c), so this is the controller instance and the sole
argument is the Ctx.
Dependency injection
A controller's constructor receives the container, so it can resolve anything:
import type { Container, Ctx } from "@shaferllc/keel/core";
import { Mailer } from "../Services/Mailer.js";
export class UserController {
constructor(private app: Container) {}
store() {
const mailer = this.app.make(Mailer);
// …
}
}
The container instantiates the controller with new Controller(container) — an
unbound class is auto-built, no registration needed. That happens per
request: each hit resolves a fresh instance, so it's safe to stash
request-scoped state on this without leaking it across requests.
If you'd rather inject specific services than the whole container, give the controller a constructor that takes them and bind it in a service provider, pulling each dependency out of the container:
// InvoiceController's constructor takes a Mailer, not the container.
app.bind(InvoiceController, (c) => new InvoiceController(c.make(Mailer)));
Single-action controllers
For a controller that does one thing, define a handle method and reference the
class with no method name:
export class PublishPost {
handle() {
return json({ published: true });
}
}
router.post("/posts/:id/publish", [PublishPost]); // calls handle()
[Controller] and [Controller, "handle"] are equivalent — the method name
defaults to "handle" when the tuple has just one element. Referencing a method
the controller doesn't define throws at request time:
Controller [PublishPost] has no method [handle].
Lazy-loaded controllers
Pass a () => import(...) loader instead of the class, and the controller is
only imported when its route is first hit — handy for large apps and cold
starts:
router.get("/reports", [() => import("../Controllers/ReportController.js"), "index"]);
The loader may resolve to a default export or the class itself — Keel unwraps
.default if present, otherwise uses the module value directly. Both work:
// default export
export default class ReportController { index() { /* … */ } }
// named export — point the loader at the property
router.get("/reports", [
() => import("../Controllers/ReportController.js").then((m) => m.ReportController),
"index",
]);
The loader must be an arrow function (or any function with no prototype).
Keel distinguishes an eager controller from a lazy loader by checking for a
prototype — classes have one, arrow functions don't — so a lazy controller
written as a function declaration would be mistaken for a class. Stick to
() => import(...).
Resource controllers
Generate a RESTful controller with all seven actions:
npm run keel make:controller Post --resource
That writes app/Controllers/PostController.ts with the conventional set —
index, create, store, show, edit, update, destroy:
import type { Ctx } from "@shaferllc/keel/core";
export class PostController {
index(c: Ctx) { return c.json({ action: "index" }); }
create(c: Ctx) { return c.json({ action: "create" }); }
store(c: Ctx) { return c.json({ action: "store" }); }
show(c: Ctx) { return c.json({ action: "show" }); }
edit(c: Ctx) { return c.json({ action: "edit" }); }
update(c: Ctx) { return c.json({ action: "update" }); }
destroy(c: Ctx) { return c.json({ action: "destroy" }); }
}
Drop --resource (or -r) for a bare controller with a single index action.
Then wire the whole set up in one line (see Routing → Resource routes):
router.resource("posts", PostController);
router.resource("posts.comments", CommentController); // nested
router.resource("posts", PostController)
.apiOnly()
.as("articles")
.params({ posts: "post" })
.use(["store", "update", "destroy"], auth);
router.resource maps each of the seven route entries onto the matching
controller method by name — so a resource controller just needs methods with
those names. Trim the set with .only() / .except() / .apiOnly() when you
don't implement all seven.
Related
- Routing — the
Routermethods (get,post,resource, …) that bind these controllers, plus closures and static-response handlers. - Container — how controllers (and their dependencies) are resolved and constructed.
- Request & response — the
Ctxobject and the ambientparam/query/jsonhelpers actions use.
API reference
Controllers are a usage pattern, not an exported API — you write the classes, and
the Router binds them. The two exported types you touch when
typing an action or a route are Ctx and RouteHandler, both from
@shaferllc/keel/core.
Types
Ctx
type Ctx = Context (Hono's request Context)
The request context passed as the first argument to every route handler and controller action. Read params/headers/body off it and build responses with it — or ignore it and use the ambient request helpers.
import type { Ctx } from "@shaferllc/keel/core";
export class UserController {
show(c: Ctx) {
const id = c.req.param("id"); // route param
const q = c.req.query("expand"); // query string
return c.json({ id, expand: q }); // JSON response
}
}
Notes: it's an alias for Hono's Context, so anything in Hono's context API
(c.req, c.json, c.html, c.header, c.get/c.set, c.env) is available.
An action may also take no argument and use param()/query()/json() instead,
which resolve the current request from async-context storage — see
request & response.
RouteHandler
type RouteHandler = HandlerFn | ControllerAction | Response
The union every Router verb accepts as its handler. A controller action is the
ControllerAction arm: [Controller], [Controller, "method"], or a lazy
[() => import(...), "method"].
import type { RouteHandler } from "@shaferllc/keel/core";
import { json } from "@shaferllc/keel/core";
// each arm of the union is a valid handler
const closure: RouteHandler = () => json({ ok: true });
const staticResp: RouteHandler = json({ status: "ok" });
const action: RouteHandler = [UserController, "show"];
const single: RouteHandler = [PublishPost]; // calls handle()
const lazy: RouteHandler = [
() => import("../Controllers/ReportController.js"),
"index",
];
Notes: the constituent types (HandlerFn, ControllerAction,
ControllerRef, LazyController) are internal to the router and not exported —
annotate values as RouteHandler when you need an explicit type. The router
turns any of these into an executable function at boot; a controller arm is
resolved from the container per request. For the verbs that consume a
RouteHandler (get, post, put, patch, delete, any, route,
resource, …), see Routing → API reference.