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:
| Approach | DI mechanism | Test ergonomics |
|---|---|---|
| Classes + constructor | new UserService(repo) — inject via new | Fine, but class boilerplate; this binding bugs |
| DI container | InversifyJS / tsyringe / awilix | Powerful but heavy; requires decorators or setup |
| Module-level singletons | Shared import at module scope | Simple, but impossible to swap in tests |
| Factory functions | createUserService(repo) returns an object | No 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 interfacemockRefreshTokenRepository— in-memory refresh token storemockUsers— 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 ConcreteImpltype trick is slightly unusual — new contributors need a brief explanation - No lifecycle management: if a dependency needs
init()/teardown(), that must be handled elsewhere (seebackend/server.tsforinitTelemetry()/initMail()) - Large services with many methods can produce verbose factory return objects; the pattern scales well but can feel wordy compared to
classsyntax