Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions backend/src/di/__tests__/container.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/**
* DI container tests — Issue #485
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { DIContainer } from '../container.js';

let c: DIContainer;

beforeEach(() => {
c = DIContainer.createFresh();
});

describe('DIContainer – lifecycle: singleton', () => {
it('returns the same instance on every resolve', () => {
c.register('svc', () => ({ id: Math.random() }), 'singleton');
const a = c.get<{ id: number }>('svc');
const b = c.get<{ id: number }>('svc');
expect(a).toBe(b);
});
});

describe('DIContainer – lifecycle: transient', () => {
it('returns a new instance on every resolve', () => {
c.register('svc', () => ({ id: Math.random() }), 'transient');
const a = c.get<{ id: number }>('svc');
const b = c.get<{ id: number }>('svc');
expect(a).not.toBe(b);
});
});

describe('DIContainer – lifecycle: scoped', () => {
it('returns the same instance within a scope', () => {
c.register('svc', () => ({ id: Math.random() }), 'scoped');
const scope = c.createScope();
const a = c.get<{ id: number }>('svc', scope);
const b = c.get<{ id: number }>('svc', scope);
expect(a).toBe(b);
});

it('returns different instances across scopes', () => {
c.register('svc', () => ({ id: Math.random() }), 'scoped');
const s1 = c.createScope();
const s2 = c.createScope();
const a = c.get<{ id: number }>('svc', s1);
const b = c.get<{ id: number }>('svc', s2);
expect(a).not.toBe(b);
});
});

describe('DIContainer – set / has / get', () => {
it('set registers a pre-built instance', () => {
const mock = { value: 42 };
c.set('mock', mock);
expect(c.get('mock')).toBe(mock);
});

it('has returns true for registered token', () => {
c.register('x', () => 1);
expect(c.has('x')).toBe(true);
});

it('has returns false for unknown token', () => {
expect(c.has('unknown')).toBe(false);
});

it('throws on unregistered token', () => {
expect(() => c.get('missing')).toThrow('[DI] Token not registered: "missing"');
});
});

describe('DIContainer – mock injection', () => {
it('allows overriding a service with a mock', () => {
c.register('RealService', () => ({ greet: () => 'real' }), 'singleton');
// Override with mock
c.set('RealService', { greet: () => 'mock' });
const svc = c.get<{ greet(): string }>('RealService');
expect(svc.greet()).toBe('mock');
});
});

describe('DIContainer – dependency graph', () => {
it('resolves transitive dependencies', () => {
c.register('dep', () => ({ value: 10 }), 'singleton');
c.register('svc', (c) => ({ doubled: c.get<{ value: number }>('dep').value * 2 }), 'singleton');
expect(c.get<{ doubled: number }>('svc').doubled).toBe(20);
});
});

describe('DIContainer – validate', () => {
it('returns registered tokens on success', () => {
c.register('a', () => 1);
c.register('b', () => 2);
const tokens = c.validate();
expect(tokens).toContain('a');
expect(tokens).toContain('b');
});

it('throws on factory that errors', () => {
c.register('bad', () => { throw new Error('init fail'); }, 'transient');
expect(() => c.validate()).toThrow('init fail');
});
});

describe('DIContainer – reset', () => {
it('clears singleton cache so next get re-creates instance', () => {
let calls = 0;
c.register('counter', () => ({ n: ++calls }), 'singleton');
const a = c.get<{ n: number }>('counter');
c.reset();
const b = c.get<{ n: number }>('counter');
expect(a.n).toBe(1);
expect(b.n).toBe(2);
});
});

describe('DIContainer – performance', () => {
it('resolves singleton in under 1ms', () => {
c.register('fast', () => ({}), 'singleton');
c.get('fast'); // warm up
const start = performance.now();
c.get('fast');
expect(performance.now() - start).toBeLessThan(1);
});
});
118 changes: 88 additions & 30 deletions backend/src/di/container.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
/**
* container.ts — Issue #366
* DI Container — Issue #485
*
* Dependency injection container for managing service instances
* Prevents circular dependencies and enables testability
* Lightweight dependency injection container with:
* - Lifecycle management: singleton, transient, scoped
* - Per-domain module registration
* - Startup validation (detects missing registrations)
* - Mock injection for tests
* - <1ms resolution overhead
*/

import { ProjectRepository } from "../repositories/ProjectRepository.js";
Expand All @@ -16,11 +20,10 @@ import { CreditPaymentProvider } from "../services/payments/providers/credit.js"

