Skip to main content

Dependency Injection

Dependency Injection (DI) is the mechanism by which services receive their dependencies, enabling loose coupling, testability, and maintainability.

Why Dependency Injection?

Without DI (tight coupling):

class BankAccountService {
private fundService = new FundService(new FundRepository(db));
private accounting = new AggregateAccountingService(
new AccountingRepository(db),
);

// Hard to test, hard to change
}

With DI (loose coupling):

class BankAccountService {
constructor(
private repository: BankAccountRepository,
private fundService?: FundService,
private accountingService?: AggregateAccountingService,
) {}

// Easy to test with mocks, flexible
}

Constructor Injection

All dependencies are passed through the constructor:

export class BankAccountService {
private repository: BankAccountRepository;
private fundService?: FundService;
private accountingService?: AggregateAccountingService;

constructor(
repository: BankAccountRepository,
fundService?: FundService,
accountingService?: AggregateAccountingService,
) {
this.repository = repository;
this.fundService = fundService;
this.accountingService = accountingService;
}

async createBankAccount(data: CreateBankAccountRequest) {
// Validate fund exists (if fund service is available)
if (this.fundService) {
const fund = await this.fundService.getFundById(data.fundId);
if (!fund) throw new Error("Fund not found");
}

const account = await this.repository.create(data);

// Create accounting entry (if accounting service is available)
if (this.accountingService) {
await this.createLedgerAccount(account);
}

return account;
}
}

Required vs Optional Dependencies

Required Dependencies

Use required parameters for dependencies that are essential:

class FundService {
// Repository is required - service cannot function without it
constructor(private repository: FundRepository) {}
}

Optional Dependencies

Use optional parameters for:

  1. Cross-domain dependencies (to avoid circular references)
  2. Enhancement features (service works without them)
  3. Test flexibility (easier to mock)
class BankAccountService {
constructor(
private repository: BankAccountRepository,
private fundService?: FundService, // Optional: for validation
private accountingService?: AggregateAccountingService, // Optional: for ledger
) {}

async createBankAccount(data: CreateBankAccountRequest) {
// Service works even without optional dependencies
const account = await this.repository.create(data);

// Optional feature: create ledger entry
if (this.accountingService) {
await this.createLedgerAccount(account);
}

return account;
}
}

Dependency Graph

Circular Dependencies

The Problem

// ❌ CIRCULAR DEPENDENCY
class FundService {
constructor(
private repository: FundRepository,
private bankAccountService: BankAccountService, // Requires Fund!
) {}
}

class BankAccountService {
constructor(
private repository: BankAccountRepository,
private fundService: FundService, // Requires BankAccount!
) {}
}

The Solution: Optional Dependencies

// ✅ RESOLVED WITH OPTIONAL
class FundService {
constructor(
private repository: FundRepository,
// No dependency on BankAccountService
) {}
}

class BankAccountService {
constructor(
private repository: BankAccountRepository,
private fundService?: FundService, // Optional breaks the cycle
) {}
}

Alternative: Coordinator Pattern

// Both services are independent
class FundService {
constructor(private repository: FundRepository) {}
}

class BankAccountService {
constructor(private repository: BankAccountRepository) {}
}

// Coordinator orchestrates both
class FundSetupCoordinator {
constructor(
private fundService: FundService,
private bankAccountService: BankAccountService
) {}

async setupFund(request: FundSetupRequest) {
const fund = await this.fundService.createFund({...});
const account = await this.bankAccountService.createBankAccount({
fundId: fund.id,
...
});
return { fund, account };
}
}

Testing with DI

Unit Testing with Mocks

import { describe, it, expect, vi } from "vitest";
import { BankAccountService } from "@worker/services/bank-account/service";
import type { FundService } from "@worker/services/fund/service";

