Skip to main content
Skip to main content

Dependency Injection Pattern

Dependency Injection (DI) in Mifty provides a clean way to manage dependencies between classes, making your code more testable, maintainable, and flexible. The framework uses a container-based approach to automatically resolve and inject dependencies.

Pattern Overview

Dependency Injection provides:

  • Loose Coupling - Classes don't create their own dependencies
  • Testability - Easy to mock dependencies for testing
  • Flexibility - Easy to swap implementations
  • Single Responsibility - Classes focus on their core logic
  • Configuration Management - Centralized dependency configuration

Architecture

DI Container

Service Registration

Dependency Resolution

Automatic Injection

DI Container Setup

Module DI Configuration

Each module defines its dependencies in a di.ts file:

// src/modules/user/di.ts
import { container } from 'tsyringe';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { PrismaClient } from '@prisma/client';

export function registerDependencies() {
// Register repository
container.register('UserRepository', {
useFactory: (container) => {
const prisma = container.resolve<PrismaClient>('PrismaClient');
return new UserRepository(prisma);
}
});

// Register service
container.register('UserService', {
useFactory: (container) => {
const userRepository = container.resolve<UserRepository>('UserRepository');
const emailService = container.resolve<EmailService>('EmailService');
return new UserService(userRepository, emailService);
}
});

// Register controller
container.register('UserController', {
useFactory: (container) => {
const userService = container.resolve<UserService>('UserService');
return new UserController(userService);
}
});
}

Global Dependencies

Core dependencies are registered globally:

// src/config/di.ts
import { container } from 'tsyringe';
import { PrismaClient } from '@prisma/client';
import { EmailService } from '../services/email.service';
import { AuditService } from '../services/audit.service';

export function registerGlobalDependencies() {
// Database client
container.register('PrismaClient', {
useValue: new PrismaClient()
});

// Core services
container.register('EmailService', {
useClass: EmailService
});

container.register('AuditService', {
useClass: AuditService
});

// Configuration
container.register('AppConfig', {
useValue: {
port: process.env.PORT || 3000,
database: {
url: process.env.DATABASE_URL
},
email: {
provider: process.env.EMAIL_PROVIDER || 'smtp',
apiKey: process.env.EMAIL_API_KEY
}
}
});
}

Injection Patterns

Constructor Injection

The most common pattern - dependencies injected through constructor:

import { injectable, inject } from 'tsyringe';

@injectable()
export class UserService extends BaseService<User, CreateUserDto, UpdateUserDto> {
constructor(
@inject('UserRepository') private userRepository: UserRepository,
@inject('EmailService') private emailService: EmailService,
@inject('AuditService') private auditService: AuditService
) {
super(userRepository);
}

async create(data: CreateUserDto): Promise<User> {
// Validate business rules
await this.validateUserCreation(data);

// Create user
const user = await super.create(data);

// Send welcome email
await this.emailService.sendWelcomeEmail(user.email, user.name);

// Create audit log
await this.auditService.log({
action: 'USER_CREATED',
entityId: user.id,
entityType: 'User'
});

return user;
}
}

Interface-Based Injection

Using interfaces for better abstraction:

// Define interfaces
interface IEmailService {
sendWelcomeEmail(email: string, name: string): Promise<void>;
sendPasswordResetEmail(email: string, token: string): Promise<void>;
}

interface IUserRepository {
findByEmail(email: string): Promise<User | null>;
create(data: CreateUserDto): Promise<User>;
}

// Implementation
@injectable()
export class UserService {
constructor(
@inject('IUserRepository') private userRepository: IUserRepository,
@inject('IEmailService') private emailService: IEmailService
) {}

async registerUser(data: CreateUserDto): Promise<User> {
// Check if email exists
const existingUser = await this.userRepository.findByEmail(data.email);
if (existingUser) {
throw new ConflictException('Email already exists');
}

// Create user
const user = await this.userRepository.create(data);

// Send welcome email
await this.emailService.sendWelcomeEmail(user.email, user.name);

return user;
}
}

// Register implementations
container.register<IUserRepository>('IUserRepository', {
useClass: UserRepository
});

container.register<IEmailService>('IEmailService', {
useClass: EmailService
});

Factory Pattern with DI

Creating complex objects with dependencies:

interface IUserServiceFactory {
createUserService(config: UserServiceConfig): UserService;
}

@injectable()
export class UserServiceFactory implements IUserServiceFactory {
constructor(
@inject('UserRepository') private userRepository: UserRepository,
@inject('EmailService') private emailService: EmailService
) {}

createUserService(config: UserServiceConfig): UserService {
return new UserService(
this.userRepository,
this.emailService,
config
);
}
}

