Dependency Injection
Dependency Injection (DI) is a fundamental pattern in 2KRIKA that promotes loose coupling, testability, and maintainability. This document explains how DI is implemented across all layers of the application.
Overview
Dependency Injection in 2KRIKA:
- Decouples components – Dependencies are injected, not instantiated
- Enables testing – Easy to mock dependencies
- Promotes flexibility – Swap implementations without changing code
- Uses NestJS DI container – In the Web Layer
- Uses function parameters – In Application Layer (framework-agnostic)
Dependency Injection Across Layers
Domain Layer: Pure Interfaces
The domain defines what dependencies it needs through abstract classes and interfaces.
// domain/user/user.repository.ts
export abstract class UserRepository extends AbstractRepository<User, string> {
abstract approveKyc(id: string, opts?: any): Promise<void>;
abstract getKYCValidationDate(userId: string): Promise<Date | null>;
abstract filterByIds(ids: string[]): Promise<User[]>;
}
Key points: - No implementation details - No framework dependencies - Pure TypeScript abstractions
Application Layer: Parameter Injection
Use cases receive dependencies as function parameters.
// app/user/usecases.ts
async function create(
data: any,
userRepository: UserRepository, // Injected
notifier: Notifier, // Injected
logger: AbstractLogger, // Injected
) {
const validator = new UserCreateValidator(userRepository, logger);
const validatedData = await validator.validate(data);
const user = User.create(
userRepository.nextId(),
validatedData.fullName,
// ...
);
await userRepository.add(user);
await notifier.sendWelcomeMessage(getActivationToken(user.id), user);
return toUserDto(user);
}
Benefits: - Framework-agnostic – No NestJS decorators - Easy to test – Pass mock objects as parameters - Explicit dependencies – Clear what each use case needs - Pure functions – No hidden global state
Testing example:
// tests/app/user/usecases.test.ts
const mockRepository = createMockUserRepository();
const mockNotifier = createMockNotifier();
const mockLogger = createMockLogger();
await userUseCases.create(
{ fullName: "John", email: "john@example.com", password: "pass123" },
mockRepository,
mockNotifier,
mockLogger,
);
expect(mockRepository.add).toHaveBeenCalled();
Adapters Layer: Concrete Implementations
Adapters provide concrete implementations of domain interfaces.
// adapters/user/user.repository.ts
@Injectable()
export class UserSqlRepository
extends SqlRepository<User, string, UserQueryPolicy>
implements UserRepository
{
constructor() {
super(db.User);
}
async approveKyc(id: string, opts: any = {}): Promise<void> {
await this.model.update(
{ isKYCVerified: true },
{ where: { id }, transaction: opts.transaction }
);
}
// ... implementation details
}
Key features:
- @Injectable() decorator for NestJS
- Implements domain interface
- Can inject other dependencies via constructor
Web Layer: NestJS Dependency Injection
Controllers receive dependencies through constructor injection.
// web/users/users.controller.ts
@Controller("users")
@UseGuards(AuthGuard)
export class UsersController {
constructor(
private readonly userRepository: UserRepository,
private readonly userRoleRepository: UserRoleRepository,
private readonly identityRepository: IdentityRepository,
private readonly accountRepository: AccountRepository,
private readonly orderRepository: OrderRepository,
private readonly serviceRepository: ServiceRepository,
private readonly competencyRepository: CompetencyRepository,
private readonly commentRepository: CommentRepository,
private readonly notifier: Notifier,
private readonly storage: StorageService,
) {}
@Post()
@NoAuth()
async create(@Body() body: any) {
return await userUseCases.create(
body,
this.userRepository, // Pass injected dependency
this.notifier,
logger,
);
}
}
Key features: - Constructor injection - Private readonly properties - Dependencies automatically resolved by NestJS
NestJS Module Configuration
Modules define how dependencies are provided and wired together.
Basic Module
// web/users/users.module.ts
@Module({
imports: [AuthModule],
providers: [
EmailNotifier,
IdentitySqlRepository,
{ provide: ServiceRepository, useClass: ServiceSqlRepository },
{ provide: IdentityRepository, useClass: IdentitySqlRepository },
{ provide: OrderRepository, useClass: OrderSqlRepository },
{ provide: AccountRepository, useClass: AccountSqlRepository },
{ provide: CompetencyRepository, useClass: CompetencySqlRepository },
{ provide: CommentRepository, useClass: CommentSqlRepository },
{ provide: UserRoleRepository, useClass: UserRoleSqlRepository },
],
controllers: [UsersController, KYCController],
})
export class UsersModule {}
Provider patterns:
-
Class provider (shorthand):
-
Interface binding:
-
Multiple implementations:
Factory Providers
For complex initialization logic.
@Module({
providers: [
{
provide: StorageService,
useFactory: (fileSystem: LocalFileSystem, uploadRepo: UploadRepository) => {
return new StorageService(fileSystem, uploadRepo);
},
inject: [LocalFileSystem, UploadRepository],
},
],
})
export class StorageModule {}
Use cases: - Conditional logic during instantiation - Configuration-based initialization - Async initialization
Async Providers
For dependencies requiring async initialization.
@Module({
providers: [
{
provide: 'DATABASE_CONNECTION',
useFactory: async () => {
const connection = await createDatabaseConnection();
return connection;
},
},
],
})
export class DatabaseModule {}
Value Providers
For injecting configuration values.
@Module({
providers: [
{
provide: 'CONFIG',
useValue: {
apiKey: process.env.API_KEY,
baseUrl: process.env.BASE_URL,
},
},
],
})
export class ConfigModule {}
Module Imports and Exports
Exporting Providers
Make providers available to other modules.
// web/auth/auth.module.ts
@Module({
providers: [AuthService, LocalFileSystem, StorageService],
exports: [AuthService, StorageService], // Export for other modules
})
export class AuthModule {}
Importing Modules
Use providers from other modules.
// web/users/users.module.ts
@Module({
imports: [AuthModule], // Import AuthModule to use AuthService
providers: [/* ... */],
controllers: [UsersController],
})
export class UsersModule {}
Now UsersController can inject AuthService:
@Controller("users")
export class UsersController {
constructor(private readonly authService: AuthService) {}
}
Global Modules
Make providers available to all modules.
@Global()
@Module({
providers: [FileLogger],
exports: [FileLogger],
})
export class LoggingModule {}
Now FileLogger can be injected anywhere without importing LoggingModule.
Injection Scopes
NestJS supports different provider scopes.
Default (Singleton)
One instance shared across the application.
Request Scope
New instance per HTTP request.
@Injectable({ scope: Scope.REQUEST })
export class RequestScopedService {
// New instance for each request
}
Use case: Services that need request-specific data (user context, request ID).
Transient Scope
New instance every time it's injected.
@Injectable({ scope: Scope.TRANSIENT })
export class TransientService {
// New instance for each injection
}
Use case: Stateful services that shouldn't be shared.
Circular Dependencies
Problem
// services.service.ts
@Injectable()
export class ServicesService {
constructor(private readonly ordersService: OrdersService) {}
}
// orders.service.ts
@Injectable()
export class OrdersService {
constructor(private readonly servicesService: ServicesService) {}
}
// ❌ Circular dependency!
Solution 1: Forward Reference
// services.service.ts
@Injectable()
export class ServicesService {
constructor(
@Inject(forwardRef(() => OrdersService))
private readonly ordersService: OrdersService
) {}
}
// orders.service.ts
@Injectable()
export class OrdersService {
constructor(
@Inject(forwardRef(() => ServicesService))
private readonly servicesService: ServicesService
) {}
}
Solution 2: Refactor (Preferred)
Extract shared logic into a third service:
// shared.service.ts
@Injectable()
export class SharedService {
// Common logic
}
// services.service.ts
@Injectable()
export class ServicesService {
constructor(private readonly sharedService: SharedService) {}
}
// orders.service.ts
@Injectable()
export class OrdersService {
constructor(private readonly sharedService: SharedService) {}
}
Custom Providers
String-based Tokens
@Module({
providers: [
{
provide: 'LOGGER',
useClass: FileLogger,
},
],
})
export class AppModule {}
// Inject using @Inject decorator
@Injectable()
export class SomeService {
constructor(@Inject('LOGGER') private logger: AbstractLogger) {}
}
Symbol-based Tokens
export const LOGGER_TOKEN = Symbol('LOGGER');
@Module({
providers: [
{
provide: LOGGER_TOKEN,
useClass: FileLogger,
},
],
})
export class AppModule {}
@Injectable()
export class SomeService {
constructor(@Inject(LOGGER_TOKEN) private logger: AbstractLogger) {}
}
Testing with Dependency Injection
Unit Testing Use Cases
Application layer use cases are easy to test with manual DI.
// tests/app/user/usecases.test.ts
describe("User creation use case", () => {
let mockRepository: jest.Mocked<UserRepository>;
let mockNotifier: jest.Mocked<Notifier>;
let mockLogger: jest.Mocked<AbstractLogger>;
beforeEach(() => {
mockRepository = {
add: jest.fn().mockResolvedValue({}),
nextId: jest.fn().mockReturnValue("test-id"),
exists: jest.fn().mockResolvedValue(false),
getBy: jest.fn().mockRejectedValue(new NotFound()),
} as any;
mockNotifier = {
sendWelcomeMessage: jest.fn().mockResolvedValue(undefined),
} as any;
mockLogger = {
error: jest.fn(),
info: jest.fn(),
} as any;
});
it("should create a user", async () => {
const result = await userUseCases.create(
{
fullName: "John Doe",
email: "john@example.com",
password: "password123",
},
mockRepository,
mockNotifier,
mockLogger,
);
expect(result.email).toBe("john@example.com");
expect(mockRepository.add).toHaveBeenCalled();
expect(mockNotifier.sendWelcomeMessage).toHaveBeenCalled();
});
});
Integration Testing Controllers
Test controllers with NestJS testing utilities.
// web/users/users.controller.spec.ts
import { Test, TestingModule } from "@nestjs/testing";
describe("UsersController", () => {
let controller: UsersController;
let mockUserRepository: jest.Mocked<UserRepository>;
beforeEach(async () => {
mockUserRepository = createMockUserRepository();
const module: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UserRepository, useValue: mockUserRepository },
{ provide: Notifier, useValue: createMockNotifier() },
{ provide: StorageService, useValue: createMockStorage() },
],
}).compile();
controller = module.get<UsersController>(UsersController);
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
it("should create a user", async () => {
const body = {
fullName: "John Doe",
email: "john@example.com",
password: "password123",
};
const result = await controller.create(body);
expect(result).toHaveProperty("id");
expect(mockUserRepository.add).toHaveBeenCalled();
});
});
E2E Testing
Test the full application with real or mock dependencies.
// tests/e2e/users.e2e-spec.ts
import { Test } from "@nestjs/testing";
import { INestApplication } from "@nestjs/common";
import * as request from "supertest";
import { AppModule } from "@/web/app.module";
describe("UsersController (e2e)", () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
});
afterAll(async () => {
await app.close();
});
it("/users (POST) should create a user", () => {
return request(app.getHttpServer())
.post("/users")
.send({
fullName: "John Doe",
email: "john@example.com",
password: "password123",
})
.expect(201)
.expect((res) => {
expect(res.body).toHaveProperty("id");
expect(res.body.email).toBe("john@example.com");
});
});
});
Dependency Injection Best Practices
✅ Do
- Inject abstractions – Depend on interfaces, not implementations
- Use constructor injection – Clear and explicit dependencies
- Keep constructors simple – Just assign injected dependencies
- Test with mocks – Easy to mock injected dependencies
- Export selectively – Only export what other modules need
❌ Don't
- Don't use property injection – Less explicit, harder to test
- Don't create dependencies manually – Use the DI container
- Don't inject concrete classes – Use interfaces/abstract classes
- Don't over-inject – Too many dependencies may indicate design issues
- Don't forget @Injectable() – Required for NestJS DI
Dependency Injection Patterns in 2KRIKA
Repository Pattern
Domain defines interface:
Adapter implements:
// adapters/user/user.repository.ts
@Injectable()
export class UserSqlRepository implements UserRepository { /* ... */ }
Module binds:
Controller injects:
Use case receives:
// app/user/usecases.ts
async function create(data: any, userRepository: UserRepository) { /* ... */ }
Service Pattern
Application defines service:
Module provides:
Controller injects:
Notifier Pattern
Abstract interface:
Email implementation:
// shared/notifications/email.ts
@Injectable()
export class EmailNotifier extends Notifier { /* ... */ }
Module provides:
Use case receives:
Summary
Dependency Injection in 2KRIKA:
- Domain Layer – Defines abstract interfaces
- Application Layer – Receives dependencies as function parameters
- Adapters Layer – Provides concrete implementations
- Web Layer – Uses NestJS DI container for wiring
This multi-layered approach ensures:
- Testability – Easy to mock dependencies
- Flexibility – Swap implementations without changing code
- Decoupling – Layers depend on abstractions, not implementations
- Framework independence – Application layer stays framework-agnostic
By combining manual parameter injection (Application Layer) with NestJS DI (Web Layer), 2KRIKA achieves a balance between clean architecture principles and framework capabilities.