Loading content...
An IoC container is a powerful tool—and like all powerful tools, it can be misused. Poor container usage creates systems that are harder to understand, harder to test, and harder to maintain than no container at all.
This page distills the collective wisdom of the software engineering community into actionable best practices. These aren't arbitrary rules—they're patterns that have proven themselves across countless production systems, and anti-patterns whose costs have been painfully learned.
You will master the Composition Root pattern, understand how to handle cross-cutting concerns properly, learn to recognize and avoid common container anti-patterns, and understand how to build container configurations that remain maintainable as systems scale.
The Composition Root is the single location where all dependency injection configuration happens. It's the one place in your application that knows about the container and wires together all the pieces.
The fundamental rule:
The container should only be referenced in the Composition Root—never in application code.
This means: business logic, domain models, services, repositories—none of them should ever see, reference, or depend on the container. They receive their dependencies through constructors, not by reaching into a container.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697
// CORRECT: Composition Root Design// Container is only referenced here, never in application code // Program.cs - The Composition Rootpublic class Program{ public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // ═══════════════════════════════════════════════════════ // COMPOSITION ROOT: All DI configuration happens here // ═══════════════════════════════════════════════════════ // Infrastructure builder.Services.AddSingleton<ILogger, SerilogLogger>(); builder.Services.AddSingleton<IDatabase>(sp => new PostgresDatabase(builder.Configuration.GetConnectionString("Default")!)); builder.Services.AddSingleton<ICache, RedisCache>(); // Repositories builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IOrderRepository, OrderRepository>(); // Services builder.Services.AddScoped<IUserService, UserService>(); builder.Services.AddScoped<IOrderService, OrderService>(); builder.Services.AddSingleton<IPaymentGateway, StripePaymentGateway>(); // Cross-cutting concerns builder.Services.AddScoped<ICurrentUserProvider, HttpContextUserProvider>(); // ═══════════════════════════════════════════════════════ // END OF COMPOSITION ROOT // ═══════════════════════════════════════════════════════ var app = builder.Build(); // Middleware, routing, etc. app.MapControllers(); app.Run(); }} // Application code - NO container references anywhere public class UserService : IUserService{ // Dependencies injected via constructor private readonly IUserRepository _userRepository; private readonly ILogger _logger; private readonly ICurrentUserProvider _currentUserProvider; // ✅ CORRECT: Constructor injection, no container public UserService( IUserRepository userRepository, ILogger logger, ICurrentUserProvider currentUserProvider) { _userRepository = userRepository; _logger = logger; _currentUserProvider = currentUserProvider; } public async Task<User> GetUserAsync(string id) { _logger.Log($"Getting user {id}"); return await _userRepository.FindByIdAsync(id) ?? throw new UserNotFoundException(id); }} // ❌ WRONG: Service locator usage in application codepublic class BadUserService : IUserService{ private readonly IServiceProvider _serviceProvider; // ❌ Container reference public BadUserService(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; // ❌ Injecting the container } public async Task<User> GetUserAsync(string id) { // ❌ WRONG: Resolving from container in application code var logger = _serviceProvider.GetRequiredService<ILogger>(); var repo = _serviceProvider.GetRequiredService<IUserRepository>(); // Dependencies are hidden, not visible in constructor // Testing requires setting up full container // Violates Dependency Inversion Principle logger.Log($"Getting user {id}"); return await repo.FindByIdAsync(id) ?? throw new UserNotFoundException(id); }}Treat the container like your database connection: you configure it at startup, but business logic never directly references it. If you're injecting IServiceProvider or IContainer into a service, you're doing it wrong. The only exceptions are framework infrastructure code (middleware, controllers) and factory patterns for per-request compositions.
Pure DI (also called Poor Man's DI) means constructing object graphs manually without any container. It's worth understanding because it clarifies what containers actually do—and when they're unnecessarily complex.
The insight:
Dependency injection doesn't require a container. Constructor injection works with plain new statements. Containers add value when manual wiring becomes burdensome—but for small applications or specific subsystems, manual wiring may be simpler.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Pure DI: Manual object graph construction// No container dependency, just constructors and 'new' public class PureDICompositionRoot{ public static WebApplication Configure(WebApplicationBuilder builder) { // Create object graph manually var configuration = builder.Configuration; // Infrastructure (singletons created once) var logger = new SerilogLogger(configuration["Logging:Level"]); var database = new PostgresDatabase( configuration.GetConnectionString("Default")!, logger ); var cache = new RedisCache( configuration.GetConnectionString("Redis")! ); var passwordHasher = new BcryptPasswordHasher(workFactor: 12); var tokenService = new JwtTokenService( configuration["Jwt:Secret"]!, int.Parse(configuration["Jwt:ExpirationMinutes"]!) ); // Factory for request-scoped objects builder.Services.AddScoped<IUserService>(sp => { var httpContext = sp.GetRequiredService<IHttpContextAccessor>(); // Manual construction with explicit dependencies var userRepository = new UserRepository(database, logger); var currentUserProvider = new HttpContextUserProvider(httpContext); return new UserService( userRepository, passwordHasher, tokenService, logger, currentUserProvider ); }); builder.Services.AddScoped<IOrderService>(sp => { var userRepository = new UserRepository(database, logger); var orderRepository = new OrderRepository(database, logger); var currentUserProvider = new HttpContextUserProvider( sp.GetRequiredService<IHttpContextAccessor>() ); return new OrderService( orderRepository, userRepository, logger, currentUserProvider ); }); return builder.Build(); }} // When Pure DI makes sense:// - Small applications (< 50 services)// - Libraries that shouldn't force container choice on consumers// - Performance-critical paths where reflection overhead matters// - Learning DI concepts without container complexity // When containers add value:// - Large applications (100+ services)// - Complex lifetime management (scoping, disposal)// - Automatic decorator/interceptor chaining// - Convention-based registration at scale// - Teams comfortable with container patternsFor most web applications, use a container—the framework integration and lifetime management are worth it. For libraries, prefer Pure DI to avoid forcing container choices on consumers. For small utilities and scripts, Pure DI is simpler. The key is understanding DI principles work independent of containers.
Cross-cutting concerns—logging, validation, caching, authorization, transaction management—affect many components but aren't central to business logic. Containers provide elegant mechanisms to apply these concerns without polluting business code.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
// Pattern 1: DECORATOR PATTERN FOR CROSS-CUTTING CONCERNS// Wrap services with additional behavior without modifying them // Core service - pure business logicpublic class OrderService : IOrderService{ private readonly IOrderRepository _orderRepository; private readonly IInventoryService _inventoryService; public OrderService( IOrderRepository orderRepository, IInventoryService inventoryService) { _orderRepository = orderRepository; _inventoryService = inventoryService; } public async Task<Order> PlaceOrderAsync(PlaceOrderCommand command) { // Pure business logic - no logging, no caching, no metrics var order = new Order(command.CustomerId, command.Items); await _inventoryService.ReserveItemsAsync(command.Items); await _orderRepository.SaveAsync(order); return order; }} // Logging decoratorpublic class LoggingOrderServiceDecorator : IOrderService{ private readonly IOrderService _inner; private readonly ILogger _logger; public LoggingOrderServiceDecorator( IOrderService inner, ILogger logger) { _inner = inner; _logger = logger; } public async Task<Order> PlaceOrderAsync(PlaceOrderCommand command) { _logger.LogInformation( "Placing order for customer {CustomerId} with {ItemCount} items", command.CustomerId, command.Items.Count ); var stopwatch = Stopwatch.StartNew(); try { var result = await _inner.PlaceOrderAsync(command); _logger.LogInformation( "Order {OrderId} placed successfully in {ElapsedMs}ms", result.Id, stopwatch.ElapsedMilliseconds ); return result; } catch (Exception ex) { _logger.LogError( ex, "Order placement failed for customer {CustomerId}", command.CustomerId ); throw; } }} // Metrics decoratorpublic class MetricsOrderServiceDecorator : IOrderService{ private readonly IOrderService _inner; private readonly IMetricsCollector _metrics; public MetricsOrderServiceDecorator( IOrderService inner, IMetricsCollector metrics) { _inner = inner; _metrics = metrics; } public async Task<Order> PlaceOrderAsync(PlaceOrderCommand command) { using var timer = _metrics.StartTimer("order_placement_duration"); _metrics.Increment("order_placement_attempts"); try { var result = await _inner.PlaceOrderAsync(command); _metrics.Increment("order_placement_success"); return result; } catch { _metrics.Increment("order_placement_failures"); throw; } }} // Validation decoratorpublic class ValidatingOrderServiceDecorator : IOrderService{ private readonly IOrderService _inner; private readonly IValidator<PlaceOrderCommand> _validator; public ValidatingOrderServiceDecorator( IOrderService inner, IValidator<PlaceOrderCommand> validator) { _inner = inner; _validator = validator; } public async Task<Order> PlaceOrderAsync(PlaceOrderCommand command) { var validationResult = await _validator.ValidateAsync(command); if (!validationResult.IsValid) { throw new ValidationException(validationResult.Errors); } return await _inner.PlaceOrderAsync(command); }} // Registration: Build decorator chain at composition rootservices.AddScoped<OrderService>(); // Base implementation services.AddScoped<IOrderService>(sp =>{ // Build chain: Validation -> Metrics -> Logging -> Core var core = sp.GetRequiredService<OrderService>(); var logger = sp.GetRequiredService<ILogger>(); var metrics = sp.GetRequiredService<IMetricsCollector>(); var validator = sp.GetRequiredService<IValidator<PlaceOrderCommand>>(); IOrderService decorated = core; decorated = new LoggingOrderServiceDecorator(decorated, logger); decorated = new MetricsOrderServiceDecorator(decorated, metrics); decorated = new ValidatingOrderServiceDecorator(decorated, validator); return decorated;}); // Or with Scrutor for cleaner syntax:services.AddScoped<IOrderService, OrderService>();services.Decorate<IOrderService, LoggingOrderServiceDecorator>();services.Decorate<IOrderService, MetricsOrderServiceDecorator>();services.Decorate<IOrderService, ValidatingOrderServiceDecorator>();Learning what NOT to do is as important as learning best practices. These anti-patterns cause real pain in production systems:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
// ════════════════════════════════════════════════════════════// ANTI-PATTERN 1: Service Locator// ════════════════════════════════════════════════════════════ // ❌ WRONG: Injecting container into servicespublic class BadService{ private readonly IServiceProvider _serviceProvider; public BadService(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; // ❌ Hidden dependencies } public void DoWork() { // ❌ Dependencies not visible in constructor var logger = _serviceProvider.GetRequiredService<ILogger>(); var repo = _serviceProvider.GetRequiredService<IUserRepository>(); // Hidden dependencies make testing difficult // Can't tell from signature what this class needs }} // ✅ CORRECT: Explicit constructor injectionpublic class GoodService{ private readonly ILogger _logger; private readonly IUserRepository _userRepository; public GoodService(ILogger logger, IUserRepository userRepository) { _logger = logger; _userRepository = userRepository; } public void DoWork() { // All dependencies visible and testable }} // ════════════════════════════════════════════════════════════// ANTI-PATTERN 2: Captive Dependencies// ════════════════════════════════════════════════════════════ // Singleton captures scoped dependency - the scoped service// lives forever, breaking its intended lifecycle // ❌ WRONG: Singleton holding scoped reference[Singleton]public class BadCachingService : ICachingService{ private readonly IUserContextProvider _userContext; // ❌ Scoped! public BadCachingService(IUserContextProvider userContext) { // This scoped service is now captured for app lifetime // It won't get new values per request _userContext = userContext; } public string GetCacheKey(string key) { // ❌ _userContext was captured at startup, never refreshed return $"{_userContext.CurrentUserId}:{key}"; }} // ✅ CORRECT: Inject factory for scoped resolution[Singleton]public class GoodCachingService : ICachingService{ private readonly IServiceScopeFactory _scopeFactory; public GoodCachingService(IServiceScopeFactory scopeFactory) { _scopeFactory = scopeFactory; } public string GetCacheKey(string key) { // ✅ Create scope, resolve scoped service, use it using var scope = _scopeFactory.CreateScope(); var userContext = scope.ServiceProvider .GetRequiredService<IUserContextProvider>(); return $"{userContext.CurrentUserId}:{key}"; }} // ════════════════════════════════════════════════════════════// ANTI-PATTERN 3: Constructor Over-Injection// ════════════════════════════════════════════════════════════ // ❌ WRONG: Too many constructor parameterspublic class OverloadedService{ public OverloadedService( IUserRepository userRepository, IOrderRepository orderRepository, IProductRepository productRepository, IInventoryService inventoryService, IPaymentService paymentService, IShippingService shippingService, INotificationService notificationService, ILogger logger, ICache cache, IConfiguration configuration, ICurrentUserProvider currentUserProvider, IEventPublisher eventPublisher) { // 12+ dependencies indicates this class does too much // Violates Single Responsibility Principle }} // ✅ CORRECT: Refactor into focused servicespublic class OrderPlacementService{ public OrderPlacementService( IOrderRepository orderRepository, IOrderValidationService validationService, IInventoryReservationService inventoryService, ILogger logger) { // Focused responsibility, few dependencies }} public class OrderFulfillmentService{ public OrderFulfillmentService( IPaymentService paymentService, IShippingService shippingService, INotificationService notificationService, ILogger logger) { // Separate concern, separate service }} // ════════════════════════════════════════════════════════════// ANTI-PATTERN 4: Temporal Coupling via Initialization// ════════════════════════════════════════════════════════════ // ❌ WRONG: Requiring Initialize() call after constructionpublic class BadInitializationService : IDataService{ private IDatabase _database; private bool _initialized = false; public BadInitializationService() { } public void Initialize(string connectionString) { _database = new PostgresDatabase(connectionString); _initialized = true; } public async Task<Data> GetDataAsync() { if (!_initialized) throw new InvalidOperationException("Call Initialize first!"); // Two-phase initialization is error-prone return await _database.QueryAsync<Data>(); }} // ✅ CORRECT: Fully initialized via constructorpublic class GoodService : IDataService{ private readonly IDatabase _database; public GoodService(IDatabase database) { _database = database ?? throw new ArgumentNullException(nameof(database)); // Object is fully ready to use after construction } public async Task<Data> GetDataAsync() { return await _database.QueryAsync<Data>(); }}| Anti-Pattern | Problem | Solution |
|---|---|---|
| Service Locator | Hidden dependencies, hard testing | Explicit constructor injection |
| Captive Dependencies | Scoped service lives forever | Inject factory or scope creator |
| Constructor Over-Injection | SRP violation, complexity | Extract focused services |
| Temporal Coupling | Two-phase initialization, fragile | Complete construction in constructor |
| Circular Dependencies | A needs B needs A, resolution fails | Redesign, extract common abstraction |
| Ambient Context | Static/global service access | Inject explicitly |
Choosing appropriate lifetimes is critical. Wrong choices cause subtle bugs—stale data from captured scoped services, race conditions from shared transients that should be singletons, memory leaks from undisposed instances.
| Lifetime | When to Use | Examples | Gotchas |
|---|---|---|---|
| Singleton | Stateless utilities, expensive-to-create resources, thread-safe shared state | Logger, Configuration, HttpClient factories, Cache clients | Must be thread-safe; don't capture scoped dependencies |
| Scoped | Per-request state, transaction boundaries, EF DbContext | DbContext, CurrentUserProvider, UnitOfWork | Not available outside request scope; don't inject into singletons |
| Transient | Stateless, lightweight services; unique instance needed each time | Validators, Command handlers, Factories | Can be expensive if heavy; created each time resolved |
A service can only depend on services with equal or longer lifetimes: Singleton → Singleton only. Scoped → Scoped or Singleton. Transient → Any. Violating this creates captive dependencies. Modern containers can detect this—enable validation in development.
Proper DI makes testing easy. When dependencies are injected, tests can substitute fakes, mocks, or stubs without any container involvement.
The key insight:
Unit tests should NOT use a container. Integration tests may use a container with controlled configuration. Only end-to-end tests use the production container configuration.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
// Unit Testing: No Container Needed// Pure DI with test doubles public class UserServiceTests{ [Fact] public async Task GetUser_WhenUserExists_ReturnsUser() { // Arrange - Pure DI, no container var mockRepo = new Mock<IUserRepository>(); var mockLogger = new Mock<ILogger>(); var expectedUser = new User("123", "John", "john@example.com"); mockRepo.Setup(r => r.FindByIdAsync("123")) .ReturnsAsync(expectedUser); // Create service with test doubles - no container! var service = new UserService( mockRepo.Object, mockLogger.Object ); // Act var result = await service.GetUserAsync("123"); // Assert Assert.Equal(expectedUser, result); mockLogger.Verify(l => l.Log(It.IsAny<string>()), Times.Once); } [Fact] public async Task GetUser_WhenUserNotFound_ThrowsException() { // Arrange var mockRepo = new Mock<IUserRepository>(); var mockLogger = new Mock<ILogger>(); mockRepo.Setup(r => r.FindByIdAsync("999")) .ReturnsAsync((User?)null); var service = new UserService(mockRepo.Object, mockLogger.Object); // Act & Assert await Assert.ThrowsAsync<UserNotFoundException>( () => service.GetUserAsync("999") ); }} // Integration Testing: Container with Test Configuration// Use real container, but configured for testing public class IntegrationTestFixture : IAsyncLifetime{ public IServiceProvider ServiceProvider { get; private set; } = null!; public async Task InitializeAsync() { var services = new ServiceCollection(); // Use real DI configuration services.AddApplicationServices(); // Override with test doubles for external systems services.AddSingleton<IEmailSender, FakeEmailSender>(); services.AddSingleton<IPaymentGateway, FakePaymentGateway>(); // Use in-memory database services.AddDbContext<ApplicationDbContext>(options => options.UseInMemoryDatabase("TestDb")); ServiceProvider = services.BuildServiceProvider(); // Seed test data await SeedTestDataAsync(); } public async Task DisposeAsync() { if (ServiceProvider is IDisposable disposable) { disposable.Dispose(); } } private async Task SeedTestDataAsync() { using var scope = ServiceProvider.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); db.Users.Add(new User("test-user", "Test User", "test@example.com")); await db.SaveChangesAsync(); }} public class OrderIntegrationTests : IClassFixture<IntegrationTestFixture>{ private readonly IntegrationTestFixture _fixture; public OrderIntegrationTests(IntegrationTestFixture fixture) { _fixture = fixture; } [Fact] public async Task PlaceOrder_WithValidData_Succeeds() { // Arrange - Use real container with test configuration using var scope = _fixture.ServiceProvider.CreateScope(); var orderService = scope.ServiceProvider.GetRequiredService<IOrderService>(); var command = new PlaceOrderCommand( CustomerId: "test-user", Items: new[] { new OrderItem("product-1", 2) } ); // Act var order = await orderService.PlaceOrderAsync(command); // Assert Assert.NotNull(order); Assert.Equal("test-user", order.CustomerId); // Verify fake email was "sent" var fakeEmailSender = _fixture.ServiceProvider .GetRequiredService<IEmailSender>() as FakeEmailSender; Assert.Contains( fakeEmailSender!.SentEmails, e => e.Subject.Contains("Order Confirmation") ); }}Effective container usage requires discipline and understanding. Here are the essential principles:
Module Complete:
You've now completed the IoC Container Deep Dive module. You understand container configuration approaches, can compare XML/Code/Attribute paradigms, implement automatic registration patterns, and apply best practices that lead to maintainable, testable systems.
The next module will explore Lifetime Management in greater depth—understanding transient, scoped, and singleton lifecycles, their implications, and advanced patterns for complex scenarios.
You've mastered IoC container configuration and best practices. You can design Composition Roots, handle cross-cutting concerns elegantly, recognize and avoid anti-patterns, choose appropriate lifetimes, and build testable systems. These skills are foundational for building maintainable software at scale.