TypeScript

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:

  1. Inversion of Control: Classes declare dependencies rather than creating them
  2. Dependency Resolution: The container resolves and injects dependencies automatically
  3. 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 classes

Exporting 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');
    });
  });
});

On this page