Skip to content

Dependency Injection (DI)

Zebra's DI is the core of the framework, not an optional feature. Every app is built around a Container; routes and middleware declare their dependencies, and the container validates the whole graph at boot.

Declaring injectable classes

Mark a class with @injectable(). Constructor dependencies are inferred automatically via emitDecoratorMetadata, or declared explicitly with @inject():

ts
import { inject, injectable } from "@zebra-web/zebra";

@injectable()
class UserRepo {
  // inferred: constructor param types come from design:paramtypes
  constructor(private db: Database) {}
}

@injectable()
class AuthService {
  // explicit: useful for abstract classes / tokens / interfaces
  constructor(@inject(UserRepo) private repo: UserRepo) {}
}

Registering bindings

Register on the Zebra instance:

ts
const z = new Zebra();

z.injectSingleton(UserRepo);        // class → itself (.toSelf()), singleton scope
z.injectSingleton(IRepo, MockRepo); // abstract identifier → concrete impl
z.injectRequest(Service);           // new instance per request
z.injectTransient(Service);         // new instance per resolution
z.injectSession(Service);           // one instance per session
z.injectValue(TOKEN, value);        // bind an existing value (singleton)

// Factory bindings: lazy form (receives the container) and declared-deps form
z.injectFactorySingleton(TOKEN, (c) => new Service(c.resolve(Dep)));
z.injectFactorySingleton(TOKEN, { dep: Dep }, ({ dep }) => new Service(dep));

injectFactory* also has Request / Transient / Session variants.

Identifiers

An id in a route or binding can be:

KindDescription
ClassConstructor<T>a concrete class (Greeter)
AbstractConstructor<T>an abstract class (IRepo)
Token<T>a semantic token: const DB = token<Database>("DB")
ts
import { token } from "@zebra-web/zebra";

export const DB = token<Database>("db");

z.injectValue(DB, new Database());

z.get("/users", { db: DB }, async (req, { db }) => db.query(...));

Tokens bound to factories or values are the standard way to decouple "interface + implementation" (especially for identifiers shared across packages). isToken() is the runtime guard.

The four scopes

ScopeLifetimeCache location
Singletonone instance for the whole approot container
Sessionone instance per session id, reclaimed after idle TTLsession child container
Requestone instance per request, disposed at request endrequest child container
Transienta fresh instance per resolutionno cache

Dependencies have scope constraints: a singleton cannot depend on request/session-scoped dependencies (it outlives them). canDependOn(consumer, dependency) encodes the rule: a dependency's rank must be ≤ the consumer's rank (singleton(0) < session(1) < request(2) < transient(3)); transient has no cache, so any scope may safely depend on it. Violations are caught at boot by validateGraph (ScopeMismatchError).

Session scope

Session scope needs a session resolver configured on Zebra (how to get a session id from a request):

ts
const z = new Zebra({
  session: {
    resolver: (req) => extractSessionId(req), // string | undefined
    ttl: 30 * 60 * 1000,                       // idle TTL
  },
});
  • A resolved id opens a "session child container" in which all Session-scoped deps are cached.
  • Idle sessions past ttl are reclaimed automatically (disposed); app.disposeSession(id) reclaims one immediately.
  • When the resolver returns undefined, the request uses an ephemeral session container (released at request end) — anonymous access still has session semantics, but nothing persists across requests.

When used with @zebra-web/session, the sessionMiddleware() result exposes a resolver — wire it into the Zebra options to make cookie sessions and session-scoped DI work together. See Sessions.

Named-object route DI

Routes and middleware declare deps as a named object; the handler's second argument receives exactly that:

ts
z.get("/hi/:name", { g: Greeter, db: DB }, async (req, { g, db }) => {
  // g: Greeter, db: Database — one-to-one with the declaration
});

Bring your own Container (advanced)

ts
import { Container } from "@zebra-web/zebra";

const container = new Container();
container.bind(IRepo).to(MockRepo);
container.bind(DB).toFactory((c) => new Database(c.resolve(Config)));
container.bind(TOKEN).toValue(instance);

const z = new Zebra({ container });

BindingBuilder chainable methods:

MethodEffect
.to(cls)bind to an implementation class
.toSelf()bind to the identifier itself (the shorthand behind injectSingleton(Cls))
.toFactory(fn)lazy factory, receives the container
.toFactoryWithDeps(deps, fn)declared-deps factory, receives resolved deps
.toValue(v)bind an existing value
.inSingletonScope() / .inSessionScope() / .inRequestScope() / .inTransientScope()set the scope

The container also supports:

  • rebind(id) — unbind then rebind (test doubles).
  • snapshot() / restore() — save/restore bindings and instances (test isolation).
  • createChildScope(kind) — create a child container manually.
  • resolve(id) — manual resolution (non-route paths, e.g. ws message handling).

Boot-time graph validation

At listen() (performPrepare), validateGraph checks:

  1. every dep declared by routes / middleware is bound → else UnboundTokenError (with the resolution chain).
  2. constructor deps are acyclic → CircularDependencyError (lists the cycle).
  3. scope constraints hold → ScopeMismatchError.

Then the container freeze()s: any registration (routes, bindings, middleware, hooks) throws after listen().

Disposal

Instances implementing Disposable (dispose(): Promise<void>) are disposed automatically at:

  • request end (request scope);
  • session reclamation / disposeSession(id) (session scope);
  • app.stop() (singleton scope).

Disposal is LIFO (dependencies before dependents). A single failed disposal does not stop the rest (errors are aggregated and rethrown).

Errors at a glance

ErrorTrigger
UnboundTokenErrorresolving an unbound identifier (with the resolution chain)
CircularDependencyErrorconstructor deps form a cycle
ScopeMismatchErrorscope violation (e.g. singleton depending on a lower scope)
RangeErrorinvalid session.ttl / gracePeriod / requestTimeout config

Next steps

Built with VitePress · MIT Licensed