describe("BankAccountService", () => {
it("validates fund exists when creating account", async () => {
// Create mock dependencies
const mockRepository = {
create: vi.fn().mockResolvedValue({ id: "123", fundId: 1 }),
findById: vi.fn(),
};

const mockFundService = {
getFundById: vi.fn().mockResolvedValue({ id: 1, name: "Test Fund" }),
} as unknown as FundService;

// Inject mocks
const service = new BankAccountService(
mockRepository as any,
mockFundService,
);

// Test
await service.createBankAccount({
fundId: 1,
accountNumber: "123456",
bankName: "Test Bank",
routingNumber: "987654",
accountName: "Test Account",
accountType: "current",
currency: "BDT",
interestRate: 5,
minimumBalance: 0,
});

// Verify fund validation was called
expect(mockFundService.getFundById).toHaveBeenCalledWith(1);
expect(mockRepository.create).toHaveBeenCalled();
});
});

Integration Testing with Real Dependencies

import { describe, it, expect, beforeAll } from "vitest";
import { createDomainServices } from "@worker/services/factory";
import { getTestDB } from "../helpers/setup";

describe("BankAccountService Integration", () => {
let services: DomainServices;

beforeAll(async () => {
const db = await getTestDB();
services = createDomainServices({ db, env: {} as Env });
});

it("creates bank account with real fund validation", async () => {
// Create a real fund
const fund = await services.fund.createFund({
name: "Test Fund",
code: "TEST01",
organizationId: "org-1",
startDate: new Date(),
});

// Create bank account (uses real fund service)
const account = await services.bankAccount.createBankAccount({
fundId: fund.id,
accountNumber: "123456",
bankName: "Test Bank",
routingNumber: "987654",
accountName: "Test Account",
accountType: "current",
currency: "BDT",
interestRate: 5,
minimumBalance: 0,
});

expect(account.fundId).toBe(fund.id);
});
});

Dependency Lifecycle

Creation Order

Dependencies must be created in the correct order:

export function createDomainServices({ db, env }: Options) {
// 1. Create repositories (no dependencies)
const fundRepo = new FundRepository(db);
const investorRepo = new InvestorRepository(db);
const accountingRepo = new AccountingRepository(db);
const bankRepo = new BankAccountRepository(db);

// 2. Create foundation services (only repository dependencies)
const fundService = new FundService(fundRepo);
const investorService = new InvestorService(investorRepo);
const accountingService = new AggregateAccountingService(accountingRepo);

// 3. Create dependent services (require other services)
const bankAccountService = new BankAccountService(
bankRepo,
fundService, // Depends on step 2
accountingService, // Depends on step 2
);

const fdrService = new FdrService(
fdrRepo,
fundService, // Depends on step 2
);

// 4. Return all services
return {
fund: fundService,
investor: investorService,
accounting: accountingService,
bankAccount: bankAccountService,
fdr: fdrService,
};
}

Singleton vs New Instances

In production (Cloudflare Workers), services are created once per request:

// In worker/app.ts
const createTRPCContext = async (env: Env) => {
const db = getDB(env.DB);

// Services are created fresh for each request
const services = createDomainServices({ db, env });
const coordinators = createApplicationServices(services);

return { services, coordinators, db };
};

In tests, you control the lifecycle:

describe("My Test Suite", () => {
let services: DomainServices;

// Create once for all tests
beforeAll(async () => {
const db = await getTestDB();
services = createDomainServices({ db, env: {} as Env });
});

// Or create fresh for each test
beforeAll(async () => {
const db = await getTestDB();
services = createDomainServices({ db, env: {} as Env });
});
});

Best Practices

DO ✅

  1. Use constructor injection for all dependencies
  2. Declare dependencies explicitly in constructor parameters
  3. Make required dependencies non-optional
  4. Use optional dependencies to break circular references
  5. Test with both mocks and real dependencies
  6. Create services via factory in production

DON'T ❌

  1. Don't create dependencies inside services
  2. Don't use service locator pattern
  3. Don't use global singletons
  4. Don't import services directly across domains
  5. Don't create circular required dependencies
  6. Don't pass primitive values as dependencies (use config objects)

Type Safety

TypeScript ensures type-safe dependency injection:

class BankAccountService {
constructor(
private repository: BankAccountRepository, // Type-checked
private fundService?: FundService, // Type-checked
) {}

async someMethod() {
// TypeScript knows fundService might be undefined
if (this.fundService) {
// Now TypeScript knows it's defined
const fund = await this.fundService.getFundById(1);
}
}
}

// Factory provides type safety
const service = new BankAccountService(
bankRepo,
fundService, // Must be FundService type
);

See Also