// Usage
@injectable()
export class UserController {
constructor(
@inject('IUserServiceFactory') private userServiceFactory: IUserServiceFactory
) {}

async createUser(req: Request, res: Response) {
const config = this.getUserServiceConfig(req);
const userService = this.userServiceFactory.createUserService(config);

const user = await userService.create(req.body);
return SuccessResponse.create(user).send(res);
}
}

Advanced DI Patterns

Conditional Registration

Register different implementations based on environment:

export function registerConditionalDependencies() {
// Email service based on environment
if (process.env.NODE_ENV === 'production') {
container.register('IEmailService', {
useClass: SendGridEmailService
});
} else if (process.env.NODE_ENV === 'development') {
container.register('IEmailService', {
useClass: ConsoleEmailService // Logs to console
});
} else {
container.register('IEmailService', {
useClass: MockEmailService // For testing
});
}

// Storage service based on configuration
const storageProvider = process.env.STORAGE_PROVIDER || 'local';

switch (storageProvider) {
case 's3':
container.register('IStorageService', {
useClass: S3StorageService
});
break;
case 'cloudinary':
container.register('IStorageService', {
useClass: CloudinaryStorageService
});
break;
default:
container.register('IStorageService', {
useClass: LocalStorageService
});
}
}

Scoped Dependencies

Managing dependency lifecycles:

import { Lifecycle } from 'tsyringe';

export function registerScopedDependencies() {
// Singleton - single instance for entire application
container.register('DatabaseConnection', {
useClass: DatabaseConnection
}, { lifecycle: Lifecycle.Singleton });

// Transient - new instance every time (default)
container.register('UserService', {
useClass: UserService
}, { lifecycle: Lifecycle.Transient });

// Container scoped - single instance per container
container.register('RequestContext', {
useClass: RequestContext
}, { lifecycle: Lifecycle.ContainerScoped });
}

Decorator-Based Configuration

Using decorators for cleaner dependency management:

import { autoInjectable, inject } from 'tsyringe';

@autoInjectable()
export class UserService extends BaseService<User, CreateUserDto, UpdateUserDto> {
constructor(
private userRepository?: UserRepository,
private emailService?: EmailService,
private auditService?: AuditService
) {
super(userRepository!);
}

// Methods use injected dependencies automatically
}

// Alternative with explicit injection
@injectable()
export class OrderService {
constructor(
@inject('OrderRepository') private orderRepository: OrderRepository,
@inject('PaymentService') private paymentService: PaymentService,
@inject('InventoryService') private inventoryService: InventoryService,
@inject('EmailService') private emailService: EmailService
) {}

async processOrder(orderData: CreateOrderDto): Promise<Order> {
// Use all injected services
const order = await this.orderRepository.create(orderData);
await this.paymentService.processPayment(order.id, orderData.paymentInfo);
await this.inventoryService.reserveItems(orderData.items);
await this.emailService.sendOrderConfirmation(order);

return order;
}
}

Configuration-Based DI

Environment-Specific Configuration

interface AppConfig {
database: {
url: string;
maxConnections: number;
};
email: {
provider: 'sendgrid' | 'smtp' | 'console';
apiKey?: string;
smtpConfig?: {
host: string;
port: number;
username: string;
password: string;
};
};
storage: {
provider: 's3' | 'local' | 'cloudinary';
config: any;
};
}

export function registerConfigBasedDependencies(config: AppConfig) {
// Register configuration
container.register('AppConfig', { useValue: config });

// Register database with configuration
container.register('PrismaClient', {
useFactory: () => new PrismaClient({
datasources: {
db: { url: config.database.url }
}
})
});

// Register email service based on config
switch (config.email.provider) {
case 'sendgrid':
container.register('IEmailService', {
useFactory: () => new SendGridEmailService(config.email.apiKey!)
});
break;
case 'smtp':
container.register('IEmailService', {
useFactory: () => new SMTPEmailService(config.email.smtpConfig!)
});
break;
case 'console':
container.register('IEmailService', {
useClass: ConsoleEmailService
});
break;
}

// Register storage service based on config
container.register('IStorageService', {
useFactory: () => createStorageService(config.storage)
});
}

function createStorageService(storageConfig: AppConfig['storage']) {
switch (storageConfig.provider) {
case 's3':
return new S3StorageService(storageConfig.config);
case 'cloudinary':
return new CloudinaryStorageService(storageConfig.config);
case 'local':
return new LocalStorageService(storageConfig.config);
default:
throw new Error(`Unknown storage provider: ${storageConfig.provider}`);
}
}

Testing with DI

Unit Testing with Mocked Dependencies

import { container } from 'tsyringe';
import { UserService } from './user.service';
import { UserRepository } from './user.repository';
import { EmailService } from './email.service';

