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.
Make a class injectable
Section titled “Make a class injectable”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.
Register providers in a module
Section titled “Register providers in a module”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.
DI scopes
Section titled “DI scopes”Scopes control how often a new instance is created when a dependency is resolved. Stratal supports three scopes:
| Scope | Behavior | When to use |
|---|---|---|
Transient | New instance every time it is resolved | Stateless services (the default) |
Singleton | One shared instance for the entire application | Configuration, caches, connection pools |
Request | One instance per HTTP request | Services 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 {}Injection tokens
Section titled “Injection tokens”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, ) {}}Built-in tokens
Section titled “Built-in tokens”Stratal provides a DI_TOKENS object with pre-defined tokens for framework services:
import { DI_TOKENS } from 'stratal/di'Commonly used tokens include:
| Token | Provides |
|---|---|
DI_TOKENS.CloudflareEnv | The Cloudflare env bindings object |
DI_TOKENS.ExecutionContext | The Cloudflare ExecutionContext |
DI_TOKENS.Container | The DI container itself |
DI_TOKENS.Application | The Stratal application instance |
DI_TOKENS.ErrorHandler | The registered error handler |
DI_TOKENS.Database | The database service (if configured) |
DI_TOKENS.Queue | The queue manager |
DI_TOKENS.ConsumerRegistry | The queue consumer registry |
DI_TOKENS.Cron | The cron job manager |
DI_TOKENS.EventRegistry | The event registry |
DI_TOKENS.AuthContext | The authentication context (request-scoped) |
The two-tier container
Section titled “The two-tier container”Stratal uses a two-tier container architecture:
- Global container — created once when the application boots. It holds singleton providers and serves as the parent for all request containers.
- 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.
Access the container
Section titled “Access the container”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.
Constructor injection vs InjectParam
Section titled “Constructor injection vs InjectParam”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)) }}Access Cloudflare bindings
Section titled “Access Cloudflare bindings”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 }}