Keelv0.86.0
Docs / Architecture

Keel is small on purpose. This page maps the pieces and traces a request from socket to response. Nothing here is magic — every layer is a short, readable file in src/core/, and this guide is mostly a reading order for it.

The layers

┌─────────────────────────────────────────────────────────┐
│  bin/keel.ts            console entry (serve, make:*, …)  │
├─────────────────────────────────────────────────────────┤
│  bootstrap/app.ts       createApplication()              │
│    └─ boots providers, binds HTTP kernel, loads routes   │
├─────────────────────────────────────────────────────────┤
│  Application  (extends Container)                        │
│    ├─ loads .env + config/*.ts                           │
│    └─ provider register() → boot() lifecycle             │
├─────────────────────────────────────────────────────────┤
│  Container    bind / singleton / instance / make        │
├─────────────────────────────────────────────────────────┤
│  HttpKernel   global middleware → compiles routes → Hono │
├─────────────────────────────────────────────────────────┤
│  @hono/node-server   the actual HTTP server              │
└─────────────────────────────────────────────────────────┘

Read it top to bottom as the flow of control at boot, and bottom to top as the flow of a request. The console and the server both enter through createApplication() — the difference is only what they do with the app once it's booted (serve it, or run a command against it).

Core building blocks

  • Container (container.ts) — the dependency registry. Two maps (bindings, cached instances) and a make() resolver.
  • Application (application.ts) — a Container with a lifecycle: load env, auto-load config, register and boot providers.
  • Config (config.ts) — a dot-notation repository plus the env() coercion helper.
  • ServiceProvider (provider.ts) — the register() / boot() contract.
  • Router (http/router.ts) — collects route definitions; resolves controller tuples out of the container.
  • HttpKernel (http/kernel.ts) — holds global middleware and compiles the router onto a Hono instance.

The container is the center

Everything else hangs off the container. Application is a Container — it extends it — so the same bind / singleton / instance / make surface that registers a service also holds Config, Router, View, the Logger, and your own controllers.

app.singleton(Router, (a) => new Router(a));   // registered at construction
const router = app.make(Router);               // resolved anywhere later

Two properties make this the spine of the framework:

  • Everything resolves through one place. A controller doesn't new its dependencies — it receives the container in its constructor and pulls what it needs, so tests can swap any binding for a fake without touching the code under test.
  • Classes auto-resolve. make(SomeClass) builds SomeClass even with no explicit binding, handing its constructor the container. You only register a binding when construction needs configuration or should be shared.

bind gives a fresh value each resolve; singleton caches after the first; instance stores an already-built value. The global helpers (make(), bind(), config(), app()) are thin wrappers that resolve against the active application, so you rarely thread the container by hand. See The Service Container for the full API and resolution rules.

Service providers wire it up

Providers are the seams where your services enter the container. Each has two phases, and the split matters:

export class AppServiceProvider extends ServiceProvider {
  register(): void {
    // Bind only. Nothing else is guaranteed registered yet.
    bind("clock", () => new Date().toISOString());
  }

  boot(): void {
    // Every provider has registered — safe to resolve and wire things.
  }
}

Application.boot() runs all providers' register() before any provider's boot(). That ordering is the whole point: register() may only add bindings, so boot() can safely depend on anything any provider bound, regardless of order. Reaching for another service inside register() is the classic bug — the binding may not exist yet. Service Providers goes deeper.

Boot sequence

When keel serve runs:

  1. createApplication() constructs the Application with the project root.
  2. The constructor registers the active application (for global helpers) and binds core services (Config, Router, View, Events, Cache, Logger).
  3. app.boot(providers):
    • loads .env, then every config/*.ts file into the Config repository,
    • runs each provider's register() (bind-only phase),
    • runs each provider's boot() (wire-up phase).
  4. The HTTP kernel (app/Http/Kernel.ts) is bound as a singleton.
  5. routes/web.ts registers routes on the Router.
  6. HttpKernel.build() returns a Hono app; @hono/node-server serves it.

Steps 1–3 are identical whether you're serving or running a console command — createApplication() is the single door in. Only steps 4–6 are HTTP-specific.

The application object

Application is Keel's central object — the container plus a lifecycle. Beyond register()/boot(), it carries a small ergonomic surface modelled on the classic service-app pattern (Feathers' app): a settings store, an inline plugin hook, and app-level events.

Configure with a plain function. register(Provider) gives you the two-phase register/boot lifecycle; configure(fn) is the one-shot alternative for inline setup — call a function with the app, chain the next:

app
  .configure((a) => a.set("mail.from", "hi@keel.dev"))
  .configure(installBilling); // (app) => { … }

Store app-wide values. set/get are a thin façade over the Config repository, so app.set("db.url", …) and config().get("db.url") read the same store — no second bag to keep in sync:

app.set("db.url", process.env.DATABASE_URL);
const url = app.get<string>("db.url");
const port = app.get("port", 3000); // typed fallback

Emit and listen at the app level. on/once/off/emit delegate to the Events singleton, so app.on(...) and the global listen() helper share one emitter. Listeners may be async; emit awaits them in order:

const off = app.on("user.registered", (user) => sendWelcome(user));
await app.emit("user.registered", user);
off(); // unsubscribe

API reference

app.configure(fn)

Run a Configurator(app) => unknown — against the app and return the app for chaining. The lightweight alternative to a ServiceProvider when you don't need the register/boot split.

app.configure((a) => a.router().get("/health", () => "ok"));

Notes: runs immediately and synchronously in call order. Its return value is ignored (return-for-chaining is the app, not the fn's result). For anything that must bind before another service boots, use a provider instead.

app.set(key, value) / app.get(key, fallback?)

Write and read an app-wide value. Both use dot-notation and are backed by Config, so values set here are visible to config() and vice-versa. set returns the app (chainable); get takes an optional typed fallback.

app.set("app.name", "Keel");
config().get("app.name"); // "Keel"
app.get("app.name"); // "Keel"
app.get("app.locale", "en"); // fallback when unset

Notes: because the store is shared with Config, prefer namespaced keys ("mail.from", not "from") to avoid collisions with config/*.ts files.

app.on(event, listener) / app.once(event, listener)

Subscribe to an app event; once auto-unsubscribes after the first emission. Both return an unsubscribe function. Delegates to the Events singleton.

const off = app.on<Order>("order.paid", (o) => fulfil(o));

Notes: the listener signature is (payload) => void | Promise<void>. Identical to app.make(Events).on(...) — the method is sugar so you rarely resolve Events by hand.

app.off(event, listener)

Remove a listener registered with on/once. Returns the app (chainable). Pass the same function reference used to subscribe.

app.emit(event, payload?)

Emit an app event, awaiting every listener in registration order. Returns a Promise<void>. An async listener that rejects propagates out of emit.

await app.emit("cache.cleared", { at: Date.now() });

Request lifecycle

For each incoming request:

request
  → Hono matches the route
  → contextStorage() stashes the context for the request helpers
  → context middleware sets c.get("app") = the container
  → global middleware stack (e.g. requestLogger) runs, in order
  → the route handler runs:
        • a closure           → called with (c)
        • a [Controller, m]   → controller resolved from the container,
                                 then method(c) is called (DI in the ctor)
  → the handler's return value becomes the response
        (a string is wrapped as HTML; a Response passes through)
  → middleware unwinds on the way back out
response

A few details worth knowing:

  • The context is stashed per request. Before your middleware runs, the kernel enables Hono's contextStorage(). That's what lets the request helpers (request, param(), json()) reach the current request without you passing c around.
  • Controllers are resolved lazily, per request. The router turns a [Controller, method] tuple into a function that resolves the controller from the container when the route fires — so constructor DI runs against the live app. Tuples may also be () => import(...) loaders for code-splitting.
  • Errors funnel through the kernel. A thrown HttpException renders at its status; anything else is a 500. The kernel content-negotiates — HTML for browsers, JSON otherwise — and hides internals unless app.debug is on. Unmatched routes go through the same path as a NotFoundException.

Middleware covers the stack in detail, and Routing covers how handlers are declared and matched.

Edge-safe by design

Keel's core imports no Node built-ins at module load. fs, path, url, and dotenv are pulled in dynamically, and only when filesystem discovery is enabled:

// application.ts — dynamic, guarded, optional
const { readdir } = await import("node:fs/promises");

The payoff is that the same Application, Router, View, and query builder run unchanged on Cloudflare Workers, Deno, and Bun — anywhere with web-standard fetch, Request, Response, and Web Crypto. On Workers, where there's no filesystem to scan, you skip discovery and pass config inline:

await app.boot(providers, { discoverConfig: false, config: { app: { name: "Keel" } } });

The second argument is BootOptions: discoverConfig (skip filesystem config discovery — the default on the edge) and config (an inline config object merged in). Together they let the app boot with no filesystem at all.

Everything that would normally reach for a platform API is designed around this seam: the database layer talks to a Connection you provide rather than importing a driver; signed URLs use Web Crypto's crypto.subtle; views render to strings with no filesystem. The rule is simple — the core owns logic, the platform owns I/O, and the two meet at an interface you supply.

Two repos: library and starter

Keel is distributed like most frameworks — a library you install plus an app that depends on it:

Repo Role
shaferllc/keel The framework. Published as @shaferllc/keel; userland imports @shaferllc/keel/core.
shaferllc/keel-app The starter app — clone it to build something. Picks up core updates via npm update.

The split mirrors the classic application-vs-library separation. Your code lives in app/ (controllers, providers, middleware); the framework lives behind the package boundary. Because the two are versioned separately, a framework upgrade is an ordinary dependency bump — your app/ doesn't move. Getting Started walks through both install paths.

Design principles

  • One container, resolved everywhere. Testability and composition follow from routing every dependency through it.
  • Convention over configuration. Fixed folders (app/, config/, routes/, bootstrap/) mean no manual wiring for the common case.
  • Thin over clever. The framework is a few hundred readable lines. When in doubt, open the source — there is no hidden magic.
  • Wrap the best, own the surface. Hono does HTTP; Keel owns the developer- facing API so the underlying library can change without breaking your app. Built on Hono draws that line precisely.
  • Edge-safe, driver-agnostic. The core imports no database driver, no mail SDK, no socket. Every backend — database, mail, redis, queues, storage, broadcasting — plugs in behind a small interface, so the same app runs on Node and on the edge. You bring the driver; Keel brings the ergonomics.
  • Explicit over implicit. No hidden runtime magic: body parsing is a method you call, templates interpret rather than eval, and providers register into one predictable global scope. Boring and predictable beats clever and surprising.

Extending Keel

The MVP core is deliberately small. Natural extension points:

  • New services → a service provider that binds them into the container.
  • New console commands → add to cli/index.ts.
  • New subsystems (ORM, queues, mail) → a provider that registers the subsystem plus a config/*.ts file for its settings. This is exactly how the roadmap items will land.