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:
- Cross-domain dependencies (to avoid circular references)
- Enhancement features (service works without them)
- 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 ✅
- Use constructor injection for all dependencies
- Declare dependencies explicitly in constructor parameters
- Make required dependencies non-optional
- Use optional dependencies to break circular references
- Test with both mocks and real dependencies
- Create services via factory in production
DON'T ❌
- Don't create dependencies inside services
- Don't use service locator pattern
- Don't use global singletons
- Don't import services directly across domains
- Don't create circular required dependencies
- 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
- Service Factory - How services are created
- DDD Approach - Domain isolation principles
- Testing Standards - Testing with DI