export class DIContainer {
private static instance: DIContainer;
private services: Map<string, unknown> = new Map();
private registry = new Map<string, Registration>();

private constructor() {
this.registerServices();
}
// Private in production; exposed via createFresh() for test isolation
constructor() {}

static getInstance(): DIContainer {
if (!DIContainer.instance) {
Expand All @@ -29,14 +32,50 @@ export class DIContainer {
return DIContainer.instance;
}

private registerServices(): void {
// Repositories
const projectRepository = new ProjectRepository();
this.services.set("ProjectRepository", projectRepository);
/** Create an isolated container for testing — does NOT share singleton state. */
static createFresh(): DIContainer {
return new DIContainer();
}

/** Register a factory with a lifecycle. */
register<T>(
token: string,
factory: (c: DIContainer, scope?: Map<string, unknown>) => T,
lifecycle: Lifecycle = 'singleton'
): this {
this.registry.set(token, { factory, lifecycle });
return this;
}

/** Register a pre-built instance as a singleton. */
set(token: string, instance: unknown): this {
this.registry.set(token, {
factory: () => instance,
lifecycle: 'singleton',
singleton: instance,
});
return this;
}

/** Resolve a token. Optionally pass a scope Map for scoped lifecycles. */
get<T>(token: string, scope?: Map<string, unknown>): T {
const reg = this.registry.get(token);
if (!reg) throw new Error(`[DI] Token not registered: "${token}"`);

if (reg.lifecycle === 'singleton') {
if (reg.singleton === undefined) reg.singleton = reg.factory(this, scope);
return reg.singleton as T;
}

if (reg.lifecycle === 'scoped') {
const s = scope ?? new Map<string, unknown>();
if (!s.has(token)) s.set(token, reg.factory(this, s));
return s.get(token) as T;
}

// Services
const projectService = new ProjectService(projectRepository);
this.services.set("ProjectService", projectService);
// transient — new instance every time
return reg.factory(this, scope) as T;
}

// Controllers
const projectController = new ProjectController(projectService);
Expand All @@ -50,33 +89,52 @@ export class DIContainer {
this.services.set("PaymentProviderRegistry", providerRegistry);
}

get<T>(serviceName: string): T {
const service = this.services.get(serviceName);
if (!service) {
throw new Error(`Service not found: ${serviceName}`);
/**
* Validate all registrations at startup.
* Throws if any token's factory throws on a dry-run resolve.
* Returns list of registered tokens.
*/
validate(): string[] {
const tokens = Array.from(this.registry.keys());
for (const token of tokens) {
try {
this.get(token);
} catch (err) {
throw new Error(`[DI] Validation failed for token "${token}": ${(err as Error).message}`);
}
}
return service as T;
return tokens;
}

set(serviceName: string, service: unknown): void {
this.services.set(serviceName, service);
/** Create a child container that inherits registrations but has its own scope. */
createScope(): Map<string, unknown> {
return new Map();
}

has(serviceName: string): boolean {
return this.services.has(serviceName);
/** Reset singleton cache (useful in tests). */
reset(): void {
for (const reg of this.registry.values()) {
delete reg.singleton;
}
}

/** Clear all registrations (useful in tests). */
clear(): void {
this.registry.clear();
}

// Convenience getters
getProjectController(): ProjectController {
return this.get<ProjectController>("ProjectController");
// ── Convenience typed getters (backwards-compatible) ──────────────────────

getProjectController() {
return this.get<import('../controllers/ProjectController.js').ProjectController>('ProjectController');
}

getProjectService(): ProjectService {
return this.get<ProjectService>("ProjectService");
getProjectService() {
return this.get<import('../services/ProjectService.js').ProjectService>('ProjectService');
}

getProjectRepository(): ProjectRepository {
return this.get<ProjectRepository>("ProjectRepository");
getProjectRepository() {
return this.get<import('../repositories/ProjectRepository.js').ProjectRepository>('ProjectRepository');
}
}

Expand Down
28 changes: 28 additions & 0 deletions backend/src/di/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Bootstrap — loads all domain modules into the container and validates.
* Call once at application startup.
*
* Feature flag: DI_VALIDATION=false disables startup validation
* (useful during incremental migration).
*/
import { container } from './container.js';
import { registerProjectModule } from './modules/project.module.js';
import { registerAuthModule } from './modules/auth.module.js';
import { registerPaymentModule } from './modules/payment.module.js';

export function bootstrapDI(): void {
registerProjectModule(container);
registerAuthModule(container);
registerPaymentModule(container);

if (process.env.DI_VALIDATION !== 'false') {
try {
container.validate();
} catch (err) {
// Log but don't crash — allows partial migration
console.warn('[DI] Startup validation warning:', (err as Error).message);
}
}
}

export { container };
24 changes: 24 additions & 0 deletions backend/src/di/modules/auth.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Auth domain module — registers lockout manager and audit service.
*/
import type { DIContainer } from '../container.js';

export function registerAuthModule(c: DIContainer): void {
c.register(
'LockoutManager',
() => {
const { LockoutManager } = require('../../services/auth/lockout-manager.js');
return new LockoutManager();
},
'singleton'
);

c.register(
'AuditService',
() => {
const { auditService } = require('../../services/auditService.js');
return auditService;
},
'singleton'
);
}
33 changes: 33 additions & 0 deletions backend/src/di/modules/payment.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Payment domain module — registers analytics, verification, invoice, and categories services.
*/
import type { DIContainer } from '../container.js';

export function registerPaymentModule(c: DIContainer): void {
c.register(
'AnalyticsService',
() => {
const { analyticsService } = require('../../services/analytics.js');
return analyticsService;
},
'singleton'
);

c.register(
'VerificationService',
() => {
const { verificationService } = require('../../services/verification.js');
return verificationService;
},
'singleton'
);

c.register(
'InvoiceService',
() => {
const { invoiceService } = require('../../services/invoice.js');
return invoiceService;
},
'singleton'
);
}
Loading
Loading