8.4 KiB
ADR-039: Dependency Injection Pattern
Date: 2026-01-09
Status: Accepted
Implemented: 2026-01-09
Context
As the application grows, tightly coupled components become difficult to test and maintain. Common issues include:
- Hard-to-Test Code: Components that instantiate their own dependencies cannot be easily unit tested with mocks.
- Rigid Architecture: Changing one implementation requires modifying all consumers.
- Hidden Dependencies: It's unclear what a component needs to function.
- Circular Dependencies: Tight coupling can lead to circular import issues.
Dependency Injection (DI) addresses these issues by inverting the control of dependency creation.
Decision
We will adopt a constructor-based dependency injection pattern for all services and repositories. This approach:
- Explicit Dependencies: All dependencies are declared in the constructor.
- Default Values: Production dependencies have sensible defaults.
- Testability: Test code can inject mocks without modifying source code.
- Loose Coupling: Components depend on interfaces, not implementations.
Design Principles
- Constructor Injection: Dependencies are passed through constructors, not looked up globally.
- Default Production Dependencies: Use default parameter values for production instances.
- Interface Segregation: Depend on the minimal interface needed (e.g.,
Pick<Pool, 'query'>). - Composition Root: Wire dependencies at the application entry point.
Implementation Details
Repository Pattern with DI
Located in src/services/db/flyer.db.ts:
import { Pool, PoolClient } from 'pg';
import { getPool } from './connection.db';
export class FlyerRepository {
// Accept any object with a 'query' method - Pool or PoolClient
private db: Pick<Pool | PoolClient, 'query'>;
constructor(db: Pick<Pool | PoolClient, 'query'> = getPool()) {
this.db = db;
}
async getFlyerById(flyerId: number, logger: Logger): Promise<Flyer> {
const result = await this.db.query<Flyer>('SELECT * FROM flyers WHERE flyer_id = $1', [
flyerId,
]);
if (result.rows.length === 0) {
throw new NotFoundError(`Flyer with ID ${flyerId} not found.`);
}
return result.rows[0];
}
async insertFlyer(flyer: FlyerDbInsert, logger: Logger): Promise<Flyer> {
// Implementation
}
}
Usage in Production:
// Uses default pool
const flyerRepo = new FlyerRepository();
Usage in Tests:
const mockDb = {
query: vi.fn().mockResolvedValue({ rows: [mockFlyer] }),
};
const flyerRepo = new FlyerRepository(mockDb);
Usage in Transactions:
import { withTransaction } from './connection.db';
await withTransaction(async (client) => {
// Pass transactional client to repository
const flyerRepo = new FlyerRepository(client);
const flyer = await flyerRepo.insertFlyer(flyerData, logger);
// ... more operations in the same transaction
});
Service Layer with DI
Located in src/services/backgroundJobService.ts:
export class BackgroundJobService {
constructor(
private personalizationRepo: PersonalizationRepository,
private notificationRepo: NotificationRepository,
private emailQueue: Queue<EmailJobData>,
private logger: Logger,
) {}
async runDailyDealCheck(): Promise<void> {
this.logger.info('[BackgroundJob] Starting daily deal check...');
const deals = await this.personalizationRepo.getBestSalePricesForAllUsers(this.logger);
// ... process deals
}
}
// Composition root - wire production dependencies
import { personalizationRepo, notificationRepo } from './db/index.db';
import { logger } from './logger.server';
import { emailQueue } from './queueService.server';
export const backgroundJobService = new BackgroundJobService(
personalizationRepo,
notificationRepo,
emailQueue,
logger,
);
Testing with Mocks:
describe('BackgroundJobService', () => {
it('should process deals for all users', async () => {
const mockPersonalizationRepo = {
getBestSalePricesForAllUsers: vi.fn().mockResolvedValue([mockDeal]),
};
const mockNotificationRepo = {
createBulkNotifications: vi.fn().mockResolvedValue([]),
};
const mockEmailQueue = {
add: vi.fn().mockResolvedValue({ id: 'job-1' }),
};
const mockLogger = {
info: vi.fn(),
error: vi.fn(),
};
const service = new BackgroundJobService(
mockPersonalizationRepo as any,
mockNotificationRepo as any,
mockEmailQueue as any,
mockLogger as any,
);
await service.runDailyDealCheck();
expect(mockPersonalizationRepo.getBestSalePricesForAllUsers).toHaveBeenCalled();
expect(mockEmailQueue.add).toHaveBeenCalled();
});
});
Processing Service with DI
Located in src/services/flyer/flyerProcessingService.ts:
export class FlyerProcessingService {
constructor(
private fileHandler: FlyerFileHandler,
private aiProcessor: FlyerAiProcessor,
private fsAdapter: FileSystemAdapter,
private cleanupQueue: Queue<CleanupJobData>,
private dataTransformer: FlyerDataTransformer,
private persistenceService: FlyerPersistenceService,
) {}
async processFlyer(filePath: string, logger: Logger): Promise<ProcessedFlyer> {
// Use injected dependencies
const fileInfo = await this.fileHandler.extractMetadata(filePath);
const aiResult = await this.aiProcessor.analyze(filePath, logger);
const transformed = this.dataTransformer.transform(aiResult);
const saved = await this.persistenceService.save(transformed, logger);
// Queue cleanup
await this.cleanupQueue.add('cleanup', { filePath });
return saved;
}
}
// Composition root
const flyerProcessingService = new FlyerProcessingService(
new FlyerFileHandler(fsAdapter, execAsync),
new FlyerAiProcessor(aiService, db.personalizationRepo),
fsAdapter,
cleanupQueue,
new FlyerDataTransformer(),
new FlyerPersistenceService(),
);
Interface Segregation
Use the minimum interface required:
// Bad - depends on full Pool
constructor(pool: Pool) {}
// Good - depends only on what's needed
constructor(db: Pick<Pool | PoolClient, 'query'>) {}
This allows injecting either a Pool, PoolClient (for transactions), or a mock object with just a query method.
Composition Root Pattern
Wire all dependencies at application startup:
// src/services/db/index.db.ts - Composition root for repositories
import { getPool } from './connection.db';
export const userRepo = new UserRepository(getPool());
export const flyerRepo = new FlyerRepository(getPool());
export const adminRepo = new AdminRepository(getPool());
export const personalizationRepo = new PersonalizationRepository(getPool());
export const notificationRepo = new NotificationRepository(getPool());
export const db = {
userRepo,
flyerRepo,
adminRepo,
personalizationRepo,
notificationRepo,
};
Consequences
Positive
- Testability: Unit tests can inject mocks without modifying production code.
- Flexibility: Swap implementations (e.g., different database adapters) easily.
- Explicit Dependencies: Clear contract of what a component needs.
- Transaction Support: Repositories can participate in transactions by accepting a client.
Negative
- More Boilerplate: Constructors become longer with many dependencies.
- Composition Complexity: Must wire dependencies somewhere (composition root).
- No Runtime Type Checking: TypeScript types are erased at runtime.
Mitigation
For complex services with many dependencies, consider:
- Factory Functions: Encapsulate construction logic.
- Dependency Groups: Pass related dependencies as a single object.
- DI Containers: For very large applications, consider a DI library like
tsyringeorinversify.
Key Files
src/services/db/*.db.ts- Repository classes with constructor DIsrc/services/db/index.db.ts- Composition root for repositoriessrc/services/backgroundJobService.ts- Service class with constructor DIsrc/services/flyer/flyerProcessingService.ts- Complex service with multiple dependencies