Dependency Injection in .NET

Dependency Injection (DI) is a core design pattern in .NET that allows for loosely coupled code. It makes your application more testable, maintainable, and scalable by managing dependencies in a centralized way.

IServiceCollection and IServiceProvider

In .NET, the DI system revolves around two key interfaces:

  • IServiceCollection: Used to register services and their lifetimes.
  • IServiceProvider: Used to resolve services and inject them into constructors or methods.

IServiceCollection

IServiceCollection is used to register dependencies in your application. These dependencies are registered with a specific lifetime, which defines how they behave throughout the application's lifecycle.

For example, you might register a service as Transient, Scoped, or Singleton based on how you want its object lifetime to be managed.

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddTransient<ITransientService, TransientService>();
        services.AddScoped<IScopedService, ScopedService>();
        services.AddSingleton<ISingletonService, SingletonService>();
    });

//might also look like this:

var host = Host.CreateDefaultBuilder(args);
var services = host.Services;

services.AddTransient<ITransientService, TransientService>();
services.AddScoped<IScopedService, ScopedService>();
services.AddSingleton<ISingletonService, SingletonService>();

IServiceProvider

Once services are registered, they are resolved through the IServiceProvider. This interface is responsible for creating instances of services based on their registrations.

public class MyService
{
    private readonly ITransientService _transientService;

    public MyService(ITransientService transientService)
    {
        _transientService = transientService;
    }
}

When the MyService class is instantiated, IServiceProvider will automatically resolve and inject the appropriate implementation of ITransientService.

Service Lifetimes

In .NET DI, services can be registered with three primary lifetimes, which dictate how and when service instances are created:

Transient

  • Lifetime: A new instance is created each time the service is requested from the container.
  • Best for: Lightweight, stateless services.
  • Example: A service that processes a single user request.
services.AddTransient<ITransientService, TransientService>();

In this case, every time ITransientService is requested, a new instance of TransientService will be created.

Scoped

  • Lifetime: A single instance is created per request (or "scope").
  • Best for: Services that need to maintain state within a single user request but not across requests.
  • Example: A database context in a web application, where the same context is used throughout the lifecycle of a request but disposed of after the request is completed.
services.AddScoped<IScopedService, ScopedService>();

Here, IScopedService will have the same instance for the duration of the request.

What the heck is a "scope"? This is NOT THE SAME as "variable scope" in C#! A scope can mean different things depending on the execution context.

For example, in an ASP.NET Core app, a scope is created for each incoming HTTP request.

You'll see plenty of examples of scoped services in the ASP.NET Core course.

Singleton

  • Lifetime: A single instance is created once and shared throughout the application's entire lifetime.
  • Best for: Services that maintain state or are expensive to create.
  • Example: A logging service that needs to maintain shared resources across the entire application.
services.AddSingleton<ISingletonService, SingletonService>();

In this example, the same SingletonService instance will be used every time ISingletonService is requested.

Choosing the Right Lifetime

The choice of lifetime depends on the specific use case of the service:

  • Transient: When the service is stateless or short-lived.
  • Scoped: When the service needs to maintain consistency throughout a request (e.g., database contexts inside of an HTTP request).
  • Singleton: When a service needs to persist for the entire application lifecycle.

🌶️🌶️🌶️ Singleton services are not super common, but they are sometimes useful - that said, do not rely on changing state inside of a singleton service, as this can lead to unexpected behavior. Most of the time, I use scoped services, followed by transient, and only rarely do I use singletons.

Resolving Services in .NET

There are different ways to resolve services in .NET:

  • Constructor Injection: The most common method, where services are injected into a class’s constructor.
  • Method Injection: Services are injected directly into methods as parameters.
  • Property Injection: Services are injected into properties. This is less common but useful in certain cases.

Here’s how you would resolve services using constructor injection:

public class MyController
{
    private readonly IScopedService _scopedService;

    public MyController(IScopedService scopedService)
    {
        _scopedService = scopedService;
    }

    public IActionResult Index()
    {
        _scopedService.DoWork();
        return View();
    }
}

Build Your Service Provider

In some cases, you may need to build your IServiceProvider manually, especially in non-web applications.

var services = new ServiceCollection();
services.AddTransient<ITransientService, TransientService>();
var serviceProvider = services.BuildServiceProvider();

var service = serviceProvider.GetService<ITransientService>();
service.DoWork();

However, this approach is less common in ASP.NET Core, where the framework automatically handles service resolution.