Dependency Injection

What is Dependency Injection?

Dependency Injection (DI) is a design pattern that allows you to provide dependencies to a class from the outside, rather than having the class create them itself. Think of it as “don’t call us, we’ll call you” - instead of your classes reaching out to get what they need, the dependencies are handed to them.

Here’s the simplest way to understand it:

// Without DI - class creates its own dependencies
public class OrderService
{
    private readonly EmailService _emailService;

    public OrderService()
    {
        _emailService = new EmailService(); // Tightly coupled!
    }
}

// With DI - dependencies are injected
public class OrderService
{
    private readonly IEmailService _emailService;

    public OrderService(IEmailService emailService)
    {
        _emailService = emailService; // Loosely coupled!
    }
}

The key difference? In the second example, OrderService doesn’t know or care about the concrete implementation of IEmailService - it just knows it needs something that can send emails.

Why Use Dependency Injection?

DI solves several fundamental problems in software development:

1. Tight Coupling

Without DI, your classes become tightly coupled to their dependencies:

public class ProductService
{
    private readonly SqlProductRepository _repository;
    private readonly SmtpEmailService _emailService;
    private readonly FileLogger _logger;

    public ProductService()
    {
        // Hardcoded dependencies - what if requirements change?
        _repository = new SqlProductRepository("connectionString");
        _emailService = new SmtpEmailService("smtp.company.com");
        _logger = new FileLogger("C:\\logs\\products.log");
    }
}

This class is impossible to test and inflexible. Want to switch from SQL to MongoDB? You have to modify the class.

2. Testing Difficulties

How do you unit test the class above? You can’t mock the dependencies because they’re created internally:

// With DI - easy to test
public class ProductService
{
    private readonly IProductRepository _repository;
    private readonly IEmailService _emailService;
    private readonly ILogger<ProductService> _logger;

    public ProductService(
        IProductRepository repository,
        IEmailService emailService,
        ILogger<ProductService> logger)
    {
        _repository = repository;
        _emailService = emailService;
        _logger = logger;
    }
}

// Testing becomes straightforward
[Test]
public async Task CreateProduct_ShouldSendEmail()
{
    // Arrange
    var mockRepo = new Mock<IProductRepository>();
    var mockEmail = new Mock<IEmailService>();
    var mockLogger = new Mock<ILogger<ProductService>>();
    
    var service = new ProductService(mockRepo.Object, mockEmail.Object, mockLogger.Object);
    
    // Act & Assert - now we can verify interactions
}

3. Configuration Flexibility

DI allows you to configure different implementations based on environment or requirements:

// Development: use in-memory database and console logging
services.AddSingleton<IProductRepository, InMemoryProductRepository>();
services.AddSingleton<ILogger, ConsoleLogger>();

// Production: use SQL database and file logging
services.AddSingleton<IProductRepository, SqlProductRepository>();
services.AddSingleton<ILogger, FileLogger>();

DI in .NET - The Built-In Container

.NET has a built-in DI container that’s configured in Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Register services with the DI container
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddScoped<IProductRepository, SqlProductRepository>();
builder.Services.AddScoped<IEmailService, EmailService>();

// Add framework services
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

var app = builder.Build();

Service Lifetimes

The .NET DI container supports three main service lifetimes:

Transient

A new instance is created every time the service is requested:

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

// Every time you ask for IEmailService, you get a new EmailService instance
// Good for: Lightweight, stateless services
// Bad for: Services that hold state or are expensive to create

Scoped

One instance per request (in web applications):

builder.Services.AddScoped<IProductService, ProductService>();

// Same instance throughout a single HTTP request
// New instance for each new HTTP request
// Good for: Services that should maintain state within a request
// Most common choice for business logic services

Singleton

One instance for the entire application lifetime:

builder.Services.AddSingleton<IConfiguration, Configuration>();

// Same instance always, created once and reused
// Good for: Expensive-to-create objects, stateless services, configuration
// Bad for: Services that hold request-specific state

Real-World API Example

Here’s how DI typically looks in a .NET API:

1. Define Interfaces

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(Guid id);
    Task<IEnumerable<Product>> GetAllAsync();
    Task<Product> CreateAsync(Product product);
    Task UpdateAsync(Product product);
    Task DeleteAsync(Guid id);
}

