zmdbzero-maintenance data layer
Docs Benchmarks Anti-patterns OpenAPI
Docs / Server framework

Injection ScopesSupported

Scope has two members — 'singleton' (the default) and 'transient'. There is no request scope, and that is a design decision rather than an omission.

Singleton and transient#

import { createToken } from '@zmdb/app/di';
import { Module } from '@zmdb/app/modules';

export const DRIVER = createToken<Driver>('DRIVER');
export const REQUEST_ID = createToken<string>('REQUEST_ID');

@Module({
  providers: [
    { token: DRIVER, useValue: driver }, // singleton
    { token: REQUEST_ID, useFactory: () => randomUUID(), scope: 'transient' }, // new per resolve
  ],
  controllers: [PostsController],
})
export class AppModule {}

ProviderDef is one of two shapes:

{ token, useValue }
{ token, useFactory: (c: Container) => T, scope?: Scope }

A factory receives the Container, so a provider can depend on another:

{ token: POSTS, useFactory: (c) => defineRepository(posts, c.resolve(DRIVER)) }

scope only applies to useFactory. A 'singleton' factory runs once and the result is cached; a 'transient' factory runs on every resolve.

📝 Note

The method is resolve(token), not get(token). has(token) checks for a registration without resolving, and build(Ctor) constructs a class and populates its @Inject fields.

Why there is no request scope#

A request-scoped provider means the container rebuilds a sub-graph on every request. That is per-request allocation and resolution work, which is precisely what constructing each controller once per app avoids. A lazy controller moves that one construction to its first load; it does not become request-scoped. See Performance.

The consequence is that request-specific values are passed, not injected. There is no ambient request context either — no AsyncLocalStorage, no ctx.state bag.

@Controller('/posts')
export class PostsController {
  @Inject(POSTS) private readonly repo!: PostRepo;

  @Get('/')
  list(ctx: Ctx<Record<never, string>, unknown>) {
    return this.service.listFor(tenantFrom(ctx.headers)); // request value as an argument
  }
}

The pattern for genuinely request-scoped resources#

Build the dependency per request in the handler. A repository is an object over a Driver, so this allocation is trivial:

@Get('/')
async list(ctx: Ctx<Record<never, string>, unknown>) {
  const repo = defineRepository(posts, driverFor(tenantFrom(ctx.headers)));
  return repo.list({ page: { limit: 20 } });
}

That covers tenant scoping, row-level security, per-request query budgets and per-request batching without a scope mechanism. The full treatment, including the set_config(..., true) transaction-local detail that prevents a cross-tenant leak on a pooled connection, is in Request Context.

⚠️ Warning

Never store request state on a controller or provider field. Both are singletons — each instance is constructed once per app — so this.currentUser = … in a handler is a race that serves one user's data to another, and it looks correct in every single-request test.

Transactions#

A transaction is the request-scoped lifecycle people usually reach for a scope to express, and it needs no scope mechanism — db.transaction owns the callback, and repo.withTransaction(tx) returns a new repository instance bound to that transaction's connection:

await this.db.transaction(async tx => {
  const posts = this.repo.withTransaction(tx);
  const post = await posts.create(dto);
  await posts.update(post.id, { slug: slugify(post.title) });
  return post;
});

The bound repository lives for the callback and no longer; this.repo is untouched and still runs outside the transaction. See Transactions.

Substituting a provider in tests#

Because everything is a singleton behind a token, overrides are the whole testing story:

const app = createTestApp(AppModule, {
  overrides: [{ token: DRIVER, useValue: fakeDriver }],
});

compileModule(root, overrides) registers the overrides first, so they win over anything in the module graph. See Testing Applications.

---

See also: Dependency Injection · Request Context · Modules