| name | dependency-injection-patterns |
| description | Dependency injection best practices - constructor injection, composition root, lifetime management, testability, circular dependency prevention, framework independence |
| keywords | dependency injection, DI, constructor injection, composition root, service locator, singleton, scoped, transient, lifetime, circular dependency, mock injection, vi.mock, testability, IoC container, tsyringe, NestJS, InversifyJS, framework independence |
| related | monorepo-architecture, testing-patterns, error-handling-patterns, safe-refactoring, domain-driven-design |
Dependency Injection Patterns
Rules for wiring dependencies that are testable, visible, and proportional to codebase size.
Research: docs/deepresearch/reports/Dependency Injection Patterns for TypeScript.md
Non-Negotiable Rules
| # | Rule | Violation Example | Correct Pattern |
|---|
| 1 | Constructor injection only | private db = new Database() inside class | constructor(private readonly db: Database) |
| 2 | No service locators | ServiceLocator.get<Database>('database') | Explicit constructor parameter |
| 3 | Single composition root | new X(new Y(new Z())) in controllers | All wiring in main.ts or container.ts |
| 4 | Explicit lifetimes | New DB pool per request | Singleton for pools; scoped for request context |
| 5 | No circular dependencies | A -> B -> A | Extract interface; use layered architecture (domain <- app <- infra) |
| 6 | DI only at I/O boundaries | @Injectable() on pure utility function | Pure functions stay plain functions |
| 7 | No framework decorators in domain | @Injectable() in domain/user.service.ts | Domain depends on interfaces only; decorators in infra layer |
| 8 | Mock via constructor, not vi.mock | vi.mock('../database') for unit tests | new UserService(mockDb, mockEmail) |
| 9 | Scale container to codebase | InversifyJS + reflection for 5 classes | Manual wiring for <15 classes; lightweight container for medium |
| 10 | Rule of Three for abstractions | Interface with 1 implementation | Duplicate first; abstract on 3rd occurrence |
Pattern 1: Constructor Injection
class UserService {
private db = new Database();
private email = new EmailService();
}
class UserService {
register(user: User) {
const db = ServiceLocator.get<Database>('database');
}
}
class UserService {
constructor(
private readonly db: Database,
private readonly email: EmailService,
) {}
async register(user: User) { }
}
Why it matters: Constructor injection makes every dependency visible in the type signature. Callers know exactly what is needed. Tests pass mocks directly without module-level patching.
Pattern 2: Composition Root
const svc = new UserService(new UserRepo(new PostgresPool()), new SendGridEmail());
const pool = createPool(config.database);
const userRepo = new UserRepoImpl(pool);
const emailService = new SendGridEmailService(config.email);
const userService = new UserService(userRepo, emailService);
app.route('/users', createUserRoutes(userService));
Rule: new appears only in the composition root (or inside factories called from it). No new ServiceX() in controllers, route handlers, or workers.
Pattern 3: Scaling the DI Tool
| Codebase Size | Strategy | Example |
|---|
| Small (<15 classes) | Manual factories in composition root | Direct new wiring in main.ts |
| Medium (15-50 classes) | Lightweight container (tsyringe, typed-inject) | container.resolve(UserService) |
| Large (50+ classes) | Full IoC (NestJS modules, InversifyJS) | @Module({ providers: [...] }) |
Rule: Never introduce reflection-heavy decorators unless the project already has >30 injectable classes and needs request scoping or module boundaries.
Pattern 4: Lifetime Management
| Lifetime | Use For | Example |
|---|
| Singleton | Shared stateless/pooled resources | DB pool, config, stateless services |
| Scoped/Request | Per-request state | Auth context, trace ID, transaction |
| Transient | Lightweight stateless helpers | Formatters, validators |
app.get('/users', async (c) => {
const pool = new Pool(config);
const users = await pool.query('SELECT ...');
});
const requestContext = { userId: '' };
const pool = createPool(config);
app.get('/users', async (c) => {
const ctx = { userId: c.get('userId'), traceId: c.get('traceId') };
const users = await userService.list(pool, ctx);
});
Rule: Always declare lifetime explicitly. Default to singleton only for truly stateless/shared resources.
Pattern 5: Testability via Injection
vi.mock('../database');
const svc = new UserService();
const mockDb = { query: vi.fn().mockResolvedValue([]) };
const mockEmail = { send: vi.fn().mockResolvedValue(undefined) };
const svc = new UserService(mockDb, mockEmail);
test('register sends welcome email', async () => {
await svc.register({ name: 'Alice', email: 'a@b.com' });
expect(mockEmail.send).toHaveBeenCalledWith(
expect.objectContaining({ to: 'a@b.com' }),
);
});
When vi.mock() is acceptable: Only for third-party libraries that cannot be injected (e.g., some AWS SDK v3 clients, native Node modules). Never for your own business logic.
Pattern 6: Circular Dependency Prevention
import { OrderService } from './order.service';
import { UserService } from './user.service';
export interface UserQueries {
findById(id: string): Promise<User>;
}
class OrderService {
constructor(private readonly userQueries: UserQueries) {}
}
class UserService {
constructor(private readonly events: EventEmitter) {}
async register(user: User) {
this.events.emit('user:registered', { userId: user.id });
}
}
Detection: Run madge --circular src in CI. Any cycle is a build-blocking error.
Resolution strategies (in preference order):
- Extract shared interface to inner layer
- Use event bus / mediator for cross-cutting communication
forwardRef / ModuleRef only as absolute last resort
Pattern 7: DI Boundaries
@Injectable()
class MathUtils {
add(a: number, b: number) { return a + b; }
}
export function calculateTotal(items: LineItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
class OrderService {
constructor(
private readonly repo: OrderRepository,
private readonly payment: PaymentGateway,
private readonly notifier: NotificationService,
) {}
async checkout(cart: Cart): Promise<Order> {
const total = calculateTotal(cart.items);
}
}
Heuristic: If it has no I/O, no side effects, and no state, it is a plain function. Never wrap it in a class for DI.
Pattern 8: Framework Independence
import { Injectable } from '@nestjs/common';
@Injectable()
export class UserService {
constructor(@Inject('USER_REPO') private repo: IUserRepo) {}
}
export class UserService {
constructor(private readonly repo: IUserRepo) {}
}
@Module({
providers: [
UserService,
{ provide: 'IUserRepo', useClass: PostgresUserRepo },
],
})
export class UserModule {}
Rule: Core domain code must never import framework DI decorators (@Injectable, @Inject, @Singleton). Business logic stays portable. Framework concerns belong exclusively in the composition root and infrastructure layer.
Common Anti-Patterns Checklist
Before submitting code, verify:
See Also
- [[monorepo-architecture]] - Module boundaries, dependency direction, layered architecture
- [[testing-patterns]] - Test isolation, mock patterns,
vi.mock() vs constructor injection
- [[error-handling-patterns]] - Error handling across service boundaries
- [[domain-driven-design]] - Bounded contexts, aggregates (DI aligns with DDD layers)
- [[safe-refactoring]] - Extracting interfaces, Strangler Fig for migrating to DI