Skip to content

Dependency Injection

Dependency injection (DI) is a design pattern where classes receive their dependencies from an external source rather than creating them internally. Stratal has a built-in DI container that resolves and injects dependencies automatically, keeping your code loosely coupled and easy to test.

To make a class available for injection, decorate it with @Transient():

import { Transient } from 'stratal/di'
@Transient()
export class UsersService {
findAll() {
return [{ id: '1', name: 'Alice' }]
}
}

Now you can inject UsersService into any other injectable class via constructor injection:

import { Controller, IController, Route, RouterContext } from 'stratal/router'
import { inject } from 'stratal/di'
import { UsersService } from './users.service'
@Controller('/api/users')
export class UsersController implements IController {
constructor(@inject(UsersService) private readonly usersService: UsersService) {}
@Route({ response: usersSchema })
index(ctx: RouterContext) {
return ctx.json({ users: this.usersService.findAll() })
}
}

The @inject() decorator tells the container which dependency to resolve for each constructor parameter.

Injectable classes must be registered in a module’s providers array before they can be resolved:

import { Module } from 'stratal/module'
import { UsersController } from './users.controller'
import { UsersService } from './users.service'
@Module({
controllers: [UsersController],
providers: [UsersService],
})
export class UsersModule {}

Listing a class directly in providers is shorthand for { provide: UsersService, useClass: UsersService }. See Providers for all registration options.

Scopes control how often a new instance is created when a dependency is resolved. Stratal supports three scopes:

ScopeBehaviorWhen to use
TransientNew instance every time it is resolvedStateless services (the default)
SingletonOne shared instance for the entire applicationConfiguration, caches, connection pools
RequestOne instance per HTTP requestServices that hold per-request state

Set the scope when registering a provider:

import { Module } from 'stratal/module'
import { Scope } from 'stratal/di'
@Module({
providers: [
{ provide: ConfigService, useClass: ConfigService, scope: Scope.Singleton },
{ provide: RequestLogger, useClass: RequestLogger, scope: Scope.Request },
UsersService, // defaults to Transient
],
})
export class AppModule {}

When you inject a concrete class, TypeScript’s type metadata is enough for the container to find the right provider. But when you depend on an abstraction (an interface or a value), you need a token to identify it.

Create tokens using Symbols:

export const USER_REPOSITORY = Symbol.for('UserRepository')

Register a provider against the token:

@Module({
providers: [
{ provide: USER_REPOSITORY, useClass: PostgresUserRepository },
],
})
export class UsersModule {}

Then inject it using @inject:

import { inject } from 'stratal/di'
@Transient()
export class UsersService {
constructor(
@inject(USER_REPOSITORY) private readonly repo: UserRepository,
) {}
}

Stratal provides a DI_TOKENS object with pre-defined tokens for framework services:

import { DI_TOKENS } from 'stratal/di'

Commonly used tokens include:

TokenProvides
DI_TOKENS.CloudflareEnvThe Cloudflare env bindings object
DI_TOKENS.ExecutionContextThe Cloudflare ExecutionContext
DI_TOKENS.ContainerThe DI container itself
DI_TOKENS.ApplicationThe Stratal application instance
DI_TOKENS.ErrorHandlerThe registered error handler
DI_TOKENS.DatabaseThe database service (if configured)
DI_TOKENS.QueueThe queue manager
DI_TOKENS.ConsumerRegistryThe queue consumer registry
DI_TOKENS.CronThe cron job manager
DI_TOKENS.EventRegistryThe event registry
DI_TOKENS.AuthContextThe authentication context (request-scoped)

Stratal uses a two-tier container architecture:

  1. Global container — created once when the application boots. It holds singleton providers and serves as the parent for all request containers.
  2. Request container — a child container created for each incoming HTTP request. It inherits all registrations from the global container and adds request-scoped instances.

When you resolve a transient or singleton provider, the global container handles it. When you resolve a request-scoped provider, the request container creates a fresh instance that lives only for the duration of that request.

Inside a controller method, you can access the request container through RouterContext:

@Route({ response: userSchema })
show(ctx: RouterContext) {
const container = ctx.getContainer()
const service = container.resolve(UsersService)
// ...
}

Inside lifecycle hooks and middleware, you receive the container as part of the ModuleContext or through direct injection.

Stratal supports two styles of injection:

Constructor injection resolves dependencies when the class is instantiated. Use it for dependencies your class always needs:

import { Transient, inject } from 'stratal/di'
@Transient()
export class OrdersService {
constructor(
@inject(UsersService) private readonly usersService: UsersService,
@inject(PaymentService) private readonly paymentService: PaymentService,
) {}
}

@InjectParam resolves dependencies at the method level, directly in route handler parameters. Use it when a dependency is only needed for a specific route:

import { InjectParam } from 'stratal/di'
@Controller('/api/orders')
export class OrdersController implements IController {
@Route({ response: orderSchema })
async show(
ctx: RouterContext,
@InjectParam(OrdersService) ordersService: OrdersService,
@InjectParam(CacheService) cache: CacheService,
) {
// ordersService and cache are resolved from the request container
const id = ctx.param('id')
return ctx.json(await ordersService.findById(id))
}
}

Cloudflare Workers receive environment bindings (KV namespaces, D1 databases, secrets, etc.) through the env object. Stratal registers this object in the container so you can inject it into any service:

import { Transient, inject, DI_TOKENS } from 'stratal/di'
@Transient()
export class StorageService {
constructor(
@inject(DI_TOKENS.CloudflareEnv) private readonly env: Env,
) {}
async get(key: string) {
return this.env.MY_KV_NAMESPACE.get(key)
}
}

The ExecutionContext is also available for operations like waitUntil():

@Transient()
export class BackgroundTaskService {
constructor(
@inject(DI_TOKENS.ExecutionContext) private readonly ctx: ExecutionContext,
) {}
scheduleCleanup() {
this.ctx.waitUntil(this.cleanup())
}
private async cleanup() {
// runs after the response is sent
}
}
  • Providers for advanced registration patterns like factories, aliases, and conditional bindings.
  • Modules to see how providers are organized across modules.