Dependency Injection
Constructor injection and modules
NitroStack implements a dependency injection (DI) container that manages class instantiation and dependency resolution. This approach promotes loose coupling, improves testability, and enables modular application architecture.
Core Concepts
Dependency injection in NitroStack follows three principles:
- Inversion of Control: Classes declare dependencies rather than creating them
- Dependency Resolution: The container resolves and injects dependencies automatically
- Singleton Scope: Services are instantiated once and shared across the application
Injectable Decorator
The @Injectable() decorator marks a class for dependency injection:
import { Injectable } from '@nitrostack/core';
@Injectable()
export class UserService {
constructor(
private db: DatabaseService,
private cache: CacheService
) {}
async findById(id: string): Promise<User | null> {
// Check cache first
const cached = await this.cache.get(`user:${id}`);
if (cached) return cached;
// Query database
const user = await this.db.query('SELECT * FROM users WHERE id = $1', [id]);
// Cache result
if (user) {
await this.cache.set(`user:${id}`, user, 300);
}
return user;
}
async create(data: CreateUserDto): Promise<User> {
const user = await this.db.query(
'INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *',
[data.email, data.name]
);
return user;
}
}Constructor Injection
Dependencies are injected through constructor parameters. The DI container analyzes parameter types and resolves them automatically:
import { Injectable, ToolDecorator as Tool, ExecutionContext } from '@nitrostack/core';
@Injectable()
export class EmailService {
constructor(private config: ConfigService) {}
async send(to: string, subject: string, body: string): Promise<void> {
const apiKey = this.config.get('EMAIL_API_KEY');
// Send email implementation
}
}
@Injectable()
export class NotificationService {
constructor(
private emailService: EmailService,
private smsService: SmsService,
private pushService: PushNotificationService
) {}
async notifyUser(userId: string, message: string, channels: string[]): Promise<void> {
const tasks = channels.map(channel => {
switch (channel) {
case 'email': return this.emailService.send(userId, 'Notification', message);
case 'sms': return this.smsService.send(userId, message);
case 'push': return this.pushService.send(userId, message);
default: throw new Error(`Unknown channel: ${channel}`);
}
});
await Promise.all(tasks);
}
}
export class NotificationTools {
constructor(private notificationService: NotificationService) {}
@Tool({
name: 'send_notification',
description: 'Send a notification to a user through specified channels'
})
async sendNotification(
input: { userId: string; message: string; channels: string[] },
ctx: ExecutionContext
) {
await this.notificationService.notifyUser(
input.userId,
input.message,
input.channels
);
return { success: true };
}
}Module Providers
Providers are registered in module definitions:
import { Module } from '@nitrostack/core';
@Module({
name: 'users',
controllers: [UserTools, UserResources],
providers: [
UserService,
UserRepository,
EmailService,
ValidationService
],
exports: [UserService] // Make available to importing modules
})
export class UsersModule {}Provider Registration
Register a class in a module's providers and the container can create and inject it.
// Standard provider (class reference)
providers: [UserService]
// The container will:
// 1. Analyze UserService constructor
// 2. Resolve all constructor parameters
// 3. Create a singleton instance
// 4. Inject into dependent classesExporting Providers
Export providers to make them available to other modules:
@Module({
name: 'database',
providers: [DatabaseService, ConnectionPool, QueryBuilder],
exports: [DatabaseService] // Only DatabaseService is public
})
export class DatabaseModule {}
@Module({
name: 'users',
imports: [DatabaseModule], // Import to use DatabaseService
providers: [UserService],
controllers: [UserTools]
})
export class UsersModule {}Global Modules
Global modules make providers available everywhere without explicit imports:
@Module({
name: 'core',
providers: [Logger, ConfigService, CacheService],
exports: [Logger, ConfigService, CacheService],
global: true // Available to all modules
})
export class CoreModule {}Service Lifecycle
Providers live as long as the application and are shared by everything that injects them.
Singleton Scope (Default)
By default, all services are singletons. One instance is created and shared:
@Injectable()
export class DatabaseConnectionPool {
private connections: Connection[] = [];
constructor() {
// Called once at application startup
this.initializePool();
}
private initializePool(): void {
// Create connection pool
}
async getConnection(): Promise<Connection> {
// Return available connection
}
}Initialization Order
Services are initialized in dependency order:
// 1. ConfigService (no dependencies)
@Injectable()
export class ConfigService {
constructor() {
// Initialized first
}
}
// 2. DatabaseService (depends on ConfigService)
@Injectable()
export class DatabaseService {
constructor(private config: ConfigService) {
// Initialized second
}
}
// 3. UserService (depends on DatabaseService)
@Injectable()
export class UserService {
constructor(private db: DatabaseService) {
// Initialized third
}
}Advanced Patterns
These patterns help as the number of services grows.
Service Interfaces
Define interfaces for better abstraction:
// interfaces/storage.interface.ts
export interface StorageService {
get(key: string): Promise<string | null>;
set(key: string, value: string, ttl?: number): Promise<void>;
delete(key: string): Promise<void>;
}
// services/redis-storage.service.ts
@Injectable()
export class RedisStorageService implements StorageService {
constructor(private redis: RedisClient) {}
async get(key: string): Promise<string | null> {
return this.redis.get(key);
}
async set(key: string, value: string, ttl?: number): Promise<void> {
if (ttl) {
await this.redis.setex(key, ttl, value);
} else {
await this.redis.set(key, value);
}
}
async delete(key: string): Promise<void> {
await this.redis.del(key);
}
}Factory Patterns
Create services with complex initialization:
@Injectable()
export class DatabaseServiceFactory {
constructor(private config: ConfigService) {}
createConnection(database: string): DatabaseConnection {
const baseConfig = {
host: this.config.get('DB_HOST'),
port: this.config.get('DB_PORT'),
user: this.config.get('DB_USER'),
password: this.config.get('DB_PASSWORD')
};
return new DatabaseConnection({
...baseConfig,
database
});
}
}
@Injectable()
export class MultiTenantDatabaseService {
private connections = new Map<string, DatabaseConnection>();
constructor(private factory: DatabaseServiceFactory) {}
getConnection(tenantId: string): DatabaseConnection {
if (!this.connections.has(tenantId)) {
const connection = this.factory.createConnection(`tenant_${tenantId}`);
this.connections.set(tenantId, connection);
}
return this.connections.get(tenantId)!;
}
}Composite Services
Combine multiple services into a facade:
@Injectable()
export class OrderFacadeService {
constructor(
private orderService: OrderService,
private inventoryService: InventoryService,
private paymentService: PaymentService,
private notificationService: NotificationService,
private auditService: AuditService
) {}
async processOrder(order: CreateOrderDto, userId: string): Promise<Order> {
// Start transaction
const orderRecord = await this.orderService.create(order, userId);
try {
// Reserve inventory
await this.inventoryService.reserve(order.items);
// Process payment
await this.paymentService.charge(userId, orderRecord.total);
// Finalize order
await this.orderService.confirm(orderRecord.id);
// Send confirmation
await this.notificationService.sendOrderConfirmation(userId, orderRecord);
// Audit log
await this.auditService.log('order.created', { orderId: orderRecord.id, userId });
return orderRecord;
} catch (error) {
// Rollback on failure
await this.orderService.cancel(orderRecord.id);
await this.inventoryService.release(order.items);
throw error;
}
}
}Testing with DI
Because dependencies come in through the constructor, tests can pass in mocks instead.
Mock Injection
Create mock implementations for testing:
// tests/mocks/user.service.mock.ts
export class MockUserService {
private users = new Map<string, User>();
async findById(id: string): Promise<User | null> {
return this.users.get(id) || null;
}
async create(data: CreateUserDto): Promise<User> {
const user = {
id: `usr_${Date.now()}`,
...data,
createdAt: new Date()
};
this.users.set(user.id, user);
return user;
}
// Helper for test setup
seedUser(user: User): void {
this.users.set(user.id, user);
}
clear(): void {
this.users.clear();
}
}Test Setup
Build a testing module that provides a mock in place of the real service.
// tests/user.tools.test.ts
import { createTestingModule } from '@nitrostack/core/testing';
import { UserTools } from '../src/user.tools.js';
import { MockUserService } from './mocks/user.service.mock.js';
describe('UserTools', () => {
let tools: UserTools;
let mockUserService: MockUserService;
beforeEach(async () => {
mockUserService = new MockUserService();
const module = await createTestingModule({
controllers: [UserTools],
providers: [
{ provide: UserService, useValue: mockUserService }
]
});
tools = module.get(UserTools);
});
afterEach(() => {
mockUserService.clear();
});
describe('get_user', () => {
it('should return user when found', async () => {
const testUser = {
id: 'usr_123',
email: 'test@example.com',
name: 'Test User',
createdAt: new Date()
};
mockUserService.seedUser(testUser);
const result = await tools.getUser({ userId: 'usr_123' }, mockContext);
expect(result).toEqual(testUser);
});
it('should throw error when user not found', async () => {
await expect(
tools.getUser({ userId: 'nonexistent' }, mockContext)
).rejects.toThrow('User not found');
});
});
});