Understanding Dependency Injection (DI) in .NET

Understanding Dependency Injection (DI) in .NET

Introduction to Dependency Injection (DI)

Dependency Injection (DI) is a design pattern used to achieve Inversion of Control (IoC) by decoupling the creation and management of dependencies from the objects that use them. Instead of having a class directly instantiate its dependencies, DI allows dependencies to be injected, making the system more maintainable and testable.

Dependency Inversion Principle (DIP)

The Dependency Inversion Principle, one of the five SOLID principles, asserts that:

  1. High-level modules should not depend on low-level modules. Both should depend on abstractions.

  2. Abstractions should not depend on details. Details should depend on abstractions.

For example, instead of a class instantiating a concrete implementation:

public class ReportService {
    private readonly EmailNotifier _notifier;

    public ReportService() {
        _notifier = new EmailNotifier();
    }

    public void GenerateReport() {
        _notifier.Send("Report Generated");
    }
}

A better approach would be to rely on an abstraction:

public interface INotifier {
    void Send(string message);
}

public class EmailNotifier : INotifier {
    public void Send(string message) {
        Console.WriteLine($"Email sent: {message}");
    }
}

public class ReportService {
    private readonly INotifier _notifier;

    public ReportService(INotifier notifier) {
        _notifier = notifier;
    }

    public void GenerateReport() {
        _notifier.Send("Report Generated");
    }
}

Inversion of Control (IoC) and IoC Container

IoC refers to the principle of delegating control over dependency management to a container. In .NET, Microsoft.Extensions.DependencyInjection provides a built-in IoC container.

Key Components:

  • ServiceCollection: A collection to register services.

  • ServiceDescriptor: Defines how services are created.

  • ServiceProvider: Resolves dependencies at runtime.

Example of setting up DI in .NET:

using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
services.AddTransient<INotifier, EmailNotifier>();
services.AddTransient<ReportService>();

var serviceProvider = services.BuildServiceProvider();
var reportService = serviceProvider.GetRequiredService<ReportService>();
reportService.GenerateReport();

DI is a Pattern to Achieve IoC

DI is a concrete implementation of IoC that allows better flexibility, maintainability, and testability in applications.

Types of Dependency Injection

  1. Constructor Injection (Preferred Approach)

     public class UserService {
         private readonly INotifier _notifier;
    
         public UserService(INotifier notifier) {
             _notifier = notifier;
         }
     }
    

    The dependency is injected through the constructor.

  2. Setter Injection

     public class UserService {
         public INotifier Notifier { private get; set; }
    
         public void NotifyUser() {
             Notifier?.Send("Hello User!");
         }
     }
    

    The dependency is assigned via a property.

  3. Interface Injection

     public interface IInjectable {
         void Inject(INotifier notifier);
     }
    
     public class UserService : IInjectable {
         private INotifier _notifier;
    
         public void Inject(INotifier notifier) {
             _notifier = notifier;
         }
     }
    

    Dependencies are provided via a dedicated method.

Reflection API and the Activator Class

The Activator class in .NET provides a way to create instances dynamically using reflection.

var instance = Activator.CreateInstance(typeof(EmailNotifier)) as INotifier;

While useful, using Activator directly can make dependency management harder, which is why DI frameworks are preferred.

Lifetime Cycles in DI

Different lifetimes control how and when instances are created:

  1. Transient

    • A new instance is created every time it is requested.

    • Used for lightweight, stateless services.

    services.AddTransient<INotifier, EmailNotifier>();
  1. Scoped

    • A single instance is created per request (in web applications).

    • Ideal for database contexts.

    • per request lifetime

    services.AddScoped<INotifier, EmailNotifier>();
  1. Singleton

    • A single instance is created for the entire application lifetime.

    • Used for caching, logging, and stateful services.

    • per application lifetime

    services.AddSingleton<INotifier, EmailNotifier>();

KeyedScope and Advanced Registration Methods

KeyedScope (Introduced in .NET 8+)

  • Allows services to be resolved by a specific key.
services.AddKeyedScoped<INotifier, EmailNotifier>("Email");

ServiceDescriptor and Try Methods

ServiceDescriptor provides a low-level way to register dependencies:

var descriptor = new ServiceDescriptor
(
    typeof(INotifier), 
    typeof(EmailNotifier), 
    ServiceLifetime.Transient
);
services.Add(descriptor);

Try Methods in ServiceCollection

The Try methods ensure a service is only registered if it hasn't already been added:

  1. TryAddSingleton<TService, TImplementation>()

  2. TryAddScoped<TService, TImplementation>()

  3. TryAddTransient<TService, TImplementation>()

  4. TryAddEnumerable<TService>() (Adds a service only if it isn’t already registered.)

Example:

services.TryAddScoped<INotifier, EmailNotifier>();

Conclusion

Dependency Injection in .NET 9 provides a robust way to manage dependencies efficiently. By following DI principles and leveraging the built-in IoC container, developers can build scalable, maintainable, and testable applications. Understanding different injection methods, lifetimes, and advanced service registration techniques enables better design decisions in modern .NET applications.