public interface IProductService
{
    Task<ProductDto?> GetProductAsync(Guid id);
    Task<IEnumerable<ProductDto>> GetAllProductsAsync();
    Task<ProductDto> CreateProductAsync(CreateProductRequest request);
    Task UpdateProductAsync(Guid id, UpdateProductRequest request);
    Task DeleteProductAsync(Guid id);
}

2. Implement Services

public class ProductService : IProductService
{
    private readonly IProductRepository _repository;
    private readonly IMapper _mapper;
    private readonly ILogger<ProductService> _logger;

    public ProductService(
        IProductRepository repository,
        IMapper mapper,
        ILogger<ProductService> logger)
    {
        _repository = repository;
        _mapper = mapper;
        _logger = logger;
    }

    public async Task<ProductDto?> GetProductAsync(Guid id)
    {
        _logger.LogInformation("Retrieving product {ProductId}", id);
        
        var product = await _repository.GetByIdAsync(id);
        
        if (product == null)
        {
            _logger.LogWarning("Product {ProductId} not found", id);
            return null;
        }

        return _mapper.Map<ProductDto>(product);
    }

    // Other methods...
}

3. Use in Controllers

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    // DI container automatically injects IProductService
    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

    [HttpGet("{id}")]
    public async Task<ActionResult<ProductDto>> GetProduct(Guid id)
    {
        var product = await _productService.GetProductAsync(id);
        
        if (product == null)
            return NotFound();
            
        return Ok(product);
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> CreateProduct(CreateProductRequest request)
    {
        var product = await _productService.CreateProductAsync(request);
        return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
    }
}

4. Register Everything

var builder = WebApplication.CreateBuilder(args);

// Add framework services
builder.Services.AddControllers();
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));

// Add application services
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IProductService, ProductService>();

// Add AutoMapper
builder.Services.AddAutoMapper(typeof(Program));

var app = builder.Build();

Advanced DI Patterns

Factory Pattern with DI

Sometimes you need to create objects at runtime:

public interface INotificationServiceFactory
{
    INotificationService CreateService(NotificationType type);
}

public class NotificationServiceFactory : INotificationServiceFactory
{
    private readonly IServiceProvider _serviceProvider;

    public NotificationServiceFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public INotificationService CreateService(NotificationType type)
    {
        return type switch
        {
            NotificationType.Email => _serviceProvider.GetRequiredService<IEmailService>(),
            NotificationType.Sms => _serviceProvider.GetRequiredService<ISmsService>(),
            NotificationType.Push => _serviceProvider.GetRequiredService<IPushService>(),
            _ => throw new ArgumentException($"Unknown notification type: {type}")
        };
    }
}

Conditional Registration

Register different implementations based on configuration:

var builder = WebApplication.CreateBuilder(args);

// Register based on environment
if (builder.Environment.IsDevelopment())
{
    builder.Services.AddScoped<IEmailService, FakeEmailService>();
    builder.Services.AddScoped<IPaymentService, FakePaymentService>();
}
else
{
    builder.Services.AddScoped<IEmailService, SmtpEmailService>();
    builder.Services.AddScoped<IPaymentService, StripePaymentService>();
}

Decorator Pattern

Add behavior to existing services:

// Original service
public class ProductService : IProductService { /* implementation */ }

// Decorator that adds caching
public class CachedProductService : IProductService
{
    private readonly IProductService _inner;
    private readonly IMemoryCache _cache;

    public CachedProductService(IProductService inner, IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<ProductDto?> GetProductAsync(Guid id)
    {
        var cacheKey = $"product_{id}";
        
        if (_cache.TryGetValue(cacheKey, out ProductDto? cachedProduct))
            return cachedProduct;

        var product = await _inner.GetProductAsync(id);
        
        if (product != null)
            _cache.Set(cacheKey, product, TimeSpan.FromMinutes(5));

        return product;
    }
}

// Registration
builder.Services.AddScoped<ProductService>(); // Concrete class
builder.Services.AddScoped<IProductService>(provider =>
    new CachedProductService(
        provider.GetRequiredService<ProductService>(),
        provider.GetRequiredService<IMemoryCache>()));

Configuration and Options Pattern

Inject configuration using the Options pattern:

// Configuration class
public class EmailSettings
{
    public string SmtpServer { get; set; } = "";
    public int Port { get; set; }
    public string Username { get; set; } = "";
    public string Password { get; set; } = "";
}

// Service that uses configuration
public class EmailService : IEmailService
{
    private readonly EmailSettings _settings;
    private readonly ILogger<EmailService> _logger;

