Files
flyer-crawler.projectium.com/docs/adr/0039-dependency-injection-pattern.md

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:

  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:

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:

  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
  • ADR-002 - Transaction Management
  • ADR-034 - Repository Pattern Standards
  • ADR-035 - Service Layer Architecture