Keelv0.86.0
Docs / The Service Container

The container is the backbone of Keel. Every service — config, the router, controllers, and anything you write — is registered in it and resolved out of it. It is the single registry every service resolves out of.

Why a container?

Instead of importing concrete classes everywhere and newing them by hand, you bind how a service is built once, then resolve it wherever you need it. That gives you a single place to swap implementations (real vs. fake in tests), share singletons, and inject dependencies.

Binding

Bindings live in a service provider's register() method (see Providers). A binding is keyed by a token — a string, symbol, or class — and a factory that receives the container.

import { ServiceProvider } from "@shaferllc/keel/core";

export class AppServiceProvider extends ServiceProvider {
  register(): void {
    // Transient: a fresh value every time it is resolved.
    this.app.bind("clock", () => new Date());

    // Singleton: built once, then cached.
    this.app.singleton(Mailer, (app) => new Mailer(app.make("config")));

    // Instance: register an already-constructed value.
    this.app.instance("version", "0.1.0");
  }
}

The short way: global helpers

You don't need this.app at all. The same operations exist as global helpers that resolve against the active application — bind and resolve from anywhere:

import { bind, singleton, instance, make, bound } from "@shaferllc/keel/core";

bind("clock", () => new Date());              // transient
singleton(Mailer, (app) => new Mailer(app));   // shared
instance("version", "0.6.0");                  // pre-built value

const mailer = make(Mailer);
const version = make<string>("version");
if (bound("clock")) { /* … */ }

Both styles work everywhere; the helpers are just less to type.

Aliases

Point a second token at an existing binding with alias(name, target). The target owns the binding (and its singleton, if any); the alias just resolves through to it:

import { alias, make } from "@shaferllc/keel/core";

alias("router", Router);        // make("router") === make(Router)

Swapping bindings in tests

swap(token, factory) temporarily replaces a binding with a fake, and restore(token) puts the original back — the container remembers the real one (binding and any resolved instance) so your test doesn't have to. restore() with no argument undoes every swap, which makes a tidy afterEach:

import { swap, restore } from "@shaferllc/keel/core";

swap(Mailer, () => fakeMailer);   // resolved once, shared for the test
// … exercise code that make()s Mailer …
restore(Mailer);                  // or restore() to undo all swaps

This is Keel's answer to mocking a service without reaching into module internals: the code under test resolves Mailer the same way it always does, and gets the fake. (For request-scoped fakes, prefer plain dependency injection — pass the collaborator in — so there's no global to restore.)

Resolving

Use make() (or this.app.make()) to pull something out:

const mailer = make(Mailer);
const version = make<string>("version");

If a token is bound, its factory runs (once, for singletons). If you pass an unbound class, the container auto-constructs it, passing itself to the constructor:

class ReportService {
  constructor(private app: Container) {}
}

const report = app.make(ReportService); // works with no explicit binding

Auto-resolution is transient: an unbound class is rebuilt every make() — it is never cached, because nothing marked it shared. Bind it with singleton if you want one instance.

When nothing is bound

make() only auto-constructs class tokens. A string or symbol that was never bound has nothing to build, so it throws:

make("nope"); // Error: Nothing bound in the container for [nope].

Guard with bound() when a token might be missing:

const clock = bound("clock") ? make<Date>("clock") : new Date();

Dependency injection in controllers

Controllers are resolved through the container, so their constructor receives it. Pull whatever you need:

import type { Ctx } from "@shaferllc/keel/core";
import { Application, type Container } from "@shaferllc/keel/core";

export class InvoiceController {
  constructor(private app: Container) {}

  index(c: Ctx) {
    const config = this.app.make(Application).config();
    return c.json({ currency: config.get("app.currency", "USD") });
  }
}

The API

Method Purpose
bind(token, factory) Transient binding — fresh value each resolve
singleton(token, factory) Shared binding — resolved once, then cached
instance(token, value) Register a pre-built value as a shared instance
make(token) / get(token) Resolve a token
bound(token) Whether a token is bound or cached
build(ctor) Instantiate a class, passing it the container

Tokens

  • Strings/symbols — good for values and interfaces: "config", "clock".
  • Classes — good for services; the class doubles as its own token and can be auto-resolved when unbound.

Under the hood

The whole container is about 90 lines in src/core/container.ts. Two maps — one for bindings, one for cached instances — and a make() that checks the cache, runs the factory, and caches shared results. Read it; there's no magic.

Bindings usually live in a service provider's register() method; the global helpers resolve against the active Application, which registers itself on construction.


API reference

Container

The registry itself. You rarely construct it — the Application is a Container, and you reach it as this.app inside providers/controllers or via the global helpers below. Registration methods (bind, singleton) return this and so chain; resolution methods return the value.

bind(token, factory)

bind<T>(token: Token<T>, factory: Factory<T>): this

Registers a transient binding — the factory runs on every resolve, yielding a fresh value each time.

app.bind("clock", () => new Date());
app.bind(Mailer, (c) => new Mailer(c.make("config")));

Notes: returns this, so calls chain. Re-binding the same token overwrites the prior binding. The factory receives the container, so it can resolve its own dependencies.

singleton(token, factory)

singleton<T>(token: Token<T>, factory: Factory<T>): this

Registers a shared binding — the factory runs at most once; the result is cached and returned on every later resolve.

app.singleton(Mailer, (c) => new Mailer(c.make("config")));

Notes: returns this. The factory is lazy — it doesn't run until the first make(). The cached value lives in the instance map, so a later bound() is true even before first resolve (the binding is registered immediately).

instance(token, value)

instance<T>(token: Token<T>, value: T): T