describe('UserService', () => {
let userService: UserService;
let mockUserRepository: jest.Mocked<UserRepository>;
let mockEmailService: jest.Mocked<EmailService>;

beforeEach(() => {
// Create mocks
mockUserRepository = {
create: jest.fn(),
findByEmail: jest.fn(),
exists: jest.fn()
} as any;

mockEmailService = {
sendWelcomeEmail: jest.fn()
} as any;

// Create child container for testing
const testContainer = container.createChildContainer();

// Register mocks
testContainer.register('UserRepository', { useValue: mockUserRepository });
testContainer.register('EmailService', { useValue: mockEmailService });

// Resolve service with mocked dependencies
userService = testContainer.resolve(UserService);
});

it('should create user and send welcome email', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com'
};

const createdUser = { id: '1', ...userData };

mockUserRepository.exists.mockResolvedValue(false);
mockUserRepository.create.mockResolvedValue(createdUser);
mockEmailService.sendWelcomeEmail.mockResolvedValue(undefined);

const result = await userService.create(userData);

expect(result).toEqual(createdUser);
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith(
userData.email,
userData.name
);
});
});

Integration Testing with Real Dependencies

import { setupTestContainer } from '../test/setup';

describe('UserService Integration', () => {
let testContainer: DependencyContainer;
let userService: UserService;

beforeAll(async () => {
testContainer = await setupTestContainer();
userService = testContainer.resolve(UserService);
});

afterAll(async () => {
await testContainer.dispose();
});

it('should create user with real dependencies', async () => {
const userData = {
name: 'John Doe',
email: 'john@example.com'
};

const user = await userService.create(userData);

expect(user.id).toBeDefined();
expect(user.email).toBe(userData.email);

// Verify user exists in database
const foundUser = await userService.findById(user.id);
expect(foundUser).toBeTruthy();
});
});

Test Container Setup

// test/setup.ts
import { container, DependencyContainer } from 'tsyringe';
import { PrismaClient } from '@prisma/client';

export async function setupTestContainer(): Promise<DependencyContainer> {
const testContainer = container.createChildContainer();

// Setup test database
const testPrisma = new PrismaClient({
datasources: {
db: { url: process.env.TEST_DATABASE_URL }
}
});

testContainer.register('PrismaClient', { useValue: testPrisma });

// Register test-specific services
testContainer.register('EmailService', {
useClass: MockEmailService
});

// Register all other dependencies
await registerTestDependencies(testContainer);

return testContainer;
}

async function registerTestDependencies(container: DependencyContainer) {
// Register repositories
container.register('UserRepository', {
useFactory: (c) => new UserRepository(c.resolve('PrismaClient'))
});

// Register services
container.register('UserService', {
useFactory: (c) => new UserService(
c.resolve('UserRepository'),
c.resolve('EmailService')
)
});
}

Best Practices

1. Use Interfaces for Abstraction

// Good: Interface-based injection
interface IEmailService {
sendEmail(to: string, subject: string, body: string): Promise<void>;
}

@injectable()
export class UserService {
constructor(
@inject('IEmailService') private emailService: IEmailService
) {}
}

// Avoid: Concrete class injection
@injectable()
export class UserService {
constructor(
@inject('SendGridEmailService') private emailService: SendGridEmailService
) {}
}

2. Register Dependencies at Module Level

// Good: Module-specific registration
// src/modules/user/di.ts
export function registerUserDependencies() {
container.register('UserRepository', { useClass: UserRepository });
container.register('UserService', { useClass: UserService });
container.register('UserController', { useClass: UserController });
}

// Avoid: Global registration of module-specific dependencies

3. Use Factory Pattern for Complex Dependencies

// Good: Factory for complex setup
container.register('DatabaseConnection', {
useFactory: () => {
const config = container.resolve<AppConfig>('AppConfig');
return new PrismaClient({
datasources: { db: { url: config.database.url } },
log: config.database.enableLogging ? ['query'] : []
});
}
});

// Avoid: Complex setup in constructor

4. Manage Dependency Lifecycles

// Good: Appropriate lifecycles
container.register('DatabaseConnection', {
useClass: PrismaClient
}, { lifecycle: Lifecycle.Singleton }); // Single instance

container.register('UserService', {
useClass: UserService
}, { lifecycle: Lifecycle.Transient }); // New instance each time

// Avoid: Wrong lifecycle choices

5. Use Child Containers for Testing

// Good: Isolated test containers
describe('UserService', () => {
let testContainer: DependencyContainer;

beforeEach(() => {
testContainer = container.createChildContainer();
// Register test-specific dependencies
});

afterEach(() => {
testContainer.dispose();
});
});

// Avoid: Modifying global container in tests