Dependency Injection: Inversion of Control
Every time you write `new`, you're making a decision that's hard to undo. Direct instantiation creates concrete dependencies that ripple through your codebase, making testing painful and changes...
Key Insights
- Dependency Injection implements the Inversion of Control principle by externalizing object creation, making your code testable, modular, and loosely coupled.
- Constructor injection should be your default choice—it makes dependencies explicit, enforces immutability, and fails fast when requirements aren’t met.
- DI containers are powerful but optional; understanding the pattern matters more than mastering any specific framework.
The Problem with Tight Coupling
Every time you write new, you’re making a decision that’s hard to undo. Direct instantiation creates concrete dependencies that ripple through your codebase, making testing painful and changes risky.
Consider this common pattern:
public class OrderService
{
private readonly SqlOrderRepository _repository;
private readonly SmtpEmailService _emailService;
public OrderService()
{
_repository = new SqlOrderRepository("Server=prod;Database=Orders;");
_emailService = new SmtpEmailService("smtp.company.com", 587);
}
public void PlaceOrder(Order order)
{
_repository.Save(order);
_emailService.SendConfirmation(order.CustomerEmail, order.Id);
}
}
This code has several problems. The OrderService knows exactly which repository implementation to use, which email service to use, and even the connection strings. Want to test this without hitting a real database? You can’t. Want to switch to a different email provider? You’re rewriting this class.
The phrase “new is glue” captures this perfectly. Every new statement glues your class to a specific implementation. Sometimes that’s fine—you probably don’t need to inject StringBuilder. But for infrastructure concerns like databases, external services, and cross-cutting concerns, that glue becomes technical debt.
Understanding Inversion of Control
Inversion of Control is a design principle where you surrender control over object creation and lifecycle management to an external system. Instead of your code calling the shots, something else orchestrates the pieces.
Think about the difference between a console application and a web framework. In a console app, your Main method controls everything—it decides when to read input, process data, and write output. In a web framework, you write controller methods and the framework calls them when requests arrive. The control has been inverted.
IoC manifests in several forms: event-driven programming, the template method pattern, and dependency injection. DI is specifically about inverting control over dependency creation. Instead of a class creating its own dependencies, those dependencies are provided from outside.
The distinction matters. IoC is the principle; DI is one technique for achieving it. You can have IoC without DI (event handlers), and technically DI without a container (manual injection). Understanding this hierarchy helps you see DI as a tool rather than a religion.
Dependency Injection Patterns
Three patterns dominate DI implementations, each with distinct characteristics.
Constructor Injection passes dependencies through the constructor:
public interface IOrderRepository
{
void Save(Order order);
Order GetById(int id);
}
public interface IEmailService
{
void SendConfirmation(string email, int orderId);
}
public class OrderService
{
private readonly IOrderRepository _repository;
private readonly IEmailService _emailService;
public OrderService(IOrderRepository repository, IEmailService emailService)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
_emailService = emailService ?? throw new ArgumentNullException(nameof(emailService));
}
public void PlaceOrder(Order order)
{
_repository.Save(order);
_emailService.SendConfirmation(order.CustomerEmail, order.Id);
}
}
Constructor injection should be your default. Dependencies are explicit—you can see exactly what a class needs by looking at its constructor. The class can’t be instantiated without its requirements, which means it fails fast rather than throwing null reference exceptions later. Dependencies can be readonly, preventing accidental reassignment.
Setter Injection uses properties:
public class ReportGenerator
{
public ILogger Logger { get; set; }
public void Generate()
{
Logger?.LogInformation("Starting report generation");
// Generate report
}
}
Use setter injection for optional dependencies. The null-conditional operator (?.) signals that the dependency might not be present. This pattern works well for cross-cutting concerns like logging where the absence shouldn’t break core functionality.
Interface Injection defines a contract for receiving dependencies:
public interface IRepositoryAware
{
void SetRepository(IOrderRepository repository);
}
public class OrderProcessor : IRepositoryAware
{
private IOrderRepository _repository;
public void SetRepository(IOrderRepository repository)
{
_repository = repository;
}
}
Interface injection is rarely used in modern applications. It adds ceremony without clear benefits over constructor injection. You’ll encounter it in legacy frameworks, but don’t reach for it in new code.
IoC Containers and Service Registration
DI containers automate dependency resolution. Instead of manually wiring up object graphs, you register types and let the container figure out how to construct them.
Here’s how registration works in .NET’s built-in container:
var builder = WebApplication.CreateBuilder(args);
// Transient: new instance every time
builder.Services.AddTransient<IOrderValidator, OrderValidator>();
// Scoped: one instance per request/scope
builder.Services.AddScoped<IOrderRepository, SqlOrderRepository>();
// Singleton: one instance for the application lifetime
builder.Services.AddSingleton<IEmailService, SmtpEmailService>();
// Configuration-based registration
builder.Services.AddScoped<IOrderService, OrderService>();
var app = builder.Build();
The three lifetimes serve different purposes:
Transient services are created fresh each time they’re requested. Use this for lightweight, stateless services. Be careful with transient services that hold expensive resources—you might create more database connections than you intended.
Scoped services live for the duration of a scope, typically an HTTP request in web applications. This is ideal for database contexts and repositories. Each request gets its own instance, enabling patterns like unit of work.
Singleton services exist once for the application’s lifetime. Use these for stateless services, caches, and configuration. Be cautious about thread safety—singletons are shared across all requests.
Spring’s configuration follows similar patterns:
@Configuration
public class AppConfig {
@Bean
@Scope("prototype") // Equivalent to transient
public OrderValidator orderValidator() {
return new OrderValidator();
}
@Bean
@Scope("request")
public OrderRepository orderRepository(DataSource dataSource) {
return new JdbcOrderRepository(dataSource);
}
@Bean // Singleton by default
public EmailService emailService() {
return new SmtpEmailService("smtp.company.com", 587);
}
}
Practical Benefits: Testing and Modularity
DI’s most immediate payoff is testability. With dependencies injected, you can substitute test doubles:
public class OrderServiceTests
{
[Fact]
public void PlaceOrder_SavesOrderAndSendsEmail()
{
// Arrange
var mockRepository = new Mock<IOrderRepository>();
var mockEmailService = new Mock<IEmailService>();
var service = new OrderService(mockRepository.Object, mockEmailService.Object);
var order = new Order { Id = 1, CustomerEmail = "test@example.com" };
// Act
service.PlaceOrder(order);
// Assert
mockRepository.Verify(r => r.Save(order), Times.Once);
mockEmailService.Verify(e => e.SendConfirmation("test@example.com", 1), Times.Once);
}
[Fact]
public void PlaceOrder_WhenRepositoryFails_DoesNotSendEmail()
{
// Arrange
var mockRepository = new Mock<IOrderRepository>();
mockRepository.Setup(r => r.Save(It.IsAny<Order>()))
.Throws<DatabaseException>();
var mockEmailService = new Mock<IEmailService>();
var service = new OrderService(mockRepository.Object, mockEmailService.Object);
// Act & Assert
Assert.Throws<DatabaseException>(() => service.PlaceOrder(new Order()));
mockEmailService.Verify(e => e.SendConfirmation(It.IsAny<string>(), It.IsAny<int>()), Times.Never);
}
}
These tests run in milliseconds, require no infrastructure, and verify specific behaviors. Without DI, you’d need integration tests with real databases and email servers—slower, flakier, and harder to debug.
Beyond testing, DI enables runtime flexibility. Swap implementations based on configuration, environment, or feature flags without changing consuming code.
Common Pitfalls and Anti-Patterns
Service Locator is DI’s evil twin. Instead of receiving dependencies, classes ask a container for them:
// Anti-pattern: Service Locator
public class OrderService
{
public void PlaceOrder(Order order)
{
var repository = ServiceLocator.Get<IOrderRepository>();
repository.Save(order);
}
}
This hides dependencies, making classes harder to understand and test. The constructor lies about what the class needs. Avoid service locator except in rare infrastructure scenarios.
Constructor Over-Injection signals a class doing too much:
// Code smell: too many dependencies
public class OrderService(
IOrderRepository repository,
IEmailService emailService,
IInventoryService inventoryService,
IPaymentGateway paymentGateway,
IShippingCalculator shippingCalculator,
ITaxService taxService,
IDiscountEngine discountEngine,
IAuditLogger auditLogger)
If a constructor takes more than three or four dependencies, the class likely violates the single responsibility principle. Refactor into smaller, focused classes or introduce a facade.
Captive Dependencies occur when a longer-lived service holds a shorter-lived one:
// Problem: Singleton holding scoped dependency
public class CachedOrderService : IOrderService
{
private readonly IOrderRepository _repository; // Scoped!
public CachedOrderService(IOrderRepository repository)
{
_repository = repository; // Captured once, used forever
}
}
A singleton capturing a scoped repository means one database context shared across all requests—a recipe for threading issues and stale data. Most containers detect this, but understand why it’s dangerous.
When DI Makes Sense
DI shines in applications with multiple layers, external dependencies, and testing requirements. Web applications, APIs, and enterprise systems benefit enormously.
But DI isn’t universal. A simple script that processes a file doesn’t need an IoC container. Small utilities, console tools, and prototypes often work better with straightforward instantiation. Adding DI infrastructure to a 200-line program is over-engineering.
The pattern also has a learning curve. Teams unfamiliar with DI will struggle initially. The indirection can make debugging harder—stack traces traverse container code, and finding where an implementation is registered requires knowing the configuration.
Use DI when you need testability, when implementations might change, or when you’re building something that will grow. Skip it when simplicity matters more than flexibility. The goal is maintainable software, not architectural purity.