Registers an already-constructed value as a shared instance, skipping any factory.

const version = app.instance("version", "0.30.0"); // returns "0.30.0"

Notes: returns the value you passed (not this), so it reads well inline. Overrides any cached instance for the token. Because it writes the instance map, make() returns it directly without ever consulting a binding.

make(token)

make<T>(token: Token<T>): T

Resolves a token: returns a cached instance if present, else runs its binding (caching the result for singletons), else auto-constructs an unbound class.

const mailer = app.make(Mailer);
const version = app.make<string>("version");

Notes: resolution order is instance cache → binding → class auto-build. An unbound class token is built via build() and not cached. An unbound string/symbol token throws Nothing bound in the container for [token].

get(token)

get<T>(token: Token<T>): T

Alias for make() — identical behavior, sugar for an app(token)-style read.

const mailer = app.get(Mailer);

Notes: delegates straight to make(); use whichever name reads better.

build(ctor)

build<T>(ctor: Constructor<T>): T

Instantiates a class directly, passing the container to its constructor. Bypasses bindings and the instance cache entirely.

const report = app.build(ReportService); // new ReportService(app)

Notes: always constructs a new instance (never cached), and ignores any binding registered for the class. make() calls this under the hood when it auto-resolves an unbound class token.

bound(token)

bound(token: Token): boolean

Reports whether the token has a binding or a cached instance.

if (app.bound("clock")) app.make("clock");

Notes: true for singletons even before first resolve (the binding exists).

alias(alias, target)

alias<T>(alias: Token<T>, target: Token<T>): this

Registers a token that resolves through to another token.

app.singleton(Router, () => new Router(app));
app.alias("router", Router);
app.make("router") === app.make(Router); // true — same singleton

Notes: returns this (chainable). The alias is a thin transient wrapper that calls make(target), so the target keeps ownership of its own sharing — aliasing a singleton still yields the one shared instance.

swap(token, factory)

swap<T>(token: Token<T>, factory: Factory<T>): this

Temporarily replaces a binding with a fake, for tests. The replacement is shared (resolved once), and the original binding and any resolved instance are remembered for restore().

app.swap(Mailer, () => fakeMailer);
app.make(Mailer); // fakeMailer

Notes: returns this. Idempotent per token — the first swap saves the original, later swaps just change the fake. Clears the cached instance so the next make() goes through the fake.

restore(token?)

restore(token?: Token): this

Undoes a swap(), restoring the original binding (and instance). Called with no token, it restores every swap — handy in an afterEach.

app.restore(Mailer); // one token
app.restore();       // all swaps

Notes: returns this. A no-op for a token that wasn't swapped. If the token had no binding before the swap, restore removes it, leaving bound() false again. Does not consider auto-resolvable classes — an unbound class is bound() === false yet still make()-able.

Global helpers

Free functions in src/core/helpers.ts that proxy to the active application's container, so you can bind and resolve from anywhere without threading this.app through. Each calls app() internally, which throws if no Application has been bootstrapped.

bind(token, factory)

bind<T>(token: Token<T>, factory: Factory<T>): void

Transient binding on the active application.

import { bind } from "@shaferllc/keel/core";
bind("clock", () => new Date());

Notes: returns void — unlike Container.bind, it does not return the container, so these helpers don't chain.

singleton(token, factory)

singleton<T>(token: Token<T>, factory: Factory<T>): void

Shared binding on the active application.

import { singleton } from "@shaferllc/keel/core";
singleton(Mailer, (app) => new Mailer(app));

Notes: returns void. Same lazy, resolve-once semantics as Container.singleton.

instance(token, value)

instance<T>(token: Token<T>, value: T): T

Registers a pre-built value on the active application; returns the value.

import { instance } from "@shaferllc/keel/core";
const version = instance("version", "0.30.0");

Notes: the one container helper that returns its value (mirrors Container.instance), so it composes inline.

make(token)

make<T>(token: Token<T>): T

Resolves a token out of the active application's container.

import { make } from "@shaferllc/keel/core";
const mailer = make(Mailer);
const version = make<string>("version");

Notes: same resolution rules and throw-on-missing behavior as Container.make.

bound(token)

bound(token: Token): boolean

Whether the token is bound or cached on the active application.

import { bound } from "@shaferllc/keel/core";
if (bound("clock")) { /* … */ }

Notes: proxies Container.bound.

alias(alias, target) · swap(token, factory) · restore(token?)

Global-helper forms of the container methods, resolving against the active application.

import { alias, swap, restore } from "@shaferllc/keel/core";

alias("router", Router);
swap(Mailer, () => fakeMailer);
restore(); // undo every swap

Notes: alias/swap proxy Container.alias/Container.swap; restore proxies Container.restore (no token restores all). swap/restore are for tests — see Swapping bindings in tests.

Interfaces & types

Token

type Token<T = unknown> = string | symbol | Constructor<T>

What every binding is keyed by. Use a string/symbol for values and interfaces, or a class — which doubles as its own token and can be auto-resolved when unbound.

const nameKey: Token<string> = "app.name";
const svcKey: Token<Mailer> = Mailer; // the class is the token

Constructor

type Constructor<T = unknown> = new (...args: any[]) => T

Any newable class. A class token is a Constructor; build() and auto-resolution call new ctor(container) on it, so the constructor's first parameter receives the container.

const ctor: Constructor<ReportService> = ReportService;

Factory

type Factory<T> = (app: Container) => T

The builder function you hand to bind/singleton. It receives the container, so it can resolve dependencies while constructing the value.

const mailerFactory: Factory<Mailer> = (app) => new Mailer(app.make("config"));