Loading content...
You now understand the major dependency injection anti-patterns: constructor over-injection, circular dependencies, and captive dependencies. But knowledge without application is incomplete. This page consolidates everything into an actionable best practices checklistβa practical reference you can apply to code reviews, new project setup, and architectural decisions.
Think of this checklist as a pre-flight checklist for DI. Pilots don't wing it; they systematically verify everything before takeoff. Similarly, these practices should become habitual verification points in your development workflow.
By the end of this page, you will have a comprehensive checklist covering constructor design, interface design, lifetime management, container configuration, testing, and code review criteria. Each practice is actionable, verifiable, and directly applicable to production code.
The constructor is the primary injection point and the first place to look for DI health. These practices ensure constructors remain clean and maintainable:
this.service = service ?? throw new ArgumentNullException(nameof(service))12345678910111213141516171819202122232425262728293031
// β
Ideal constructor following all best practices class OrderService { // Private readonly fields - immutable after construction private readonly orderRepository: IOrderRepository; private readonly pricingService: IPricingService; private readonly eventPublisher: IEventPublisher; constructor( // Abstractions, not concretions orderRepository: IOrderRepository, pricingService: IPricingService, eventPublisher: IEventPublisher ) { // Guard clauses - fail fast with clear messages if (!orderRepository) throw new Error('orderRepository is required'); if (!pricingService) throw new Error('pricingService is required'); if (!eventPublisher) throw new Error('eventPublisher is required'); // ONLY assignment - no logic, no calls, no initialization this.orderRepository = orderRepository; this.pricingService = pricingService; this.eventPublisher = eventPublisher; // β WRONG: Don't do any of these in constructor: // this.warmupCache(); // Side effect // await this.loadConfig(); // Async work // this.repository.validate(); // External call // console.log('Constructed'); // Side effect }}The best constructor is the most boring constructor. It should be so simple that reviewing it takes seconds. Any complexity in the constructor is either misplaced logic or a design smell waiting to cause problems.
Well-designed interfaces are the foundation of effective dependency injection. These practices ensure interfaces serve their purpose as abstraction points:
IEmailSender not ISmtpClient; IPaymentProcessor not IStripeGateway.IRequestScopedContext vs ISingletonCache.1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// β
GOOD: Focused, consumer-oriented interfaces /** * Sends notifications to users through the configured channel. * Thread-safe. Idempotent when given same notificationId. * @throws NotificationFailedException if all retry attempts exhausted */interface INotificationSender { send(notification: Notification): Promise<void>; scheduleForLater(notification: Notification, sendAt: Date): Promise<string>;} /** * Provides access to the current user's context within a request. * SCOPED: New instance per HTTP request. Do not inject into singletons. */interface IRequestScopedUserContext { readonly userId: string; readonly tenantId: string; readonly permissions: ReadonlyArray<string>; hasPermission(permission: string): boolean;} /** * Thread-safe cache for read-heavy, write-light data. * SINGLETON: Single instance across application lifetime. */interface ISingletonCache<K, V> { get(key: K): V | undefined; set(key: K, value: V, options?: CacheOptions): void; invalidate(key: K): void;} // β BAD: Leaky, implementation-coupled interfaces interface IUserRepository { // Exposes SQL - leaky abstraction executeQuery(sql: string): Promise<any[]>; // Exposes MongoDB - implementation detail getMongoCollection(): MongoCollection; // Too many methods - God interface findById(id: string): Promise<User>; findByEmail(email: string): Promise<User>; findByPhone(phone: string): Promise<User>; search(criteria: SearchCriteria): Promise<User[]>; save(user: User): Promise<void>; delete(id: string): Promise<void>; bulkImport(users: User[]): Promise<void>; exportToCSV(): Promise<string>; // ... 15 more methods}Correct lifetime management prevents captive dependencies and ensures resources are properly scoped and disposed:
Func<T> or IServiceScopeFactory, not the scoped service directly.ValidateScopes or equivalent to catch captive dependencies at runtime.HttpContext.Current or thread-local storage is implicit global state. Pass context explicitly.| Service Type | Recommended Lifetime | Reasoning |
|---|---|---|
| Database Context | Scoped | Unit of work per request; connection management |
| HTTP Client | Singleton (pooled) | Connection pooling; DNS changes handled internally |
| User Context | Scoped | Request-specific state; security boundary |
| Configuration | Singleton | Immutable after startup; shared read-only |
| Logger | Singleton | Stateless; thread-safe by design |
| Validator | Transient or Scoped | Typically stateless; no sharing needed |
| Cache | Singleton | Shared state is the point; thread-safe required |
| Domain Service | Scoped | May use unit of work; isolated per request |
| Background Worker Host | Singleton | Long-running; creates scopes internally |
| Factory | Singleton | Stateless creator; produces scoped instances |
Scoped lifetime is the safest default. It provides request isolation, automatic disposal, and prevents most captive dependency issues. Only upgrade to singleton when you have explicit reasons (shared state, expensive construction, stateless services).
How you configure the IoC container impacts maintainability, testability, and the likelihood of anti-patterns:
IServiceProvider, IContainer, or IScope.Register(typeof(IRepository<>), typeof(Repository<>)) over individual registrations.123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// β
GOOD: Clean composition root with organized modules // === COMPOSITION ROOT (single location for all wiring) ===class CompositionRoot { static configureContainer(container: Container, config: AppConfig): void { // Infrastructure module - databases, external services InfrastructureModule.register(container, config); // Domain module - business logic services DomainModule.register(container, config); // Application module - use cases, handlers ApplicationModule.register(container, config); // Presentation concerns - controllers, view models (if applicable) PresentationModule.register(container, config); // Validate all registrations at startup container.validate(); console.log(`Container configured with ${container.registrationCount} services`); }} // === INFRASTRUCTURE MODULE ===class InfrastructureModule { static register(container: Container, config: AppConfig): void { // Database context - scoped (unit of work per request) container.register(IDbContext, DbContext, Lifetime.Scoped); // HTTP client - singleton (connection pooling) container.register(IHttpClient, HttpClient, Lifetime.Singleton); // Cache - singleton (shared state) container.register(ICache, RedisCache, Lifetime.Singleton); // Message bus - singleton (connection pooling) container.register(IMessageBus, RabbitMqBus, Lifetime.Singleton); }} // === DOMAIN MODULE (using convention-based registration) ===class DomainModule { static register(container: Container, config: AppConfig): void { // Auto-register all domain services by convention container.registerByConvention({ assemblies: ['domain'], pattern: /.*Service$/, // Classes ending in 'Service' asInterface: true, // Register as I{ClassName} lifetime: Lifetime.Scoped, // Default to scoped }); // Register open generic repository container.registerGeneric( IRepository, Repository, Lifetime.Scoped ); }}Dependency injection is fundamentally about testability. These practices ensure DI delivers its testing benefits:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384
// β
GOOD: Unit test without container describe('OrderService', () => { it('should calculate total with discount', async () => { // Arrange: Create mocks manually - no container needed const mockRepository = createMock<IOrderRepository>(); const mockPricingService = createMock<IPricingService>(); const mockEventPublisher = createMock<IEventPublisher>(); mockPricingService.calculateTotal.mockReturnValue({ subtotal: 100, discount: 10, total: 90 }); // Create service with mocks - just like production wiring const service = new OrderService( mockRepository, mockPricingService, mockEventPublisher ); // Act const result = await service.createOrder(testOrder); // Assert expect(result.total).toBe(90); expect(mockEventPublisher.publish).toHaveBeenCalledWith( expect.objectContaining({ type: 'OrderCreated' }) ); });}); // β
GOOD: Integration test with real container describe('OrderService Integration', () => { let container: Container; beforeAll(() => { container = new Container(); CompositionRoot.configureContainer(container, testConfig); // Override only what needs test isolation container.override(IPaymentGateway, FakePaymentGateway); container.override(IEmailSender, FakeEmailSender); }); it('should complete order flow end-to-end', async () => { // Resolve from container - tests real wiring const orderService = container.resolve(IOrderService); const result = await orderService.createOrder(testOrder); expect(result.status).toBe('Completed'); // Verify side effects in fakes expect(container.resolve(FakeEmailSender).sentEmails).toHaveLength(1); });}); // β
GOOD: Composition root validation test describe('CompositionRoot', () => { it('should validate all registrations successfully', () => { const container = new Container(); // This will throw if any registration is missing a dependency expect(() => { CompositionRoot.configureContainer(container, productionConfig); }).not.toThrow(); }); it('should catch lifetime violations with scope validation', () => { const container = new Container({ validateScopes: true }); CompositionRoot.configureContainer(container, productionConfig); // Create a scope and resolve all services to verify lifetimes const scope = container.createScope(); const registrations = container.getAllRegistrations(); for (const reg of registrations) { expect(() => scope.resolve(reg.serviceType)).not.toThrow(); } });});Code reviews are the last line of defense against DI anti-patterns. Use this checklist when reviewing code that involves dependency injection:
IServiceProvider or Container.Resolve() outside composition root is a smell.| Observation | Action | Severity |
|---|---|---|
| 5+ constructor parameters | Request decomposition into smaller classes | Block PR |
| New circular dependency path possible | Request dependency graph check with tooling | Block PR |
| Singleton with scoped dependency | Request factory injection or lifetime change | Block PR |
| Concrete type injected without justification | Request interface extraction | Request changes |
| IServiceProvider in business class | Request refactoring to explicit dependencies | Block PR |
| Missing null checks in constructor | Add guard clauses | Request changes |
| Undocumented lifetime choice | Add comment explaining lifetime decision | Optional |
Configure linters and static analysis to catch constructor parameter counts, circular dependencies, and other mechanical checks. Reserve human review time for design decisions that require judgment and context.
A consolidated reference of the anti-patterns we've covered, with instant recognition cues and remediation approaches:
| Anti-Pattern | Recognition Cue | Root Cause | Primary Fix |
|---|---|---|---|
| Constructor Over-Injection | 7+ constructor parameters | SRP violation; class doing too much | Extract cohesive classes; facade pattern |
| Circular Dependency | Stack overflow at startup; construction fails | Missing abstraction; misplaced responsibilities | Extract interface; mediator; events |
| Captive Dependency | Memory leak; stale data; ObjectDisposedException | Singleton holding scoped/transient reference | Factory injection; scope factory |
| Service Locator | IServiceProvider in business code | Hiding dependencies; avoiding explicit injection | Constructor injection; explicit dependencies |
| Bastard Injection | Default dependencies in constructor | Testing convenience; legacy code | Full constructor injection; DI container |
| Control Freak | New keyword for dependencies in class | Not understanding DI benefits | Move instantiation to composition root |
| Ambient Context | Static Current property; thread-local | Convenience over explicitness | Explicit context injection |
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// ANTI-PATTERNS QUICK VISUAL REFERENCE // β Constructor Over-Injectionclass BadService { constructor(a: A, b: B, c: C, d: D, e: E, f: F, g: G, h: H) {} // 8 params!} // β Circular Dependency class A { constructor(b: B) {} }class B { constructor(a: A) {} } // A β B cycle // β Captive Dependencyclass Singleton { constructor(scopedService: ScopedService) {} // Singleton β Scoped} // β Service Locatorclass BadController { private service: IService; constructor(container: IServiceProvider) { this.service = container.resolve(IService); // Hidden dependency! }} // β Bastard Injectionclass BadService { constructor( repo: IRepository = new ConcreteRepository() // Default = hidden coupling ) {}} // β Control Freakclass BadService { doWork() { const helper = new Helper(); // Creating dependency internally helper.help(); }} // β Ambient Contextclass BadService { doWork() { const user = HttpContext.Current.User; // Static global state }}Let's consolidate everything from this module into a final checklist and mastery criteria:
True DI mastery isn't about following rules mechanicallyβit's about understanding why these patterns exist. The rules prevent problems discovered through decades of collective experience. When you deeply understand the problems, the solutions become obvious rather than prescriptive.
Module Complete:
You have now completed the comprehensive exploration of DI anti-patterns and best practices. You understand:
These patterns apply across languages, frameworks, and decades of software evolution. They are foundational knowledge for any engineer building maintainable systems.
Congratulations! You have completed Module 8: DI Anti-Patterns and Best Practices. You now possess the knowledge to identify, prevent, and resolve the most common dependency injection problems. Apply this checklist to your projects, share it with your team, and make DI a strength rather than a source of technical debt.