Skip to main content

Dependency Injection

Dependency Injection (DI) is a design pattern that implements Inversion of Control (IoC) for managing dependencies between objects. Rather than objects creating their own dependencies, they receive them from an external source. This promotes loose coupling, testability, and maintainable code.

Understanding Dependency Injection

The Restaurant Analogy

Imagine you're running a restaurant. Without DI, your chef would need to:

  • Grow vegetables in a garden
  • Raise chickens for eggs
  • Mill flour for bread
  • Manufacture cooking equipment

This is tightly coupled and impractical. With DI, you provide the chef with pre-sourced ingredients and equipment. The chef doesn't care where the ingredients come from, they just use what's provided to create dishes.

In code terms:

  • Without DI: Classes create their own dependencies (the chef grows vegetables)
  • With DI: Classes receive dependencies from outside (ingredients are provided)

Inversion of Control

Traditional programming flow: "I need a database connection, so I'll create one."

public class PlatformsController
{
private readonly AppDbContext _context;

public PlatformsController()
{
_context = new AppDbContext(); // ❌ Tightly coupled
}
}

Inversion of Control: "I need a database connection, someone else will provide it."

public class PlatformsController
{
private readonly AppDbContext _context;

public PlatformsController(AppDbContext context) // ✅ Dependency injected
{
_context = context;
}
}

Control is inverted: The class (in this case a controller) no longer controls how its dependencies are created, that responsibility is inverted to an external container, and the dependency is injected into the class via it's constructor.

The .NET DI Container

ASP.NET Core includes a built-in DI container (also called the service container) that manages object creation and lifetime. It's lightweight, capable, and sufficient for most applications.

The container:

  • Resolves dependencies: Creates objects and their dependencies automatically
  • Manages lifetimes: Controls when objects are created and disposed
  • Handles dependency graphs: Resolves nested dependencies recursively

When your application starts, services are registered in the container. When a class requests a dependency, the container automatically provides an instance.

Request → Controller needs IRepository 
→ Container creates/provides IRepository instance
→ Controller uses IRepository

Registering Services

Services are registered in Program.cs using extension methods on IServiceCollection. This tells the container what to provide when a dependency is requested.

Basic Registration

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddScoped<ICommandRepository, CommandRepository>();
builder.Services.AddSingleton<IConfigService, ConfigService>();
builder.Services.AddTransient<IEmailService, EmailService>();

Registration Patterns

Interface to Implementation:

builder.Services.AddScoped<IPlatformRepository, PlatformRepository>();
// When someone requests IPlatformRepository, provide PlatformRepository

Concrete Types:

builder.Services.AddScoped<AppDbContext>();
// Register the concrete type directly

Factory Functions:

builder.Services.AddScoped<IApiClient>(provider => 
{
var config = provider.GetRequiredService<IConfiguration>();
var apiKey = config["ApiKeys:External"];
return new ApiClient(apiKey);
});

Service Lifetimes

The container manages three distinct lifetimes that control when instances are created and disposed.

Transient

Created every time they're requested.

builder.Services.AddTransient<IEmailService, EmailService>();

Characteristics:

  • New instance per request
  • Disposed after use
  • Lightweight services with no state
  • No shared data between requests

Use for:

  • Stateless services
  • Lightweight operations
  • Services that shouldn't be shared

Example:

public class EmailService : IEmailService
{
public Task SendEmail(string to, string subject, string body)
{
// Send email logic
return Task.CompletedTask;
}
}

Scoped

Created once per HTTP request (or scope).

builder.Services.AddScoped<ICommandRepository, CommandRepository>();
builder.Services.AddScoped<AppDbContext>();

Characteristics:

  • One instance per HTTP request
  • Shared across the request pipeline
  • Disposed at end of request
  • Most common lifetime for web applications

Use for:

  • Database contexts (EF Core)
  • Repositories
  • Unit of Work patterns
  • Request-specific services

Example:

public class CommandRepository : ICommandRepository
{
private readonly AppDbContext _context;

public CommandRepository(AppDbContext context)
{
_context = context; // Same context throughout the request
}
}

Singleton

Created once for the application lifetime.

builder.Services.AddSingleton<ICacheService, CacheService>();

Characteristics:

  • Single instance for entire application
  • Created on first request (or at startup)
  • Never disposed until application shuts down
  • Shared across all requests and users

Use for:

  • Configuration services
  • Caching services
  • Thread-safe stateless services
  • Application-wide services

Example:

public class CacheService : ICacheService
{
private readonly ConcurrentDictionary<string, object> _cache = new();

public void Set(string key, object value) => _cache[key] = value;
public object? Get(string key) => _cache.TryGetValue(key, out var value) ? value : null;
}

