Dependency Injection
Rikta features a powerful dependency injection (DI) system that makes it easy to manage the lifecycles and relationships between your application's components.
Introduction
Dependency Injection is a design pattern where dependencies are "injected" into a class rather than being created by the class itself. This leads to:
- Loose coupling between components
- Easier testing through mock injection
- Better code organization
- Automatic lifecycle management
The Container
At the heart of Rikta's DI system is the Container. The container is responsible for:
- Registering providers (services, factories, values)
- Resolving dependencies between providers
- Managing the lifecycle of singleton instances
- Injecting dependencies into classes
import { container } from '@riktajs/core';
// The container is available globally
container.registerClass(MyService, MyService);
container.resolve(MyService); // Returns singleton instance
Injection Types
Property Injection with @Autowired()
The most common way to inject dependencies in Rikta:
import { Controller, Autowired } from '@riktajs/core';
@Controller('/users')
export class UserController {
@Autowired()
private userService!: UserService;
@Autowired()
private logger!: LoggerService;
}
The ! after the property name tells TypeScript that the property will be initialized (by the DI container), even though it's not assigned in the constructor.
Constructor Injection
You can also inject dependencies via the constructor:
import { Injectable, Autowired } from '@riktajs/core';
@Injectable()
export class UserService {
constructor(
@Autowired() private database: DatabaseService,
@Autowired() private cache: CacheService
) {}
}
Scopes
Rikta supports different scopes for providers:
Singleton (Default)
By default, all providers are singletons - a single instance is shared across the entire application:
@Injectable()
export class ConfigService {
// Same instance everywhere
}
Transient
Create a new instance every time the provider is requested:
@Injectable({ scope: 'transient' })
export class RequestLogger {
private requestId = crypto.randomUUID();
}
Request Scope
Create a new instance for each HTTP request. The same instance is reused within a single request context, which is useful for request-specific services:
import { Injectable } from '@riktajs/core';
@Injectable({ scope: 'request' })
export class RequestContext {
public requestId = crypto.randomUUID();
public startTime = Date.now();
public userId?: string;
}
@Controller('/orders')
export class OrderController {
@Autowired()
private requestContext!: RequestContext;
@Autowired()
private orderService!: OrderService;
@Get('/')
async getOrders() {
// Same RequestContext instance throughout this request
console.log(`Request ${this.requestContext.requestId}`);
return this.orderService.findAll();
}
}
@Injectable()
export class OrderService {
@Autowired()
private requestContext!: RequestContext;
async findAll() {
// Same RequestContext instance as in the controller!
console.log(`Processing in request ${this.requestContext.requestId}`);
return [];
}
}
Request scope uses Node.js AsyncLocalStorage to maintain a request-specific context. This means:
- Each HTTP request gets its own instance
- The same instance is shared across all services within that request
- Instances are automatically cleaned up when the request completes
- Works correctly with async/await operations
- Request logging: Track a request ID across all service calls
- User context: Store authenticated user info accessible everywhere
- Transaction management: Share a database transaction across services
- Performance tracking: Measure timing across the request lifecycle
Injection Tokens
For injecting non-class values (strings, numbers, objects), use injection tokens:
import { InjectionToken, container } from '@riktajs/core';
// Define a token
const DATABASE_URL = new InjectionToken<string>('database.url');
// Register a value
container.registerValue(DATABASE_URL, 'postgres://localhost/mydb');
// Inject by token
@Injectable()
export class DatabaseService {
@Autowired(DATABASE_URL)
private databaseUrl!: string;
}
Factory Providers
When you need custom initialization logic:
import { container, InjectionToken } from '@riktajs/core';
const CONNECTION = new InjectionToken<Connection>('database.connection');
container.registerFactory(CONNECTION, () => {
const config = container.resolve(ConfigService);
return new Connection({
host: config.get('DB_HOST'),
port: config.get('DB_PORT'),
});
});
Circular Dependencies
Rikta handles circular dependencies automatically when using property injection:
@Injectable()
export class ServiceA {
@Autowired()
private serviceB!: ServiceB; // Works even if ServiceB depends on ServiceA
}
@Injectable()
export class ServiceB {
@Autowired()
private serviceA!: ServiceA;
}
Circular dependencies will cause errors when using constructor injection. Use property injection to avoid this issue.
Optional Dependencies
Mark a dependency as optional when it may not exist:
import { Optional, Autowired } from '@riktajs/core';
@Injectable()
export class NotificationService {
@Optional()
@Autowired()
private emailService?: EmailService;
notify(message: string) {
if (this.emailService) {
this.emailService.send(message);
} else {
console.log(message);
}
}
}
Advanced: Custom Decorators
Create your own injection decorators for cleaner code:
import { Autowired } from '@riktajs/core';
// Create a custom decorator
export function InjectConfig() {
return Autowired();
}
// Usage
@Injectable()
export class MyService {
@InjectConfig()
private config!: ConfigService;
}
The Registry
Rikta uses a Registry to track all decorated classes:
import { Registry } from '@riktajs/core';
// Access registered providers
const providers = Registry.getAllProviders();
const controllers = Registry.getAllControllers();
Best Practices
1. Prefer Property Injection
// ✅ Preferred
@Injectable()
export class UserService {
@Autowired()
private database!: DatabaseService;
}
// ⚠️ Works, but less flexible
@Injectable()
export class UserService {
constructor(@Autowired() private database: DatabaseService) {}
}
2. Use Interfaces with Tokens
interface Logger {
log(message: string): void;
}
const LOGGER = new InjectionToken<Logger>('logger');
// Register different implementations
container.registerClass(LOGGER, ConsoleLogger);
// or
container.registerClass(LOGGER, FileLogger);
3. Use Abstract Classes for Complex Contracts
For complex service contracts (Strategy pattern, Repository pattern), use abstract classes instead of tokens:
// Define the contract
abstract class NotificationStrategy {
abstract send(recipient: string, message: string): Promise<boolean>;
abstract isAvailable(): boolean;
}
// Implement with @Implements and @Primary
@Injectable()
@Primary()
@Implements(NotificationStrategy)
export class EmailStrategy extends NotificationStrategy {
async send(recipient: string, message: string): Promise<boolean> {
// Email implementation
return true;
}
isAvailable(): boolean {
return true;
}
}
// Inject the abstract class - auto-resolved to @Primary
@Injectable()
export class NotificationService {
@Autowired()
private strategy!: NotificationStrategy; // Gets EmailStrategy
}
See Strategy Pattern with Factory for a complete example.
4. Keep Services Focused
// ✅ Good - single responsibility
@Injectable()
export class UserValidationService {}
@Injectable()
export class UserPersistenceService {}
// ❌ Avoid - too many responsibilities
@Injectable()
export class UserService {
// validation, persistence, notifications, etc.
}
5. Avoid Service Locator Pattern
// ❌ Anti-pattern - manually resolving
@Injectable()
export class BadService {
doSomething() {
const other = container.resolve(OtherService);
}
}
// ✅ Good - proper injection
@Injectable()
export class GoodService {
@Autowired()
private other!: OtherService;
doSomething() {
this.other.method();
}
}
Strategy Pattern with Factory
The Strategy pattern allows you to define a family of algorithms and make them interchangeable at runtime. Rikta's Abstract Class DI makes this pattern elegant and type-safe.
Define the Abstract Strategy
// strategies/notification.strategy.ts
export abstract class NotificationStrategy {
abstract send(recipient: string, message: string): Promise<boolean>;
abstract isAvailable(): boolean;
abstract getChannel(): string;
// Shared utility method available to all implementations
protected log(message: string): void {
console.log(`[${this.constructor.name}] ${message}`);
}
}
Create Multiple Implementations
// strategies/email.strategy.ts
import { Injectable, Implements, Primary } from '@riktajs/core';
@Injectable()
@Primary() // This is the default
@Implements(NotificationStrategy)
export class EmailStrategy extends NotificationStrategy {
async send(recipient: string, message: string): Promise<boolean> {
this.log(`Sending email to ${recipient}`);
return true;
}
isAvailable(): boolean {
return true;
}
getChannel(): string {
return 'email';
}
}
// strategies/sms.strategy.ts
@Injectable()
@Implements(NotificationStrategy)
export class SmsStrategy extends NotificationStrategy {
async send(recipient: string, message: string): Promise<boolean> {
this.log(`Sending SMS to ${recipient}`);
return true;
}
isAvailable(): boolean {
return process.env.TWILIO_ENABLED === 'true';
}
getChannel(): string {
return 'sms';
}
}
Create a Factory for Runtime Selection
// strategies/notification.factory.ts
@Injectable()
export class NotificationFactory {
@Autowired()
private emailStrategy!: EmailStrategy;
@Autowired()
private smsStrategy!: SmsStrategy;
@Autowired()
private pushStrategy!: PushStrategy;
getStrategy(channel: 'email' | 'sms' | 'push'): NotificationStrategy {
switch (channel) {
case 'email': return this.emailStrategy;
case 'sms': return this.smsStrategy;
case 'push': return this.pushStrategy;
}
}
getAvailableStrategies(): NotificationStrategy[] {
return [this.emailStrategy, this.smsStrategy, this.pushStrategy]
.filter(s => s.isAvailable());
}
}
Use in Your Service
@Injectable()
export class NotificationService {
// Default strategy (EmailStrategy - marked as @Primary)
@Autowired()
private defaultStrategy!: NotificationStrategy;
// Factory for runtime selection
@Autowired()
private factory!: NotificationFactory;
async notify(
recipient: string,
message: string,
channel?: 'email' | 'sms' | 'push'
): Promise<boolean> {
const strategy = channel
? this.factory.getStrategy(channel)
: this.defaultStrategy;
return strategy.send(recipient, message);
}
async broadcastToAll(recipient: string, message: string): Promise<void> {
const strategies = this.factory.getAvailableStrategies();
await Promise.all(strategies.map(s => s.send(recipient, message)));
}
}
- Single implementation: Automatically used, no
@Primaryneeded - Multiple with
@Primary: The@Primaryis the default when injecting the abstract class - Multiple without
@Primary: Throws error at resolution time - Multiple with
@Named: Use qualified injection to select specific implementations
Named Implementations with @Named
When you have multiple implementations and need to inject specific ones by name (rather than just a default), use the @Named decorator:
Define Named Implementations
// mailers/smtp.mailer.ts
import { Injectable, Implements, Named, Primary } from '@riktajs/core';
abstract class Mailer {
abstract send(to: string, body: string): Promise<void>;
}
@Injectable()
@Implements(Mailer)
@Named('smtp')
@Primary() // Also the default when no name is specified
export class SmtpMailer extends Mailer {
async send(to: string, body: string): Promise<void> {
console.log(`[SMTP] Sending to ${to}`);
}
}
// mailers/sendgrid.mailer.ts
@Injectable()
@Implements(Mailer)
@Named('sendgrid')
export class SendGridMailer extends Mailer {
async send(to: string, body: string): Promise<void> {
console.log(`[SendGrid] Sending to ${to}`);
}
}
// mailers/mailgun.mailer.ts
@Injectable()
@Implements(Mailer)
@Named('mailgun')
export class MailgunMailer extends Mailer {
async send(to: string, body: string): Promise<void> {
console.log(`[Mailgun] Sending to ${to}`);
}
}
Inject by Name
Use @Autowired(Token, 'name') to inject a specific named implementation:
@Injectable()
export class MailService {
// Default (Primary) - SmtpMailer
@Autowired(Mailer)
private defaultMailer!: Mailer;
// Explicit named injection
@Autowired(Mailer, 'smtp')
private smtpMailer!: Mailer;
@Autowired(Mailer, 'sendgrid')
private sendgridMailer!: Mailer;
@Autowired(Mailer, 'mailgun')
private mailgunMailer!: Mailer;
async sendWithFallback(to: string, body: string): Promise<void> {
try {
await this.sendgridMailer.send(to, body);
} catch {
// Fallback to SMTP
await this.smtpMailer.send(to, body);
}
}
}
Constructor Injection with Names
Named injection also works with constructor parameters:
@Injectable()
export class EmailCampaignService {
constructor(
@Autowired(Mailer, 'sendgrid') private bulkMailer: Mailer,
@Autowired(Mailer, 'smtp') private transactionalMailer: Mailer
) {}
async sendCampaign(recipients: string[], body: string): Promise<void> {
// Use SendGrid for bulk emails
await Promise.all(recipients.map(r => this.bulkMailer.send(r, body)));
}
async sendReceipt(to: string, receipt: string): Promise<void> {
// Use SMTP for transactional emails
await this.transactionalMailer.send(to, receipt);
}
}
If you request a name that doesn't exist, Rikta will throw a descriptive error:
No implementation named 'unknown' found for abstract class Mailer.
Available names: smtp, sendgrid, mailgun
DI in Queue Processors
Queue processors (from @riktajs/queue) fully support dependency injection. You can inject services, repositories, or any other registered providers:
import { Autowired } from '@riktajs/core';
import { Processor, Process, QueueService, QUEUE_SERVICE } from '@riktajs/queue';
import { Job } from 'bullmq';
@Processor('task-queue')
export class TaskProcessor {
@Autowired(LoggerService)
private logger!: LoggerService;
@Autowired(DatabaseService)
private database!: DatabaseService;
@Autowired(QUEUE_SERVICE)
private queueService!: QueueService;
@Process('process-task')
async handleTask(job: Job) {
this.logger.info(`Processing task ${job.id}`);
await this.database.save(job.data);
// Trigger follow-up job in another queue
await this.queueService.addJob('notification-queue', 'send', {
message: 'Task completed!',
});
}
}
All injected services are resolved through the DI container, ensuring proper lifecycle management. For more details, see the Queues documentation.
TypeScript Type System
Constructor Types
Rikta exports several TypeScript utility types for working with constructors:
import type { Constructor, AbstractConstructor, AnyConstructor } from '@riktajs/core';
// Constructor<T> - concrete class constructor
type MyConstructor = Constructor<MyService>;
// AbstractConstructor<T> - abstract class constructor
type MyAbstractConstructor = AbstractConstructor<BaseService>;
// AnyConstructor<T> - either concrete or abstract
type EitherConstructor = AnyConstructor<SomeInterface>;
any in Constructor Types?You may notice that Constructor<T = any> uses any as its default generic parameter. This is intentional and necessary because:
- reflect-metadata compatibility: TypeScript's
Reflect.getMetadata('design:paramtypes', ...)returns an array ofanytypes - Decorator signature requirements: TypeScript decorator signatures require
anyfor proper type inference - Dynamic instantiation: The DI container needs to handle unknown constructor parameters at runtime
This is a common pattern in all major TypeScript DI frameworks (Angular, NestJS, InversifyJS).