ADR-003: Factory Function Pattern for Dependency Injection

Status: Accepted

Context

The backend layers (Controller → Service → Repository) need a way to be unit-tested without hitting a real database and without mocking module imports. The main options were:

ApproachDI mechanismTest ergonomics
Classes + constructornew UserService(repo) — inject via newFine, but class boilerplate; this binding bugs
DI containerInversifyJS / tsyringe / awilixPowerful but heavy; requires decorators or setup
Module-level singletonsShared import at module scopeSimple, but impossible to swap in tests
Factory functionscreateUserService(repo) returns an objectNo class, no container, testable by default

The project deliberately avoids frameworks (no NestJS, no Express), so a lightweight in-language pattern was preferred.

Decision

Use factory functions for all controllers and services. Each factory accepts its dependencies as parameters and returns a plain object of methods. The wired-up production singleton is exported at the bottom of the file.

// Service — injectable
export const createUserService = (repo: typeof UserRepositoryType) => ({
  async getAllUsers(): Promise<User[]> {
    const users = await repo.getAll();
    return users.map(({ password: _p, ...safe }) => safe);
  },
});
 
// Production singleton — wired to the real repository
export const userService = createUserService(userRepository);
// Controller — injectable
export const createUserController = (service: typeof UserServiceType) => ({
  async getUsers(): Promise<Response> {
    return successResponse(await service.getAllUsers());
  },
});
 
// Production singleton
export const userController = createUserController(userService);
// Unit test — injected with an in-memory mock
import { mockUserRepository } from '@backend/utils/test';
 
const svc = createUserService(mockUserRepository);
const ctrl = createUserController(svc);
 
test('returns 200 with users', async () => {
  const res = await ctrl.getUsers();
  expect(res.status).toBe(200);
});

Shared test mocks live in backend/utils/test/:

  • mockUserRepository — in-memory implementation of the user repository interface
  • mockRefreshTokenRepository — in-memory refresh token store
  • mockUsers — seeded test user objects (with hashed passwords)

Consequences

Positive

  • Every service and controller is testable with zero module mocking — inject a mock, call the method, assert on the Response
  • No class syntax, no this, no decorator metadata — plain TypeScript objects
  • No DI container to configure or maintain
  • Type safety is structural: the factory parameter type is inferred directly from the concrete implementation (typeof userRepository), so TypeScript catches interface drift at compile time

Negative / Risks

  • The typeof ConcreteImpl type trick is slightly unusual — new contributors need a brief explanation
  • No lifecycle management: if a dependency needs init() / teardown(), that must be handled elsewhere (see backend/server.ts for initTelemetry() / initMail())
  • Large services with many methods can produce verbose factory return objects; the pattern scales well but can feel wordy compared to class syntax