Lifetime Comparison

LifetimeCreatedSharedDisposedUse Case
TransientEvery injectionNoAfter useLightweight, stateless services
ScopedPer HTTP requestWithin requestEnd of requestDatabase contexts, repositories
SingletonOnceAcross entire appApplication shutdownCaches, configuration, stateless utilities

Lifetime Best Practices

Safe:

  • Singleton → Singleton ✅
  • Singleton → Transient ✅
  • Scoped → Scoped ✅
  • Scoped → Transient ✅
  • Transient → Transient ✅

Unsafe (Captive Dependencies):

  • Singleton → Scoped ❌ (Scoped service becomes singleton)
  • Singleton → Transient ⚠️ (Transient service becomes singleton)
  • Scoped → Singleton ✅ (But singleton must be thread-safe)

Injecting Dependencies

Dependencies can be injected via 2 methods when using the .NET DI Container

  • Via a Constructor
  • Via a Method

Other methods of injection (e.g. Property Injection) are possible via 3rd party DI containers.

Constructor Injection

public class PlatformsController : ControllerBase
{
private readonly IPlatformRepository _repository;
private readonly ILogger<PlatformsController> _logger;
private readonly IMapper _mapper;

// DI container injects all dependencies
public PlatformsController(
IPlatformRepository repository,
ILogger<PlatformsController> logger,
IMapper mapper)
{
_repository = repository;
_logger = logger;
_mapper = mapper;
}

[HttpGet]
public async Task<ActionResult<IEnumerable<PlatformReadDto>>> GetPlatforms()
{
_logger.LogInformation("Retrieving all platforms");
var platforms = await _repository.GetAllAsync();
return Ok(_mapper.Map<IEnumerable<PlatformReadDto>>(platforms));
}
}

Method Injection

Dependencies can also be injected directly into method parameters. This is particularly useful in minimal APIs or when a dependency is only needed in one or two methods.

Minimal API Example:

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddScoped<IPlatformRepository, PlatformRepository>();

var app = builder.Build();

// Dependencies injected directly into the endpoint method
app.MapGet("/api/platforms", async (IPlatformRepository repository) =>
{
var platforms = await repository.GetAllAsync();
return Results.Ok(platforms);
});

app.MapGet("/api/platforms/{id}", async (int id, IPlatformRepository repository) =>
{
var platform = await repository.GetByIdAsync(id);
return platform is not null ? Results.Ok(platform) : Results.NotFound();
});

app.Run();

Controller Action Injection:

public class PlatformsController : ControllerBase
{
[HttpGet("special")]
public async Task<IActionResult> GetSpecialPlatforms(
[FromServices] IPlatformRepository repository,
[FromServices] ILogger<PlatformsController> logger)
{
logger.LogInformation("Fetching special platforms");
var platforms = await repository.GetSpecialAsync();
return Ok(platforms);
}
}

When to use Method Injection:

  • Dependency needed in only one or two methods
  • Minimal API endpoints
  • Avoiding constructor clutter
  • Optional dependencies for specific actions

Note: Constructor injection is still preferred for dependencies used across multiple methods.

Multiple Dependencies

The container resolves entire dependency graphs:

// Service A depends on B and C
public class ServiceA
{
public ServiceA(IServiceB serviceB, IServiceC serviceC) { }
}

// Service B depends on D
public class ServiceB : IServiceB
{
public ServiceB(IServiceD serviceD) { }
}

// When ServiceA is requested:
// Container creates ServiceD → ServiceB → ServiceC → ServiceA

Resolving Services Manually

Rarely needed, but possible using IServiceProvider:

var app = builder.Build();

using (var scope = app.Services.CreateScope())
{
var repository = scope.ServiceProvider.GetRequiredService<IPlatformRepository>();
// Use repository
}

Benefits of Dependency Injection

Testability: Mock dependencies easily for unit testing

// Test with mock repository
var mockRepo = new Mock<IPlatformRepository>();
var controller = new PlatformsController(mockRepo.Object, ...);
  • Loose Coupling: Classes depend on abstractions, not concrete implementations
  • Maintainability: Change implementations without modifying consumers
  • Flexibility: Swap implementations for different environments (e.g., mock services in development)
  • Lifecycle Management: Container handles creation and disposal automatically

Dependency Injection is fundamental to modern .NET development. It's the backbone of ASP.NET Core's architecture and enables clean, testable, maintainable applications. Understanding how DI works in .NET is fundamental when working with the framework.


Further Reading: