# 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: 1. **Hard-to-Test Code**: Components that instantiate their own dependencies cannot be easily unit tested with mocks. 2. **Rigid Architecture**: Changing one implementation requires modifying all consumers. 3. **Hidden Dependencies**: It's unclear what a component needs to function. 4. **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: 1. **Explicit Dependencies**: All dependencies are declared in the constructor. 2. **Default Values**: Production dependencies have sensible defaults. 3. **Testability**: Test code can inject mocks without modifying source code. 4. **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`). - **Composition Root**: Wire dependencies at the application entry point. ## Implementation Details ### Repository Pattern with DI Located in `src/services/db/flyer.db.ts`: ```typescript 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; constructor(db: Pick = getPool()) { this.db = db; } async getFlyerById(flyerId: number, logger: Logger): Promise { const result = await this.db.query('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 { // Implementation } } ``` **Usage in Production**: ```typescript // Uses default pool const flyerRepo = new FlyerRepository(); ``` **Usage in Tests**: ```typescript const mockDb = { query: vi.fn().mockResolvedValue({ rows: [mockFlyer] }), }; const flyerRepo = new FlyerRepository(mockDb); ``` **Usage in Transactions**: ```typescript 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`: ```typescript export class BackgroundJobService { constructor( private personalizationRepo: PersonalizationRepository, private notificationRepo: NotificationRepository, private emailQueue: Queue, private logger: Logger, ) {} async runDailyDealCheck(): Promise { 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**: ```typescript 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`: ```typescript export class FlyerProcessingService { constructor( private fileHandler: FlyerFileHandler, private aiProcessor: FlyerAiProcessor, private fsAdapter: FileSystemAdapter, private cleanupQueue: Queue, private dataTransformer: FlyerDataTransformer, private persistenceService: FlyerPersistenceService, ) {} async processFlyer(filePath: string, logger: Logger): Promise { // 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: ```typescript // Bad - depends on full Pool constructor(pool: Pool) {} // Good - depends only on what's needed constructor(db: Pick) {} ``` 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: ```typescript // 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: 1. **Factory Functions**: Encapsulate construction logic. 2. **Dependency Groups**: Pass related dependencies as a single object. 3. **DI Containers**: For very large applications, consider a DI library like `tsyringe` or `inversify`. ## Key Files - `src/services/db/*.db.ts` - Repository classes with constructor DI - `src/services/db/index.db.ts` - Composition root for repositories - `src/services/backgroundJobService.ts` - Service class with constructor DI - `src/services/flyer/flyerProcessingService.ts` - Complex service with multiple dependencies ## Related ADRs - [ADR-002](./0002-standardized-transaction-management.md) - Transaction Management - [ADR-034](./0034-repository-pattern-standards.md) - Repository Pattern Standards - [ADR-035](./0035-service-layer-architecture.md) - Service Layer Architecture