279 lines
8.4 KiB
Markdown
279 lines
8.4 KiB
Markdown
# 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<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`:
|
|
|
|
```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<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**:
|
|
|
|
```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<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**:
|
|
|
|
```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<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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```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
|