Skip to content

Providers

Providers are the building blocks of Stratal’s dependency injection system. A provider tells the DI container how to create or locate a dependency. There are five ways to register a provider, plus two advanced patterns for conditional logic and service decoration.

The simplest way to register a provider is to list the class directly in the providers array:

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

This is shorthand for { provide: UsersService, useClass: UsersService }. The container resolves UsersService by instantiating it with its constructor dependencies.

A ClassProvider maps a token to a class. Use it when the token differs from the class, or when you need to set a specific scope:

import { Module } from 'stratal/module'
import { Scope } from 'stratal/di'
@Module({
providers: [
{
provide: USER_REPOSITORY,
useClass: PostgresUserRepository,
scope: Scope.Singleton,
},
],
})
export class UsersModule {}
PropertyTypeDescription
provideInjectionTokenThe token to register under
useClassConstructorThe class to instantiate
scopeScopeOptional. Defaults to Transient if not specified

A ValueProvider registers an existing value. The container returns this exact value every time the token is resolved, making it inherently singleton-like:

@Module({
providers: [
{
provide: APP_CONFIG,
useValue: {
apiUrl: 'https://api.example.com',
maxRetries: 3,
},
},
],
})
export class AppModule {}
PropertyTypeDescription
provideInjectionTokenThe token to register under
useValueTThe value to return on resolution

A FactoryProvider uses a factory function to create the value. The inject array specifies dependencies that are resolved and passed as arguments to the factory:

@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (config: ConfigService) => {
return new DatabaseConnection(config.get('DATABASE_URL'))
},
inject: [ConfigService],
},
],
})
export class DatabaseModule {}

The factory can also be async:

@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: async (config: ConfigService) => {
const conn = new DatabaseConnection(config.get('DATABASE_URL'))
await conn.connect()
return conn
},
inject: [ConfigService],
},
],
})
export class DatabaseModule {}
PropertyTypeDescription
provideInjectionTokenThe token to register under
useFactory(...deps) => TFactory function that creates the value
injectInjectionToken[]Optional. Dependencies to resolve and pass to the factory

An ExistingProvider creates an alias from one token to another. When you resolve the alias, the container resolves the target token instead:

@Module({
providers: [
UsersService,
{
provide: USER_SERVICE_ALIAS,
useExisting: UsersService,
},
],
})
export class UsersModule {}
PropertyTypeDescription
provideInjectionTokenThe alias token
useExistingInjectionTokenThe target token that the alias points to

This is useful when you want multiple tokens to resolve to the same underlying provider, or when migrating from one token to another without breaking existing consumers.

Sometimes which implementation to use depends on runtime conditions. The container’s when() method lets you register providers conditionally:

container
.when((c) => c.resolve(CONFIG_TOKEN).get('env') === 'development')
.use(FORMATTER_TOKEN)
.give(PrettyFormatter)
.otherwise(JsonFormatter)

This reads as: “When the environment is development, use PrettyFormatter for the FORMATTER_TOKEN. Otherwise, use JsonFormatter.”

The predicate receives a container reference so you can resolve other services to make the decision. You typically call this inside a module’s onInitialize lifecycle hook:

import { Module, ModuleContext } from 'stratal/module'
@Module({ providers: [PrettyFormatter, JsonFormatter] })
export class LoggingModule {
onInitialize({ container }: ModuleContext) {
container
.when((c) => c.resolve(CONFIG_TOKEN).get('env') === 'development')
.use(FORMATTER_TOKEN)
.give(PrettyFormatter)
.otherwise(JsonFormatter)
}
}

The extend() method wraps an existing service with a decorator. This is useful for adding cross-cutting concerns like logging or caching without modifying the original service:

import { Module, ModuleContext } from 'stratal/module'
@Module({ providers: [UsersService] })
export class UsersModule {
onInitialize({ container }: ModuleContext) {
container.extend(UsersService, (original, c) => {
const logger = c.resolve(LoggerService)
return new LoggingUsersService(original, logger)
})
}
}

After this call, any resolution of UsersService returns the wrapped LoggingUsersService instead. The decorator receives the original service instance and the container, so it can resolve additional dependencies.