Dependency Injection in C#: what it is and why it's needed
Dependency Injection (DI) is one of the key design patterns that allows you to create loosely coupled and easily testable applications in C#. Instead of a class creating its own dependencies (for example, objects for working with a database or logging), these dependencies are passed (injected) from outside. This implements the Inversion of Control (IoC) principle — control is transferred to an external container.
In the .NET ecosystem, DI has become the de facto standard starting with ASP.NET Core. The built-in dependency container (via Microsoft.Extensions.DependencyInjection) allows you to easily register and resolve dependencies. In this article, we will cover the theory, practical examples, and best practices for using DI in C#.
Basic Principles of Dependency Injection
DI is built on three basic methods of injecting dependencies. Let's look at each of them.
1. Constructor Injection
The most common and preferred method. Dependencies are passed through the constructor parameters of the class.
public interface ILogger{ void Log(string message);}
public class ConsoleLogger : ILogger{ public void Log(string message) => Console.WriteLine(message);}
public class OrderService{ private readonly ILogger _logger;
// Dependency injection via constructor public OrderService(ILogger logger) { _logger = logger; }
public void ProcessOrder(int orderId) { _logger.Log($"Processing order {orderId}"); // business logic }}2. Property Injection
The dependency is set through a public property. Less preferred, as it makes the dependency optional and can lead to runtime errors.
public class OrderService{ public ILogger Logger { get; set; }
public void ProcessOrder(int orderId) { if (Logger == null) throw new InvalidOperationException("Logger is not set"); Logger.Log($"Processing order {orderId}"); }}3. Method Injection
The dependency is passed as a method parameter. Used rarely, usually for temporary dependencies.
public class OrderService{ public void ProcessOrder(int orderId, ILogger logger) { logger.Log($"Processing order {orderId}"); }}Registering Dependencies in the .NET Container
In modern .NET (ASP.NET Core, Console, Worker Services), the built-in DI container is used. Registration happens in the ConfigureServices method or when creating a ServiceCollection.
There are three lifetimes for registered services:
- Transient — a new instance is created each time it is requested. Suitable for lightweight, short-lived services.
- Scoped — one instance per HTTP request (or scope). Used for Entity Framework DbContext.
- Singleton — one instance for the entire application. Suitable for caches, settings, loggers.
// Example for ASP.NET Corepublic class Startup{ public void ConfigureServices(IServiceCollection services) { // Registration with interface services.AddTransient<ILogger, ConsoleLogger>(); services.AddScoped<IOrderRepository, OrderRepository>(); services.AddSingleton<IConfiguration, ConfigurationManager>();
// Registration without interface (concrete class) services.AddTransient<OrderService>(); }}
// Usage in a controllerpublic class OrderController : ControllerBase{ private readonly OrderService _orderService;
public OrderController(OrderService orderService) { _orderService = orderService; }
[HttpGet("{id}")] public IActionResult GetOrder(int id) { _orderService.ProcessOrder(id); return Ok(); }}Benefits of Using Dependency Injection
Using DI provides several important advantages:
- Testability — you can easily replace real implementations with mocks or stubs during unit testing.
- Flexibility — changing the behavior of an application often only requires replacing one registered implementation with another.
- Reduced coupling — classes depend on abstractions (interfaces), not concrete implementations.
- Centralized lifecycle management — the container controls when and how objects are created and destroyed.
Common Mistakes and Best Practices
Avoid Captive Dependencies
A captive dependency occurs when a service with a shorter lifetime is injected into a service with a longer lifetime. For example, injecting a Scoped service into a Singleton service. This can lead to unexpected behavior and memory leaks.
// Wrong: Singleton depends on Scopedpublic class SingletonService{ private readonly ScopedService _scopedService; // Captive dependency! public SingletonService(ScopedService scopedService) { _scopedService = scopedService; }}Use the Composition Root
The composition root is a single place in the application where all dependencies are registered. In ASP.NET Core, this is the ConfigureServices method. Avoid using the container directly in application code (Service Locator anti-pattern).
Avoid Over-Injection
If a class requires too many dependencies (more than 3-4), this may indicate a violation of the Single Responsibility Principle. Consider refactoring such a class.
// Bad practice: too many dependenciespublic class OrderService{ public OrderService( ILogger logger, IRepository repository, IEmailService emailService, IPaymentService paymentService, INotificationService notificationService, ICacheService cacheService) { // ... }}Conclusion
Dependency Injection is a powerful tool in the arsenal of a C# developer. It makes code cleaner, more flexible, and easier to test. The built-in DI container in .NET provides everything you need for most tasks, from small console applications to large web services. Start using DI in your projects, and you will quickly appreciate its benefits.
Remember that DI is not a silver bullet — it is important to use it wisely, following best practices and avoiding common mistakes. In the next article, we will look at more advanced DI scenarios, such as working with multiple implementations of one interface and using third-party containers like Autofac.