Loading content...
A common mistake when learning Dependency Injection is over-application. Developers who grasp DI's benefits sometimes conclude that more injection is always better. They create interfaces for every class, inject every collaborator, and build elaborate container configurations for simple applications.
This over-application misses a crucial insight: DI is a tool with appropriate and inappropriate uses. As with any tool, mastery includes knowing when not to use it. This page establishes the boundaries—the scope within which DI adds value and beyond which it adds complexity without benefit.
By the end of this page, you will understand where DI's boundaries lie, how to identify dependencies that don't need injection, and how to apply DI judiciously to maximize value while minimizing ceremony.
The most fundamental boundary in DI is between volatile and stable dependencies. This distinction, introduced in Mark Seemann's influential work on DI, provides the primary decision criterion for whether to inject.
Volatile Dependencies:
Volatile dependencies have characteristics that make substitution valuable:
Stable Dependencies:
Stable dependencies lack these characteristics:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// ═══════════════════════════════════════════// VOLATILE DEPENDENCIES: Should be injected// ═══════════════════════════════════════════ // Database connections - different in prod/dev/testclass UserRepository { constructor(private readonly db: IDatabaseConnection) {} // ✅ Inject} // External APIs - may be mocked, may have multiple providersclass PaymentService { constructor(private readonly gateway: IPaymentGateway) {} // ✅ Inject} // File system - side effects, environment-dependent pathsclass ConfigLoader { constructor(private readonly fs: IFileSystem) {} // ✅ Inject} // Current time - needs control for testing temporal logicclass SessionManager { constructor(private readonly clock: IClock) {} // ✅ Inject} // Random number generation - needs determinism in testsclass IdGenerator { constructor(private readonly rng: IRandomSource) {} // ✅ Inject} // Logging - production vs development behavior differsclass OrderProcessor { constructor(private readonly logger: ILogger) {} // ✅ Inject} // ═══════════════════════════════════════════// STABLE DEPENDENCIES: Often should NOT be injected// ═══════════════════════════════════════════ // Standard library math - never changes, no alternativesclass Calculator { calculate(a: number, b: number): number { return Math.sqrt(a * a + b * b); // ✅ Direct use, no injection }} // JSON serialization - stable, deterministic, fastclass Serializer { serialize(obj: unknown): string { return JSON.stringify(obj); // ✅ Direct use }} // Array operations - stable, no side effectsclass DataProcessor { process(items: number[]): number { return items.reduce((a, b) => a + b, 0); // ✅ Direct use }} // String utilities - stable, deterministicclass Formatter { format(value: string): string { return value.trim().toLowerCase(); // ✅ Direct use }} // Type conversions - language primitivesclass Parser { parse(str: string): number { return parseInt(str, 10); // ✅ Direct use }} // Cryptographic hashing (if deterministic algorithm is stable)class HashGenerator { hash(data: string): string { // Using stable, well-tested crypto library directly return crypto.createHash('sha256').update(data).digest('hex'); // Judgment call }}Ask yourself: 'Would I ever want to substitute a different implementation of this dependency?' If the answer is 'no' or 'I can't imagine when,' it's likely a stable dependency that doesn't need injection.
DI requires somewhere to assemble the object graph—to create implementations and wire them together. This location is called the Composition Root. Understanding this boundary is crucial for applying DI correctly.
Definition:
The Composition Root is the single location in an application where the object graph is composed. It's as close to the application's entry point as possible and is the only place where concrete implementations are instantiated and wired together.
The Boundary:
new for injectable dependencies12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
// ═══════════════════════════════════════════// composition-root.ts - THE ONLY PLACE for 'new' and wiring// ═══════════════════════════════════════════ import { StripePaymentGateway } from './infrastructure/stripe';import { PostgresUserRepository } from './infrastructure/postgres';import { SendGridEmailService } from './infrastructure/sendgrid';import { ConsoleLogger } from './infrastructure/logging';import { OrderService } from './services/order-service';import { NotificationService } from './services/notification-service';import { Application } from './application'; export function createApplication(): Application { // ═══════════════════════════════════════════ // This is the COMPOSITION ROOT // All 'new' statements for injectables live here // ═══════════════════════════════════════════ // Infrastructure layer - concrete implementations const logger = new ConsoleLogger(process.env.LOG_LEVEL); const paymentGateway = new StripePaymentGateway( process.env.STRIPE_API_KEY!, logger ); const userRepository = new PostgresUserRepository( process.env.DATABASE_URL!, logger ); const emailService = new SendGridEmailService( process.env.SENDGRID_API_KEY!, logger ); // Service layer - receives injected dependencies const notificationService = new NotificationService( emailService, logger ); const orderService = new OrderService( paymentGateway, userRepository, notificationService, logger ); // Application - the fully composed root object return new Application(orderService, userRepository, logger);} // ═══════════════════════════════════════════// main.ts - Entry point, just calls composition root// ═══════════════════════════════════════════import { createApplication } from './composition-root'; const app = createApplication();app.start(); // ═══════════════════════════════════════════// order-service.ts - INSIDE application core// NO 'new' for injectable dependencies// ═══════════════════════════════════════════export class OrderService { constructor( private readonly paymentGateway: IPaymentGateway, // Abstraction only private readonly userRepository: IUserRepository, // Abstraction only private readonly notificationService: INotificationService, // Abstraction only private readonly logger: ILogger // Abstraction only ) {} async processOrder(order: Order): Promise<OrderResult> { // Notice: NO 'new' statements here // All dependencies come from constructor this.logger.info('Processing order', { orderId: order.id }); const user = await this.userRepository.findById(order.userId); const payment = await this.paymentGateway.charge(order.total, user.paymentMethod); if (payment.success) { await this.notificationService.sendOrderConfirmation(user, order); } return { success: payment.success, transactionId: payment.transactionId }; }}Why a Single Composition Root?
new SomeService() appears elsewhere, it's a code smellIf you find 'new ConcreteService()' calls scattered through your business logic, something is wrong. Either the dependency should be injected, or it's a value object/stable dependency that doesn't need DI. 'new' in business logic (outside composition root) is a red flag.
Not everything should be injected. Value objects and entities are typically created within business logic, not injected through DI. Understanding this exception prevents over-application of DI patterns.
Why Value Objects Aren't Injected:
Value objects are small, immutable objects that represent concepts like Money, Email, DateRange, or Coordinates. They're created constantly during business operations—often dozens or hundreds per request. Injecting them would be absurd:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// ═══════════════════════════════════════════// VALUE OBJECTS: Create with 'new', never inject// ═══════════════════════════════════════════ // Money: immutable value representing currency amountclass Money { private constructor( public readonly amount: number, public readonly currency: string ) { if (amount < 0) throw new Error('Money cannot be negative'); } static usd(amount: number): Money { return new Money(amount, 'USD'); } static eur(amount: number): Money { return new Money(amount, 'EUR'); } add(other: Money): Money { if (this.currency !== other.currency) { throw new Error('Cannot add different currencies'); } return new Money(this.amount + other.amount, this.currency); } equals(other: Money): boolean { return this.amount === other.amount && this.currency === other.currency; }} // Email: immutable value with validationclass Email { private constructor(public readonly value: string) {} static create(value: string): Email { if (!this.isValid(value)) { throw new Error(`Invalid email: ${value}`); } return new Email(value.toLowerCase()); } private static isValid(value: string): boolean { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); }} // DateRange: immutable value representing time spanclass DateRange { private constructor( public readonly start: Date, public readonly end: Date ) { if (start > end) throw new Error('Start must be before end'); } static create(start: Date, end: Date): DateRange { return new DateRange(start, end); } contains(date: Date): boolean { return date >= this.start && date <= this.end; } overlaps(other: DateRange): boolean { return this.start <= other.end && this.end >= other.start; }} // ═══════════════════════════════════════════// USAGE: Value objects created in business logic// ═══════════════════════════════════════════class OrderService { constructor( private readonly paymentGateway: IPaymentGateway, // Injected: volatile private readonly taxCalculator: ITaxCalculator // Injected: volatile ) {} async processOrder(items: OrderItem[]): Promise<OrderResult> { // Value objects created directly - NOT injected const subtotal = items.reduce( (sum, item) => sum.add(Money.usd(item.price * item.quantity)), Money.usd(0) ); const tax = await this.taxCalculator.calculate(subtotal); // Injected service const total = subtotal.add(tax); // Value object created via method const customerEmail = Email.create(items[0].customerEmail); // Value object const result = await this.paymentGateway.charge(total); // Injected service return { success: result.success, total, email: customerEmail }; }}Entities: Also Not Injected
Entities (domain objects with identity like User, Order, Product) are created through factories or repository operations, not DI:
Cross-cutting concerns like logging, caching, and metrics present an interesting boundary case. These concerns are pervasive—nearly every service might need a logger. But injecting them everywhere can create constructor bloat.
The Tension:
ILogger into every single class is tediousApproaches to Managing This:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// ═══════════════════════════════════════════// APPROACH 1: Full Injection (Pure, but verbose)// ═══════════════════════════════════════════class OrderService { constructor( private readonly paymentGateway: IPaymentGateway, private readonly repository: IOrderRepository, private readonly logger: ILogger, // Every service gets logger private readonly metrics: IMetrics, // Every service gets metrics private readonly cache: ICacheService // Every service gets cache ) {} async process(order: Order): Promise<Result> { this.logger.info('Processing order'); this.metrics.increment('orders.processed'); // ... }} // Downside: Constructors become unwieldy// Upside: Completely testable, explicit dependencies // ═══════════════════════════════════════════// APPROACH 2: Decorator Pattern for Cross-Cutting// ═══════════════════════════════════════════// Keep business logic clean; add concerns via decoration class OrderService { constructor( private readonly paymentGateway: IPaymentGateway, private readonly repository: IOrderRepository // No logger, metrics, cache in core service ) {} async process(order: Order): Promise<Result> { // Pure business logic, no cross-cutting concerns const payment = await this.paymentGateway.charge(order.total); await this.repository.save(order); return { success: payment.success }; }} // Logging decoratorclass LoggingOrderService implements IOrderService { constructor( private readonly inner: IOrderService, private readonly logger: ILogger ) {} async process(order: Order): Promise<Result> { this.logger.info('Processing order', { orderId: order.id }); const result = await this.inner.process(order); this.logger.info('Order processed', { success: result.success }); return result; }} // Metrics decoratorclass MetricsOrderService implements IOrderService { constructor( private readonly inner: IOrderService, private readonly metrics: IMetrics ) {} async process(order: Order): Promise<Result> { const start = Date.now(); const result = await this.inner.process(order); this.metrics.timing('order.process.duration', Date.now() - start); this.metrics.increment(result.success ? 'order.success' : 'order.failure'); return result; }} // Composition: Stack decoratorsconst orderService = new MetricsOrderService( new LoggingOrderService( new OrderService(paymentGateway, repository), logger ), metrics); // ═══════════════════════════════════════════// APPROACH 3: Ambient Context (Pragmatic compromise)// ═══════════════════════════════════════════// For truly ubiquitous concerns, sometimes ambient access is acceptable class LoggerContext { private static instance: ILogger = new NullLogger(); static configure(logger: ILogger): void { this.instance = logger; } static get current(): ILogger { return this.instance; }} class OrderService { constructor( private readonly paymentGateway: IPaymentGateway, private readonly repository: IOrderRepository ) {} async process(order: Order): Promise<Result> { // Access logger via context (less pure, but pragmatic) LoggerContext.current.info('Processing order'); // ... }} // In composition root:LoggerContext.configure(new ConsoleLogger()); // In tests:beforeEach(() => LoggerContext.configure(new MockLogger())); // Trade-off: Less explicit dependency, but cleaner constructors// Acceptable for truly universal concerns like loggingPurists prefer full injection. Pragmatists accept that injecting a logger into every single class creates noise without proportional benefit. The decorator approach is the best of both worlds for cross-cutting concerns. Choose based on team preference and codebase size.
Application size and complexity should influence DI adoption. Full DI with IoC containers makes sense for complex enterprise applications. It's overhead for simple scripts or small utilities.
The Spectrum:
| Application Type | Recommended DI Approach | Rationale |
|---|---|---|
| Scripts/CLI Tools (<500 LOC) | None or minimal | Overhead exceeds benefit; direct dependencies fine |
| Small Utilities (500-2000 LOC) | Manual DI, no container | Some injection for testability; containers overkill |
| Medium Applications (2000-20000 LOC) | Manual DI or simple container | DI adds value; complexity justifies structure |
| Large Applications (20000+ LOC) | Full DI with IoC container | Container benefits offset configuration cost |
| Microservices | Full DI with container | Per-service isolation benefits from DI |
| Libraries (published packages) | Minimal; let consumers inject | Don't force DI framework on consumers |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
// ═══════════════════════════════════════════// SMALL SCRIPT: No DI needed// ═══════════════════════════════════════════// A 50-line script to process CSV files// DI would be absurd here import { readFileSync, writeFileSync } from 'fs'; const input = readFileSync(process.argv[2], 'utf-8');const processed = input.split('\n') .map(line => line.toUpperCase()) .join('\n');writeFileSync(process.argv[3], processed); // ═══════════════════════════════════════════// SMALL UTILITY: Minimal DI for testing// ═══════════════════════════════════════════// A 1000-line CLI tool with a few core services// Manual DI, no container interface IHttpClient { get(url: string): Promise<Response>;} class ApiChecker { constructor(private readonly http: IHttpClient) {} async checkEndpoints(urls: string[]): Promise<CheckResult[]> { return Promise.all(urls.map(url => this.checkOne(url))); } private async checkOne(url: string): Promise<CheckResult> { try { const response = await this.http.get(url); return { url, status: response.status, ok: response.ok }; } catch (error) { return { url, status: 0, ok: false, error: String(error) }; } }} // main.ts - simple manual compositionconst checker = new ApiChecker(new FetchHttpClient());checker.checkEndpoints(urls).then(console.log); // ═══════════════════════════════════════════// LARGE APPLICATION: Full DI with Container// ═══════════════════════════════════════════// An e-commerce platform with 50+ services// Container pays for itself // container.tsimport { Container } from 'inversify'; const container = new Container(); // Register infrastructurecontainer.bind<IDatabaseConnection>(TYPES.Database) .to(PostgresConnection).inSingletonScope();container.bind<IPaymentGateway>(TYPES.PaymentGateway) .to(StripeGateway).inSingletonScope();container.bind<IEmailService>(TYPES.EmailService) .to(SendGridService).inSingletonScope(); // Register services with automatic injectioncontainer.bind<IOrderService>(TYPES.OrderService) .to(OrderService).inRequestScope();container.bind<IUserService>(TYPES.UserService) .to(UserService).inRequestScope();container.bind<IInventoryService>(TYPES.InventoryService) .to(InventoryService).inRequestScope(); // 50 more bindings... // Usage: Container resolves entire dependency treeconst orderService = container.get<IOrderService>(TYPES.OrderService);// OrderService gets PaymentGateway, Repository, Notifications automaticallyBegin with manual DI even in larger applications. Introduce a container when manual composition becomes unwieldy (typically 20+ services with complex dependency graphs). Container migration is straightforward; premature container adoption creates unnecessary complexity.
Third-party libraries present a special boundary consideration. Should you wrap external libraries in interfaces? Should you inject them? The answer depends on the library's volatility and your isolation needs.
The Adapter Pattern for External Libraries:
Instead of depending directly on a third-party library, create an adapter that implements your interface. This boundary provides:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
// ═══════════════════════════════════════════// VOLATILE LIBRARY: Wrap with adapter// ═══════════════════════════════════════════// Payment libraries change, companies switch providers // Your interface (you own this)interface IPaymentGateway { charge(amount: Money, card: CardDetails): Promise<PaymentResult>; refund(transactionId: string): Promise<RefundResult>;} // Stripe adapter (thin wrapper over Stripe SDK)class StripeAdapter implements IPaymentGateway { constructor(private readonly stripe: Stripe) {} async charge(amount: Money, card: CardDetails): Promise<PaymentResult> { // Translate between your domain and Stripe's API const stripeCharge = await this.stripe.charges.create({ amount: amount.cents, currency: amount.currency.toLowerCase(), source: card.token }); return { success: stripeCharge.status === 'succeeded', transactionId: stripeCharge.id }; } async refund(transactionId: string): Promise<RefundResult> { const refund = await this.stripe.refunds.create({ charge: transactionId }); return { success: refund.status === 'succeeded' }; }} // If you switch to Square:class SquareAdapter implements IPaymentGateway { constructor(private readonly square: SquareClient) {} async charge(amount: Money, card: CardDetails): Promise<PaymentResult> { const payment = await this.square.paymentsApi.createPayment({ sourceId: card.token, amountMoney: { amount: BigInt(amount.cents), currency: amount.currency }, idempotencyKey: generateIdempotencyKey() }); return { success: payment.result.payment?.status === 'COMPLETED', transactionId: payment.result.payment?.id || '' }; } // ...} // ═══════════════════════════════════════════// STABLE LIBRARY: Direct use is fine// ═══════════════════════════════════════════// Well-established libraries with stable APIs import { v4 as uuidv4 } from 'uuid'; class OrderService { createOrder(): Order { // UUID library is stable, unlikely to change // Wrapping it adds no value return { id: uuidv4(), // Direct use createdAt: new Date() }; }} // ═══════════════════════════════════════════// SEMI-VOLATILE: Judgment call// ═══════════════════════════════════════════// HTTP clients, ORMs, date libraries // Option 1: Direct use (simpler, some risk)import axios from 'axios';const response = await axios.get(url); // Option 2: Wrap in interface (safer, more work)interface IHttpClient { get<T>(url: string): Promise<HttpResponse<T>>;} class AxiosHttpClient implements IHttpClient { async get<T>(url: string): Promise<HttpResponse<T>> { const response = await axios.get<T>(url); return { status: response.status, data: response.data }; }} // Decision factors:// - How likely is the library to change?// - How pervasive is its use in your codebase?// - How important is testability without the real library?Testing introduces its own DI boundaries. Not everything mocked in tests represents a proper DI boundary. Some mocking is pragmatic; some is a sign of over-engineering.
What Should Be Mockable (DI Boundaries):
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// ═══════════════════════════════════════════// APPROPRIATE: Mock external dependencies// ═══════════════════════════════════════════describe('OrderService', () => { it('should process order successfully', async () => { // Mock: External payment gateway (correct) const mockPayment: IPaymentGateway = { charge: jest.fn().mockResolvedValue({ success: true }) }; // Mock: External notification service (correct) const mockNotification: INotificationService = { send: jest.fn().mockResolvedValue(undefined) }; const service = new OrderService(mockPayment, mockNotification); const result = await service.process(testOrder); expect(result.success).toBe(true); });}); // ═══════════════════════════════════════════// OVER-MOCKING: Mocking value objects// ═══════════════════════════════════════════describe('PriceCalculator', () => { it('should calculate total - OVER-MOCKED', () => { // DON'T mock value objects const mockMoney = { add: jest.fn().mockReturnValue(new Money(150)) }; // This test is testing the mock, not the calculator }); it('should calculate total - CORRECT', () => { // Use real value objects const price1 = Money.usd(100); const price2 = Money.usd(50); const calculator = new PriceCalculator(); const total = calculator.sum([price1, price2]); expect(total.equals(Money.usd(150))).toBe(true); });}); // ═══════════════════════════════════════════// OVER-MOCKING: Mocking internal collaborators// ═══════════════════════════════════════════// If OrderService uses PriceCalculator internally, don't mock it: class OrderService { private priceCalculator = new PriceCalculator(); // Internal, stable constructor( private readonly payment: IPaymentGateway // External, mock this ) {}} describe('OrderService', () => { it('WRONG: mocking internal collaborator', () => { const mockCalculator = { sum: jest.fn() }; // Don't do this // ... }); it('CORRECT: only mock external dependencies', () => { const mockPayment = { charge: jest.fn().mockResolvedValue({ success: true }) }; // PriceCalculator is internal, deterministic, fast - use the real one });});If you feel compelled to mock everything, your design may have too many boundaries or too little cohesion. Well-designed systems have clear external boundaries (mock those) and cohesive internal modules (test those together).
DI is a powerful tool, but like all tools, it has appropriate and inappropriate uses. Mastery includes knowing where to stop.
Key Boundaries:
You've completed Module 1: Dependency Injection Revisited. You now have a comprehensive understanding of DI foundations, the technique/principle duality, benefits, and appropriate boundaries. The next modules explore specific injection patterns in depth.