The ZudoJS core Core
Dependency injection with @zudojs/container
Let a container build and share your services. Learn tokens, class, factory and value providers, the singleton, scoped and transient lifetimes, circular dependency detection, disposal, and swapping real services for fakes in tests.
The problem: who builds what?
A service rarely works alone. The Task API's TaskService needs somewhere to keep tasks and a way to know the time. The quick way is to create those inside the service:
class TaskStore {
readonly tasks = new Map<number, string>();
}
class TaskService {
private readonly store = new TaskStore();
create(title: string): string {
const stamp = new Date().toISOString().slice(0, 10);
this.store.tasks.set(this.store.tasks.size + 1, title);
return `${title} (created ${stamp})`;
}
}
console.log(new TaskService().create("Buy milk").includes("created"));
npx tsx hard-wired.ts and of the browser terminaltrue
It works, but look at what is now impossible:
- Two services cannot share one store: each
new TaskService()makes its own. - A test cannot give the service an empty store, a fake database, or a fixed date. The output changes every day, so the test can only check
includes("created").
The fix is called dependency injection: a class does not create what it needs, it receives it, usually as constructor parameters. The things it receives are its dependencies:
interface Clock {
now(): Date;
}
class TaskStore {
readonly tasks = new Map<number, string>();
}
class TaskService {
constructor(private readonly store: TaskStore, private readonly clock: Clock) {}
create(title: string): string {
this.store.tasks.set(this.store.tasks.size + 1, title);
return `${title} (created ${this.clock.now().toISOString().slice(0, 10)})`;
}
}
const fixedClock: Clock = { now: () => new Date("2026-09-23T09:00:00Z") };
const store = new TaskStore();
const service = new TaskService(store, fixedClock);
console.log(service.create("Buy milk"));
console.log("tasks in the shared store:", store.tasks.size);
npx tsx injected.ts and of the browser terminalBuy milk (created 2026-09-23) tasks in the shared store: 1
Now the caller decides. The test passed a clock that always says 23 September, so the output is predictable, and it can look inside the same store the service used.
Someone still has to call all those constructors in the right order. In a small app you do it by hand in one place, called the composition root. With dozens of services, a container does it for you: you tell it how to build each thing once, and it builds, shares and cleans up. @zudojs/container is already a dependency of the Task API; src/app.ts creates one with createContainer() and gives it to the runtime.
Tokens: names for dependencies
A container stores registrations: "when someone asks for X, build it like this". The X is a token, a key that names the dependency. A token can be a class, or an InjectionToken made with createToken:
import { createContainer, createToken } from "@zudojs/container";
interface Clock {
now(): Date;
}
const CLOCK = createToken<Clock>("Clock");
const MAX_TASKS = createToken<number>("MaxTasks");
const container = createContainer();
container.registerValue(CLOCK, { now: () => new Date("2026-09-23T09:00:00Z") });
container.registerValue(MAX_TASKS, 500);
const clock = container.resolve(CLOCK);
console.log(clock.now().toISOString(), container.resolve(MAX_TASKS));
console.log(container.has(CLOCK), container.has(createToken<Clock>("Clock")));
npx tsx tokens.ts and of the browser terminal2026-09-23T09:00:00.000Z 500 true false
resolve asks the container for a dependency. Three things to notice:
- The
<Clock>increateToken<Clock>makesresolve(CLOCK)return aClock, so TypeScript checks how you use it. - An interface such as
Clockdoes not exist when the program runs, so it cannot be a key. That is why interfaces need a token. A class does exist at runtime, so a class can be its own token. - Every
createTokencall makes a new, unique token, even with the same name. The last line isfalse: create each token once and import it wherever it is needed.
TIP
You can also use plain strings such as"clock" as tokens, but TypeScript cannot check them: resolve<number>("clock") would compile and be wrong. Prefer createToken or a class.Providers: how to build each dependency
A provider tells the container how to produce a value. There are four kinds, each with a short method:
| Method | Provider | What the container does |
|---|---|---|
registerValue(token, value) | value | Hands out the object you gave it. |
registerClass(token, Class, { inject }) | class | Calls new Class(...) with the resolved inject tokens as arguments. |
registerFactory(token, fn, inject) | factory | Calls your function and uses what it returns. |
registerExisting(token, otherToken) | alias | Answers with whatever otherToken resolves to. |
Here is the Task API's TaskService built by the container. The service lists its dependencies in its constructor. The registration lists the matching tokens in inject, in the same order:
import { createContainer, createToken } from "@zudojs/container";
interface Clock {
now(): Date;
}
class TaskStore {
readonly titles: string[] = [];
}
class TaskService {
constructor(private readonly store: TaskStore, private readonly clock: Clock) {}
create(title: string): string {
this.store.titles.push(title);
return `#${this.store.titles.length} ${title} at ${this.clock.now().toISOString()}`;
}
}
const CLOCK = createToken<Clock>("Clock");
const START = createToken<string>("StartTime");
const container = createContainer();
container.registerValue(START, "2026-09-23T09:00:00Z");
container.registerFactory(CLOCK, (start) => ({ now: () => new Date(start) }), [START]);
container.registerClass(TaskStore, TaskStore);
container.registerClass(TaskService, TaskService, { inject: [TaskStore, CLOCK] });
const service = container.resolve(TaskService);
console.log(service.create("Buy milk"));
npx tsx providers.ts and of the browser terminal#1 Buy milk at 2026-09-23T09:00:00.000Z
To build TaskService, the container first resolved TaskStore (a class with no dependencies) and CLOCK (a factory, which needed START), then called new TaskService(store, clock). You never wrote that call.
What if you resolve a class you never registered? The container builds it for you, as a transient, but only when its constructor takes no parameters. Otherwise it cannot know what to pass, and it tells you how to register the class:
import { createContainer } from "@zudojs/container";
class TaskStore {
readonly titles: string[] = [];
}
class TaskService {
constructor(readonly store: TaskStore) {}
}
const container = createContainer();
console.log(container.resolve(TaskStore).titles);
try {
container.resolve(TaskService);
} catch (error) {
console.log((error as Error).name);
console.log((error as Error).message);
}
npx tsx auto.ts and of the browser terminal[]
RegistrationNotFoundError
Cannot auto-register class "TaskService": its constructor declares 1 parameter and no inject list is registered, so it would be built with undefined dependencies. Register it with container.registerClass(TaskService, TaskService, { inject: [/* one token per parameter */] }) or container.registerFactory(TaskService, (...deps) => new TaskService(...deps), [/* tokens */]).Registering every service explicitly is still the clearer choice: the composition root then lists everything the app is made of.
The factory's parameter start has no type annotation, yet TypeScript knows it is a string: a factory's parameters are typed from its inject list, in order (since @zudojs/container 1.2.0). So a factory whose parameters do not match its tokens does not compile. Here the two parameters are in the wrong order:
import { createContainer, createToken } from "@zudojs/container";
interface Clock {
now(): Date;
}
class TaskStore {
readonly titles: string[] = [];
}
class TaskService {
constructor(readonly store: TaskStore, readonly clock: Clock) {}
}
const CLOCK = createToken<Clock>("Clock");
const container = createContainer();
container.registerFactory(
TaskService,
(clock, store) => new TaskService(store, clock),
[TaskStore, CLOCK],
);
npx tsc --noEmit printsfactory-check.ts:20:37 - error TS2741: Property 'titles' is missing in type 'Clock' but required in type 'TaskStore'.
20 (clock, store) => new TaskService(store, clock),
~~~~~
factory-check.ts:8:12 - 'titles' is declared here.
8 readonly titles: string[] = [];
~~~~~~
Found 1 error in factory-check.ts:20The second token is CLOCK, so the second parameter is a Clock, even though it is called store. The constructor wants a TaskStore there, and a Clock has no titles. The names of the parameters do not matter; their order does.
THE CLASS INJECT LIST IS NOT TYPE-CHECKED
ForregisterClass, TypeScript still does not compare inject: [TaskStore, CLOCK] with the constructor. If you swap the two tokens there, the code compiles and fails when it runs. Keep the inject list next to the constructor and in the same order, and let a test resolve every service once, so a mistake shows up in the test run. When you want the compiler to check the wiring, register the class with a factory instead: registerFactory(TaskService, (store, clock) => new TaskService(store, clock), [TaskStore, CLOCK]).Lifetimes: singleton, scoped and transient
Should everyone who asks for TaskStore get the same store, or a new one? That is the registration's lifetime, set with the scope option:
ContainerScope.SINGLETON: one instance for the whole container. Use it for things that must be shared: a store, a connection pool, a logger.ContainerScope.TRANSIENT: a new instance on everyresolve. This is the default.ContainerScope.SCOPED: one instance per scope. A scope is a short-lived child of the container, usually one per HTTP request.
The identity operator === shows the difference: it is true only for the very same object.
import { ContainerScope, createContainer } from "@zudojs/container";
let created = 0;
class TaskStore { readonly id = ++created; }
class Report { readonly id = ++created; }
class RequestInfo { readonly id = ++created; }
const container = createContainer();
container.registerClass(TaskStore, TaskStore, { scope: ContainerScope.SINGLETON });
container.registerClass(Report, Report);
container.registerClass(RequestInfo, RequestInfo, { scope: ContainerScope.SCOPED });
console.log("singleton:", container.resolve(TaskStore) === container.resolve(TaskStore));
console.log("transient:", container.resolve(Report) === container.resolve(Report));
const request1 = container.createScope({ name: "request-1" });
const request2 = container.createScope({ name: "request-2" });
console.log("scoped, same request:", request1.resolve(RequestInfo) === request1.resolve(RequestInfo));
console.log("scoped, two requests:", request1.resolve(RequestInfo) === request2.resolve(RequestInfo));
console.log("singleton inside a scope:", request1.resolve(TaskStore) === container.resolve(TaskStore));
console.log("objects created:", created);
npx tsx lifetimes.ts and of the browser terminalsingleton: true transient: false scoped, same request: true scoped, two requests: false singleton inside a scope: true objects created: 5
Count the objects: one TaskStore, two Reports (one per resolve), and two RequestInfos (one per scope). A singleton stays the same everywhere, even when resolved through a scope.
The container also stops you from mixing lifetimes in a dangerous way:
import { ContainerScope, createContainer } from "@zudojs/container";
class RequestInfo {}
class TaskService {
constructor(readonly request: RequestInfo) {}
}
const container = createContainer();
container.registerClass(RequestInfo, RequestInfo, { scope: ContainerScope.SCOPED });
container.registerClass(TaskService, TaskService, {
scope: ContainerScope.SINGLETON,
inject: [RequestInfo],
});
try {
container.resolve(RequestInfo);
} catch (error) {
console.log((error as Error).name);
}
try {
container.createScope().resolve(TaskService);
} catch (error) {
console.log((error as Error).name);
console.log((error as Error).message);
}
npx tsx lifetime-errors.ts and of the browser terminalScopedResolutionError CaptiveDependencyError Captive dependency: singleton "TaskService" depends on scoped "RequestInfo". A longer-lived consumer cannot capture a shorter-lived dependency. (chain: TaskService -> RequestInfo)
- A scoped dependency belongs to a request, so asking for it with no scope is a
ScopedResolutionError. - A singleton lives forever. If it kept a reference to one request's
RequestInfo, every later request would see the first request's data: a real data leak between users. The container refuses with aCaptiveDependencyError. The rule: a dependency must live at least as long as the thing that uses it.
NOTE
Error messages name a class token by its class name, and a token made withcreateToken("Mailer") by its name, Mailer.Circular dependencies
If A needs B and B needs A, neither can be built first. You met this problem between modules in the runtime lesson. The container detects it too, and names the whole loop:
import { CircularDependencyError, createContainer } from "@zudojs/container";
class TaskService {
constructor(readonly notifier: unknown) {}
}
class Notifier {
constructor(readonly tasks: unknown) {}
}
const container = createContainer();
container.registerClass(TaskService, TaskService, { inject: [Notifier] });
container.registerClass(Notifier, Notifier, { inject: [TaskService] });
try {
container.resolve(TaskService);
} catch (error) {
if (error instanceof CircularDependencyError) {
console.log(error.message);
}
}
npx tsx circular.ts and of the browser terminalCircular dependency detected: TaskService -> Notifier -> TaskService.
A cycle is almost always a design problem, not a container problem. The usual fixes: move the shared part into a third class that both use, or let one side send an event instead of calling the other directly (you will meet events in the events lesson).
Disposal: cleaning up
Some objects hold resources: a database connection, a file, a timer. When the app stops, they must be released. An object is disposable if it has a dispose() method. container.dispose() calls it on every singleton the container created, newest first, so a service is disposed before the pool it uses:
import { ContainerScope, createContainer } from "@zudojs/container";
class Pool {
dispose() { console.log("pool closed"); }
}
class TaskService {
constructor(readonly pool: Pool) {}
dispose() { console.log("task service stopped"); }
}
class RequestLog {
dispose() { console.log("request log flushed"); }
}
const container = createContainer();
container.registerClass(Pool, Pool, { scope: ContainerScope.SINGLETON });
container.registerClass(TaskService, TaskService, { scope: ContainerScope.SINGLETON, inject: [Pool] });
container.registerClass(RequestLog, RequestLog, { scope: ContainerScope.SCOPED });
container.resolve(TaskService);
const request = container.createScope();
request.resolve(RequestLog);
await request.dispose();
console.log("--- request finished");
await container.dispose();
console.log("disposed:", container.isDisposed());
npx tsx disposal.ts and of the browser terminalrequest log flushed --- request finished task service stopped pool closed disposed: true
Disposing a scope only disposes that scope's objects. Transient objects are never tracked, so whoever resolved them must clean them up. Values you gave with registerValue are never disposed either: the container did not create them, so it does not own them.
By default, the runtime does not dispose the container for you: you created it, so you own it. Either dispose it yourself at the very end of shutdown, after runtime.stop(), or pass the runtime option disposeContainerOnStop: true (from the runtime lesson), and runtime.stop() disposes it after every module has stopped.
Testing with fakes
This is where dependency injection pays off. In a test you want the real TaskService but not the real outside world: no real e-mails, a fixed clock. container.replace swaps one registration, and every singleton that was built on the old one is rebuilt. snapshot and restoreSnapshot put everything back afterwards:
import { ContainerScope, createContainer, createToken } from "@zudojs/container";
interface Mailer {
send(to: string, text: string): void;
}
const MAILER = createToken<Mailer>("Mailer");
class TaskNotifier {
constructor(private readonly mailer: Mailer) {}
taskDone(title: string): void {
this.mailer.send("ada@example.com", `"${title}" is done`);
}
}
const container = createContainer();
container.registerValue(MAILER, { send: (to, text) => console.log(`SMTP to ${to}: ${text}`) });
container.registerClass(TaskNotifier, TaskNotifier, { scope: ContainerScope.SINGLETON, inject: [MAILER] });
container.resolve(TaskNotifier).taskDone("Buy milk");
const original = container.snapshot();
const sent: string[] = [];
container.replace(MAILER, { useValue: { send: (_to, text) => { sent.push(text); } } });
container.resolve(TaskNotifier).taskDone("Walk the dog");
console.log("the fake mailer recorded:", sent);
container.restoreSnapshot(original);
container.resolve(TaskNotifier).taskDone("Water the plants");
npx tsx fakes.ts and of the browser terminalSMTP to ada@example.com: "Buy milk" is done the fake mailer recorded: [ '"Walk the dog" is done' ] SMTP to ada@example.com: "Water the plants" is done
While the fake was in place, nothing went to "SMTP" and the test could inspect exactly what would have been sent. TaskNotifier itself did not change at all. In a test file, the simplest approach is often a fresh container per test, filled with fakes from the start.
Registering the same token twice by accident is an error (DuplicateRegistrationError), so a typo cannot silently replace a real service. Use replace when you mean it.
Put it in the Task API
Open src/container.ts. The CLI calls it the composition root, and it is the hand-written kind from the start of this lesson: createDependencies builds the example resource itself, with new ExamplesController(new ExamplesService(new InMemoryExamplesRepository())). For one small resource with no shared parts, that is fine.
The Task API's pieces need more. The same TaskStore must reach the runtime's modules and the task service, and a test must be able to swap the clock. That is a job for the container that src/app.ts already creates with createContainer() and hands to the runtime. So far that container is empty. The TaskStore from the previous lesson does not change:
export type Priority = "low" | "normal" | "high";
export interface StoredTask {
readonly id: number;
readonly title: string;
readonly done: boolean;
readonly priority: Priority;
readonly createdAt: string;
}
export class TaskStore {
public readonly tasks = new Map<number, StoredTask>();
public connected = false;
private lastId = 0;
public nextId(): number {
this.lastId += 1;
return this.lastId;
}
}
Copy task.schema.ts from the ts-tasks folder of Your first Zudo code to src/dtos/tasks.dto.ts. The src/dtos/ folder holds the schemas for data that crosses the API, like the generated examples.dto.ts. Make one change:
import { schema } from "@zudojs/schema";
import type { Infer } from "@zudojs/schema";
export const NewTaskSchema = schema.object({
title: schema.string().trim().min(3).max(100),
done: schema.default(schema.boolean(), false),
priority: schema.default(schema.enum(["low", "normal", "high"] as const), "normal"),
});
export type NewTask = Infer<typeof NewTaskSchema>;
Add as const after the list of priorities, as shown. Without it, TypeScript widens the list to string[], so the inferred priority type would be any string instead of exactly "low" | "normal" | "high", and it would not fit the store's Priority type.
The service is the one from that lesson, changed to receive its store and a clock instead of creating them. Create src/services/tasks.service.ts, next to the generated examples.service.ts:
import { ConflictError, NotFoundError } from "@zudojs/errors";
import { NewTaskSchema } from "../dtos/tasks.dto.js";
import type { StoredTask, TaskStore } from "../repositories/tasks.store.js";
export interface Clock {
now(): Date;
}
export class TaskService {
public constructor(private readonly store: TaskStore, private readonly clock: Clock) {}
public create(input: unknown): StoredTask {
const data = NewTaskSchema.parse(input);
const clash = [...this.store.tasks.values()].some((t) => t.title === data.title);
if (clash) {
throw new ConflictError(`A task called "${data.title}" already exists`);
}
const task: StoredTask = { id: this.store.nextId(), ...data, createdAt: this.clock.now().toISOString() };
this.store.tasks.set(task.id, task);
return task;
}
public list(): StoredTask[] {
return [...this.store.tasks.values()];
}
public get(id: number): StoredTask {
const task = this.store.tasks.get(id);
if (!task) {
throw new NotFoundError(`Task ${id} not found`);
}
return task;
}
}
Now tell the container how to build them. Add a CLOCK token and a registerServices function to src/container.ts, above DependencyOptions, with their imports at the top. Keep your lines outside the // zudojs:… markers: zudojs generate writes between them. The rest of the file stays as it is:
import { ContainerScope, createToken } from "@zudojs/container";
import type { Container } from "@zudojs/container";
import { TaskStore } from "./repositories/tasks.store.js";
import type { HealthCheck } from "./routes/health.routes.js";
import { TaskService } from "./services/tasks.service.js";
import type { Clock } from "./services/tasks.service.js";
// zudojs:container-imports:start
// (the generated imports stay here)
// zudojs:container-imports:end
/** The clock the Task API uses; a test can replace it with a fixed one. */
export const CLOCK = createToken<Clock>("Clock");
/** Tells the runtime's container how to build the shared task services. */
export function registerServices(container: Container): void {
container.registerValue(CLOCK, { now: () => new Date() });
container.registerClass(TaskStore, TaskStore, { scope: ContainerScope.SINGLETON });
container.registerClass(TaskService, TaskService, {
scope: ContainerScope.SINGLETON,
inject: [TaskStore, CLOCK],
});
}
In src/app.ts, fill the container right after it is created, and take the store from it instead of calling new TaskStore(). Then the modules and the service share one store. Add the import, and replace the const store = new TaskStore(); line from the previous lesson:
import { registerServices } from "./container.js";
// inside createApp():
const logger = createLogger({ name: "task-api" });
const container = createContainer();
registerServices(container);
const store = container.resolve(TaskStore);
const eventBus = createEventBus();
The runtime keeps that container as runtime.context.container. Code that needs the service, such as the routes in the next lesson, asks it: runtime.context.container.resolve(TaskService). This check script does the same with a container of its own, and swaps the clock for a fixed one, exactly as a test would. Save it as src/check-container.ts:
import { createContainer } from "@zudojs/container";
import { CLOCK, registerServices } from "./container.js";
import { TaskStore } from "./repositories/tasks.store.js";
import { TaskService } from "./services/tasks.service.js";
const container = createContainer();
registerServices(container);
container.replace(CLOCK, { useValue: { now: () => new Date("2026-09-23T09:00:00Z") } });
const service = container.resolve(TaskService);
console.log(service.create({ title: " Buy milk " }));
console.log(service.list().length, container.resolve(TaskStore).tasks.size);
console.log(service === container.resolve(TaskService));
await container.dispose();
Run it in your project, then check that everything still type-checks:
npx tsx src/check-container.ts { id: 1, title: 'Buy milk', done: false, priority: 'normal', createdAt: '2026-09-23T09:00:00.000Z' } 1 1 true npx tsc --noEmit
The service got the store and the fixed clock without calling a single constructor, and the store it wrote to is the same singleton the rest of the app sees. registerClass checks nothing about the order of inject, as the warning above said, so this script doubles as the test that resolves every service once.
Practice
TRY IT YOURSELF
An id generator
Register an IdGenerator class whose next() method returns 1, 2, 3, … Resolve it twice and call next() on each. Try it first as transient (the default), then as a singleton. Explain the difference in output.
Show a solution
import { ContainerScope, createContainer } from "@zudojs/container";
class IdGenerator {
private last = 0;
next(): number {
this.last += 1;
return this.last;
}
}
for (const scope of [ContainerScope.TRANSIENT, ContainerScope.SINGLETON]) {
const container = createContainer();
container.registerClass(IdGenerator, IdGenerator, { scope });
const a = container.resolve(IdGenerator);
const b = container.resolve(IdGenerator);
console.log(scope, a.next(), b.next());
}
npx tsx ids.ts and of the browser terminaltransient 1 1 singleton 1 2
Transient gave two separate generators, so both started at 1: two tasks could get the same id. An id generator must be shared, so it has to be a singleton.
TRY IT YOURSELF
One scope per request
Register a scoped RequestContext class with a requestId property, and a transient AuditLog that receives it. Simulate two requests with two scopes, resolve AuditLog twice in each, and show that both logs in the same request share the same RequestContext.
Show a solution
import { ContainerScope, createContainer } from "@zudojs/container";
let counter = 0;
class RequestContext {
readonly requestId = `req-${++counter}`;
}
class AuditLog {
constructor(readonly context: RequestContext) {}
}
const container = createContainer();
container.registerClass(RequestContext, RequestContext, { scope: ContainerScope.SCOPED });
container.registerClass(AuditLog, AuditLog, { inject: [RequestContext] });
for (const name of ["first", "second"]) {
const scope = container.createScope({ name });
const a = scope.resolve(AuditLog);
const b = scope.resolve(AuditLog);
console.log(name, a.context.requestId, b.context.requestId, a === b);
await scope.dispose();
}
npx tsx per-request.ts and of the browser terminalfirst req-1 req-1 false second req-2 req-2 false
The two AuditLogs are different objects (transient), but inside one scope they share one RequestContext. A transient may depend on a scoped dependency, because the transient never outlives the request.
Recap
- Dependency injection: a class receives its dependencies instead of creating them, so they can be shared and replaced.
- A token names a dependency: a class, or
createToken<T>("Name")for interfaces and values. - Providers:
registerValue,registerClasswith aninjectlist,registerFactory(its parameters are typed from itsinjectlist, so TypeScript checks the wiring),registerExisting. - Lifetimes: singleton (one per container), scoped (one per
createScope()), transient (new every time, the default). A singleton may not depend on a scoped dependency. - Cycles fail with
CircularDependencyError.dispose()cleans up singletons and scopes, newest first. - For tests:
replacea registration with a fake, thenrestoreSnapshot.
The Task API now has a working TaskService, but nothing can reach it from the network yet. The next lesson gives it HTTP routes.
Test yourself
Five questions, picked at random from this lesson's question bank. Some ask you to choose an answer, some to predict what code prints, and some to write code and run it in the terminal. Get 4 of 5 right to pass. If you don't, read the explanations and try again: you get 5 different questions.