Skip to content

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:

  1. Class provider (shorthand):

    providers: [EmailNotifier]
    // Equivalent to:
    { provide: EmailNotifier, useClass: EmailNotifier }
    

  2. Interface binding:

    { provide: UserRepository, useClass: UserSqlRepository }
    // Binds abstract class to concrete implementation
    

  3. Multiple implementations:

    { provide: Notifier, useClass: EmailNotifier }
    // Can swap to SMSNotifier without changing controllers
    

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.

@Injectable()
export class UserRepository {
  // Singleton by default
}

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:

// domain/user/user.repository.ts
export abstract class UserRepository { /* ... */ }

Adapter implements:

// adapters/user/user.repository.ts
@Injectable()
export class UserSqlRepository implements UserRepository { /* ... */ }

Module binds:

// web/users/users.module.ts
{ provide: UserRepository, useClass: UserSqlRepository }

Controller injects:

// web/users/users.controller.ts
constructor(private readonly userRepository: UserRepository) {}

Use case receives:

// app/user/usecases.ts
async function create(data: any, userRepository: UserRepository) { /* ... */ }

Service Pattern

Application defines service:

// app/shared/storage.ts
@Injectable()
export class StorageService { /* ... */ }

Module provides:

// web/auth/auth.module.ts
providers: [StorageService]
exports: [StorageService]

Controller injects:

// web/users/users.controller.ts
constructor(private readonly storage: StorageService) {}

Notifier Pattern

Abstract interface:

// shared/notifications/base.ts
export abstract class Notifier { /* ... */ }

Email implementation:

// shared/notifications/email.ts
@Injectable()
export class EmailNotifier extends Notifier { /* ... */ }

Module provides:

// web/users/users.module.ts
providers: [{ provide: Notifier, useClass: EmailNotifier }]

Use case receives:

// app/user/usecases.ts
async function create(data: any, notifier: Notifier) { /* ... */ }

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.