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():
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:
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:
| Kind | Description |
|---|---|
ClassConstructor<T> | a concrete class (Greeter) |
AbstractConstructor<T> | an abstract class (IRepo) |
Token<T> | a semantic token: const DB = token<Database>("DB") |
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
| Scope | Lifetime | Cache location |
|---|---|---|
Singleton | one instance for the whole app | root container |
Session | one instance per session id, reclaimed after idle TTL | session child container |
Request | one instance per request, disposed at request end | request child container |
Transient | a fresh instance per resolution | no 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):
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
ttlare 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, thesessionMiddleware()result exposes aresolver— wire it into theZebraoptions 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:
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)
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:
| Method | Effect |
|---|---|
.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:
- every dep declared by routes / middleware is bound → else
UnboundTokenError(with the resolution chain). - constructor deps are acyclic →
CircularDependencyError(lists the cycle). - 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
| Error | Trigger |
|---|---|
UnboundTokenError | resolving an unbound identifier (with the resolution chain) |
CircularDependencyError | constructor deps form a cycle |
ScopeMismatchError | scope violation (e.g. singleton depending on a lower scope) |
RangeError | invalid session.ttl / gracePeriod / requestTimeout config |