    public EmailService(IOptions<EmailSettings> settings, ILogger<EmailService> logger)
    {
        _settings = settings.Value;
        _logger = logger;
    }

    public async Task SendEmailAsync(string to, string subject, string body)
    {
        _logger.LogInformation("Sending email to {Recipient} via {SmtpServer}", 
            to, _settings.SmtpServer);
        
        // Email sending implementation
    }
}

// Registration
builder.Services.Configure<EmailSettings>(
    builder.Configuration.GetSection("EmailSettings"));
builder.Services.AddScoped<IEmailService, EmailService>();

Third-Party DI Containers

While .NET’s built-in container is sufficient for most scenarios, you might consider third-party containers for advanced features:

Autofac

// More advanced registration scenarios
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());
builder.Host.ConfigureContainer<ContainerBuilder>(containerBuilder =>
{
    containerBuilder.RegisterType<ProductService>().As<IProductService>()
        .InstancePerLifetimeScope()
        .WithParameter("maxRetries", 3);
});

Ninject, Unity, StructureMap

Each offers different features like convention-based registration, advanced interception, etc.

Best Practices

1. Use Interfaces

Always depend on abstractions, not concretions:

// Good
public ProductService(IProductRepository repository) { }

// Bad
public ProductService(SqlProductRepository repository) { }

2. Avoid Service Locator Anti-Pattern

Don’t inject IServiceProvider everywhere:

// Anti-pattern - avoid this
public class OrderService
{
    public OrderService(IServiceProvider serviceProvider)
    {
        // This hides dependencies and makes testing harder
    }
}

// Better - explicit dependencies
public class OrderService
{
    public OrderService(IEmailService emailService, IPaymentService paymentService)
    {
        // Dependencies are clear and testable
    }
}

3. Keep Constructors Simple

Don’t do work in constructors:

// Bad
public class ProductService
{
    public ProductService(IProductRepository repository)
    {
        // Don't do this in constructor
        var products = repository.GetAllAsync().Result;
        ProcessProducts(products);
    }
}

// Good
public class ProductService
{
    private readonly IProductRepository _repository;

    public ProductService(IProductRepository repository)
    {
        _repository = repository; // Simple assignment only
    }
}

4. Be Mindful of Lifetimes

Understand when to use each lifetime:

// Singleton - expensive to create, stateless
builder.Services.AddSingleton<IMemoryCache, MemoryCache>();

// Scoped - per request, can hold request state
builder.Services.AddScoped<IOrderService, OrderService>();

// Transient - cheap to create, stateless
builder.Services.AddTransient<IEmailValidator, EmailValidator>();

Common Pitfalls

1. Captive Dependencies

Don’t inject shorter-lived services into longer-lived ones:

// Dangerous - Singleton holding onto Scoped service
builder.Services.AddSingleton<ISomeService>(provider =>
{
    var scopedService = provider.GetRequiredService<IScopedService>(); // Bad!
    return new SomeService(scopedService);
});

2. Circular Dependencies

Avoid services that depend on each other:

// This will cause a runtime exception
public class ServiceA
{
    public ServiceA(IServiceB serviceB) { }
}

public class ServiceB
{
    public ServiceB(IServiceA serviceA) { } // Circular dependency!
}

3. Over-Registration

Don’t register everything - some things should be created directly:

// Don't register simple DTOs or value objects
// builder.Services.AddTransient<ProductDto>(); // No!

// Do register services with behavior
builder.Services.AddScoped<IProductService, ProductService>(); // Yes!

Wrap Up

Dependency Injection is fundamental to modern .NET development. It makes your code more testable, flexible, and maintainable by removing tight coupling between classes.

The key concepts to remember:

  • Depend on abstractions (interfaces) not concretions
  • Inject dependencies rather than creating them
  • Understand lifetimes and choose appropriately
  • Keep constructors simple - they’re for setting up state, not doing work

Once you embrace DI, you’ll wonder how you ever built applications without it. Your code becomes easier to test, easier to change, and easier to understand. It’s one of those patterns that, once learned, becomes as natural as breathing in your day-to-day development work.