You are viewing a preview of this course. Sign in to start learning

Lesson 2: Dependency Injection and IoC Containers in ASP.NET Core

Deep dive into Dependency Injection patterns, service lifetimes, and IoC container configuration for building maintainable enterprise applications

Lesson 2: Dependency Injection and IoC Containers in ASP.NET Core πŸ’‰

Introduction

Welcome back! In Lesson 1, you mastered async/await patterns. Now we're tackling one of the most critical architectural patterns in modern .NET development: Dependency Injection (DI). πŸ—οΈ

As a developer with 4 years of experience, you've likely used DI, but interview questions often probe deep understanding: service lifetimes, scoping issues, advanced registration patterns, and how the IoC container actually resolves dependencies. This lesson will transform your working knowledge into interview-ready expertise.

Why DI matters in interviews: πŸ’Ό

  • Tests architectural thinking and SOLID principles
  • Essential for ASP.NET Core applications
  • Common source of production bugs (lifetime issues)
  • Gateway to discussing testability and maintainability

Core Concepts 🧠

What is Dependency Injection?

Dependency Injection is a design pattern where objects receive their dependencies from external sources rather than creating them internally. It's an implementation of the Inversion of Control (IoC) principle.

🌍 Real-world analogy: Think of a restaurant kitchen. A chef doesn't grow vegetables, raise cattle, or mill flour. Instead, ingredients are injected into the kitchen by suppliers. The chef focuses on cooking, not procurement. Similarly, your classes focus on business logic while dependencies are provided externally.

❌ WITHOUT DI (Tight Coupling):
+------------------+
|  OrderService    |
|                  |
|  - Creates own   |
|    EmailSender   |----> Hard to test
|  - Creates own   |      Hard to change
|    DbContext     |
+------------------+

βœ… WITH DI (Loose Coupling):
+------------------+
|  OrderService    |
|                  |
|  Receives:       |
|  - IEmailSender  |<---- Injected
|  - IRepository   |<---- Injected
+------------------+
      ↑
      | IoC Container manages creation
      |

The Three Types of Injection 🎯

1. Constructor Injection (Recommended βœ…)

public class OrderService
{
    private readonly IEmailSender _emailSender;
    private readonly IOrderRepository _repository;
    
    // Dependencies injected through constructor
    public OrderService(IEmailSender emailSender, IOrderRepository repository)
    {
        _emailSender = emailSender;
        _repository = repository;
    }
}

2. Property Injection (Rare, optional dependencies)

public class OrderService
{
    public ILogger? Logger { get; set; } // Optional dependency
}

3. Method Injection (Specific operation needs)

public void ProcessOrder(Order order, IPaymentGateway gateway)
{
    // Gateway injected per method call
}

πŸ’‘ Tip: In interviews, emphasize constructor injection. It makes dependencies explicit and ensures objects are always in a valid state.


Service Lifetimes: The Critical Concept ⏰

ASP.NET Core's IoC container supports three service lifetimes. Understanding these is crucial for interviews:

+----------------+---------------------------+----------------------+
| Lifetime       | Created                   | Shared Within        |
+----------------+---------------------------+----------------------+
| Transient      | Every time requested      | Never shared         |
| Scoped         | Once per HTTP request     | Same HTTP request    |
| Singleton      | Once per application      | Entire application   |
+----------------+---------------------------+----------------------+

Transient πŸ”„

  • New instance every time
  • Use for: Lightweight, stateless services
  • Registration: services.AddTransient<IService, Implementation>()

Scoped 🌐

  • One instance per HTTP request (or scope)
  • Use for: Database contexts, request-specific data
  • Registration: services.AddScoped<IService, Implementation>()
  • ⚠️ Most common lifetime for business logic services

Singleton 🎯

  • One instance for entire application lifetime
  • Use for: Caches, configuration, stateless utilities
  • Registration: services.AddSingleton<IService, Implementation>()
  • ⚠️ Must be thread-safe!

🧠 Mnemonic: TSS = Traffic Stop Sign

  • Transient: Like traffic - always moving, new cars
  • Scoped: Stop at each intersection (request)
  • Singleton: Sign stays forever

The Captive Dependency Problem 🚨

This is a favorite interview topic because it reveals deep understanding:

⚠️ DANGER: Captive Dependency

+------------------+
| Singleton        |
|  (Lives forever) |
|       |          |
|       v          |
|  Scoped Service  |<--- PROBLEM!
|  (Should die     |
|   after request) |
+------------------+

The scoped service is "captured" by the singleton
and lives longer than intended!

The Rule: A service can only depend on services with equal or longer lifetimes.

βœ… ALLOWED:
Singleton β†’ Singleton
Scoped β†’ Scoped or Singleton
Transient β†’ Any

❌ FORBIDDEN:
Singleton β†’ Scoped or Transient
Scoped β†’ Transient (risky, but allowed)

Registration Patterns πŸ“‹

1. Interface to Implementation

services.AddScoped<IOrderService, OrderService>();

2. Concrete Type Only

services.AddScoped<OrderService>(); // No interface

3. Factory Pattern

services.AddScoped<IOrderService>(sp => 
{
    var logger = sp.GetRequiredService<ILogger<OrderService>>();
    var config = sp.GetRequiredService<IConfiguration>();
    return new OrderService(logger, config["ApiKey"]);
});

4. Multiple Implementations

services.AddScoped<INotificationService, EmailNotificationService>();
services.AddScoped<INotificationService, SmsNotificationService>();
// Last registration wins, unless you use IEnumerable<INotificationService>

5. Open Generics

services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// Registers IRepository<T> for any T

Detailed Examples πŸ’»

Example 1: Building a Repository Pattern with DI

// 1. Define abstractions
public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IEnumerable<T>> GetAllAsync();
    Task AddAsync(T entity);
}

public interface IUnitOfWork
{
    IRepository<Order> Orders { get; }
    IRepository<Customer> Customers { get; }
    Task<int> SaveChangesAsync();
}

// 2. Implement with EF Core
public class Repository<T> : IRepository<T> where T : class
{
    private readonly ApplicationDbContext _context;
    private readonly DbSet<T> _dbSet;
    
    public Repository(ApplicationDbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }
    
    public async Task<T?> GetByIdAsync(int id)
    {
        return await _dbSet.FindAsync(id);
    }
    
    public async Task<IEnumerable<T>> GetAllAsync()
    {
        return await _dbSet.ToListAsync();
    }
    
    public async Task AddAsync(T entity)
    {
        await _dbSet.AddAsync(entity);
    }
}

public class UnitOfWork : IUnitOfWork
{
    private readonly ApplicationDbContext _context;
    private IRepository<Order>? _orders;
    private IRepository<Customer>? _customers;
    
    public UnitOfWork(ApplicationDbContext context)
    {
        _context = context;
    }
    
    public IRepository<Order> Orders => 
        _orders ??= new Repository<Order>(_context);
    
    public IRepository<Customer> Customers => 
        _customers ??= new Repository<Customer>(_context);
    
    public async Task<int> SaveChangesAsync()
    {
        return await _context.SaveChangesAsync();
    }
}

// 3. Register in Program.cs
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();

// 4. Use in a service
public class OrderService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<OrderService> _logger;
    
    public OrderService(IUnitOfWork unitOfWork, ILogger<OrderService> logger)
    {
        _unitOfWork = unitOfWork;
        _logger = logger;
    }
    
    public async Task<Order> CreateOrderAsync(int customerId, List<OrderItem> items)
    {
        var customer = await _unitOfWork.Customers.GetByIdAsync(customerId);
        if (customer == null)
            throw new ArgumentException("Customer not found");
        
        var order = new Order 
        { 
            CustomerId = customerId, 
            Items = items,
            OrderDate = DateTime.UtcNow 
        };
        
        await _unitOfWork.Orders.AddAsync(order);
        await _unitOfWork.SaveChangesAsync();
        
        _logger.LogInformation("Order {OrderId} created for customer {CustomerId}", 
            order.Id, customerId);
        
        return order;
    }
}

Why this works:

  • DbContext is scoped (lives for one request)
  • Repository<T> is scoped (matches DbContext lifetime)
  • UnitOfWork is scoped (coordinates repositories)
  • OrderService is scoped (uses scoped dependencies)

Example 2: Service Lifetime Demonstration

// Services with different lifetimes
public interface IOperationService
{
    Guid OperationId { get; }
}

public class OperationService : IOperationService
{
    public Guid OperationId { get; } = Guid.NewGuid();
}

// Registration
builder.Services.AddTransient<IOperationService, OperationService>(); // Named TransientOperation
builder.Services.AddScoped<IOperationService, OperationService>();    // Named ScopedOperation
builder.Services.AddSingleton<IOperationService, OperationService>(); // Named SingletonOperation

// Better approach with distinct interfaces:
public interface ITransientOperation : IOperationService { }
public interface IScopedOperation : IOperationService { }
public interface ISingletonOperation : IOperationService { }

public class Operation : ITransientOperation, IScopedOperation, ISingletonOperation
{
    public Guid OperationId { get; } = Guid.NewGuid();
}

// Registration
builder.Services.AddTransient<ITransientOperation, Operation>();
builder.Services.AddScoped<IScopedOperation, Operation>();
builder.Services.AddSingleton<ISingletonOperation, Operation>();

// Controller to test
[ApiController]
[Route("api/[controller]")]
public class LifetimeController : ControllerBase
{
    private readonly ITransientOperation _transient1;
    private readonly ITransientOperation _transient2;
    private readonly IScopedOperation _scoped1;
    private readonly IScopedOperation _scoped2;
    private readonly ISingletonOperation _singleton1;
    private readonly ISingletonOperation _singleton2;
    
    public LifetimeController(
        ITransientOperation transient1,
        ITransientOperation transient2,
        IScopedOperation scoped1,
        IScopedOperation scoped2,
        ISingletonOperation singleton1,
        ISingletonOperation singleton2)
    {
        _transient1 = transient1;
        _transient2 = transient2;
        _scoped1 = scoped1;
        _scoped2 = scoped2;
        _singleton1 = singleton1;
        _singleton2 = singleton2;
    }
    
    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            Transient1 = _transient1.OperationId,
            Transient2 = _transient2.OperationId,  // DIFFERENT from Transient1
            Scoped1 = _scoped1.OperationId,
            Scoped2 = _scoped2.OperationId,        // SAME as Scoped1
            Singleton1 = _singleton1.OperationId,
            Singleton2 = _singleton2.OperationId   // SAME as Singleton1 (and across requests)
        });
    }
}

Expected output:

{
  "transient1": "a1b2c3d4-...",
  "transient2": "e5f6g7h8-...",  // Different!
  "scoped1": "i9j0k1l2-...",
  "scoped2": "i9j0k1l2-...",     // Same within request
  "singleton1": "m3n4o5p6-...",
  "singleton2": "m3n4o5p6-..."   // Same across all requests
}

Example 3: Resolving Dependencies Manually (ServiceProvider)

// Sometimes you need to resolve dependencies manually
// (though constructor injection is preferred)

public class PaymentProcessorFactory
{
    private readonly IServiceProvider _serviceProvider;
    
    public PaymentProcessorFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public IPaymentProcessor GetProcessor(PaymentMethod method)
    {
        return method switch
        {
            PaymentMethod.CreditCard => 
                _serviceProvider.GetRequiredService<ICreditCardProcessor>(),
            PaymentMethod.PayPal => 
                _serviceProvider.GetRequiredService<IPayPalProcessor>(),
            PaymentMethod.BankTransfer => 
                _serviceProvider.GetRequiredService<IBankTransferProcessor>(),
            _ => throw new ArgumentException("Invalid payment method")
        };
    }
}

// Alternative: Using IEnumerable for multiple implementations
public class NotificationService
{
    private readonly IEnumerable<INotificationProvider> _providers;
    
    public NotificationService(IEnumerable<INotificationProvider> providers)
    {
        _providers = providers;
    }
    
    public async Task NotifyAllAsync(string message)
    {
        var tasks = _providers.Select(p => p.SendAsync(message));
        await Task.WhenAll(tasks);
    }
}

// Registration
builder.Services.AddScoped<INotificationProvider, EmailNotificationProvider>();
builder.Services.AddScoped<INotificationProvider, SmsNotificationProvider>();
builder.Services.AddScoped<INotificationProvider, PushNotificationProvider>();
// All three will be injected into IEnumerable<INotificationProvider>

Example 4: Testing with DI

// Original service
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailSender _emailSender;
    
    public OrderService(IOrderRepository repository, IEmailSender emailSender)
    {
        _repository = repository;
        _emailSender = emailSender;
    }
    
    public async Task<bool> ProcessOrderAsync(Order order)
    {
        await _repository.SaveAsync(order);
        await _emailSender.SendOrderConfirmationAsync(order);
        return true;
    }
}

// Unit test with mocks (using Moq)
[Fact]
public async Task ProcessOrder_Should_SaveAndSendEmail()
{
    // Arrange
    var mockRepository = new Mock<IOrderRepository>();
    var mockEmailSender = new Mock<IEmailSender>();
    
    var order = new Order { Id = 1, Total = 100m };
    
    mockRepository
        .Setup(r => r.SaveAsync(It.IsAny<Order>()))
        .ReturnsAsync(true);
    
    mockEmailSender
        .Setup(e => e.SendOrderConfirmationAsync(It.IsAny<Order>()))
        .ReturnsAsync(true);
    
    var service = new OrderService(
        mockRepository.Object, 
        mockEmailSender.Object);
    
    // Act
    var result = await service.ProcessOrderAsync(order);
    
    // Assert
    Assert.True(result);
    mockRepository.Verify(r => r.SaveAsync(order), Times.Once);
    mockEmailSender.Verify(e => e.SendOrderConfirmationAsync(order), Times.Once);
}

πŸ’‘ Tip: In interviews, explain how DI makes testing easier by allowing mock implementations.


Common Mistakes ⚠️

1. Captive Dependencies (Lifetime Violations)

❌ WRONG:
public class SingletonService  // Singleton
{
    private readonly ApplicationDbContext _context;  // DbContext is scoped!
    
    public SingletonService(ApplicationDbContext context)
    {
        _context = context;  // DANGER: Scoped service captured by singleton
    }
}

βœ… CORRECT:
public class SingletonService
{
    private readonly IServiceProvider _serviceProvider;
    
    public SingletonService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public async Task DoWorkAsync()
    {
        using var scope = _serviceProvider.CreateScope();
        var context = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
        // Use context within scope
    }
}

2. Disposing Singleton Services

❌ WRONG:
builder.Services.AddSingleton<IDisposable, MyDisposableService>();
// Container will dispose it when application stops, not after each use!

βœ… CORRECT:
builder.Services.AddScoped<IDisposable, MyDisposableService>();
// Disposed at end of each request/scope

3. Not Making Singletons Thread-Safe

❌ WRONG:
public class CacheService  // Registered as Singleton
{
    private Dictionary<string, object> _cache = new();  // NOT thread-safe!
    
    public void Set(string key, object value)
    {
        _cache[key] = value;  // Race condition!
    }
}

βœ… CORRECT:
public class CacheService
{
    private readonly ConcurrentDictionary<string, object> _cache = new();
    
    public void Set(string key, object value)
    {
        _cache[key] = value;  // Thread-safe
    }
}

4. Service Locator Anti-Pattern

❌ WRONG (Service Locator):
public class OrderService
{
    private readonly IServiceProvider _serviceProvider;
    
    public OrderService(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }
    
    public void ProcessOrder()
    {
        var repo = _serviceProvider.GetRequiredService<IOrderRepository>();
        // Hides dependencies, hard to test
    }
}

βœ… CORRECT (Constructor Injection):
public class OrderService
{
    private readonly IOrderRepository _repository;
    
    public OrderService(IOrderRepository repository)  // Explicit dependency
    {
        _repository = repository;
    }
}

5. Forgetting to Register Services

❌ Runtime Error:
public class MyController : ControllerBase
{
    private readonly IMyService _service;
    
    public MyController(IMyService service)  // InvalidOperationException!
    {
        _service = service;
    }
}
// Forgot: builder.Services.AddScoped<IMyService, MyService>();

πŸ’‘ Use AddControllers() which registers controllers automatically,
   but you must manually register your services!

Advanced Concepts πŸš€

Scrutor for Convention-Based Registration

// Instead of registering each service manually:
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IProductService, ProductService>();
// ... 50 more services

// Use Scrutor (NuGet: Scrutor):
builder.Services.Scan(scan => scan
    .FromAssemblyOf<IOrderService>()
    .AddClasses(classes => classes.Where(type => type.Name.EndsWith("Service")))
    .AsImplementedInterfaces()
    .WithScopedLifetime());

Keyed Services (.NET 8+)

// Register multiple implementations with keys
builder.Services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("stripe");
builder.Services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("paypal");

// Inject specific implementation
public class PaymentController : ControllerBase
{
    private readonly IPaymentProcessor _processor;
    
    public PaymentController([FromKeyedServices("stripe")] IPaymentProcessor processor)
    {
        _processor = processor;
    }
}

Options Pattern

// appsettings.json
{
  "Email": {
    "SmtpServer": "smtp.gmail.com",
    "Port": 587
  }
}

// Configuration class
public class EmailOptions
{
    public string SmtpServer { get; set; } = string.Empty;
    public int Port { get; set; }
}

// Registration
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Email"));

// Usage
public class EmailService
{
    private readonly EmailOptions _options;
    
    public EmailService(IOptions<EmailOptions> options)
    {
        _options = options.Value;
    }
    
    public void SendEmail()
    {
        // Use _options.SmtpServer, _options.Port
    }
}

Key Takeaways 🎯

  1. Constructor injection is the preferred methodβ€”makes dependencies explicit and testable

  2. Service lifetimes matter:

    • Transient: New every time
    • Scoped: One per request
    • Singleton: One for entire app (must be thread-safe)
  3. Lifetime rule: Services can only depend on equal or longer lifetimes

  4. Captive dependencies are a common bugβ€”watch for singletons holding scoped services

  5. Avoid Service Locatorβ€”inject dependencies explicitly through constructors

  6. DI enables SOLID principles, particularly Dependency Inversion

  7. IServiceProvider is for advanced scenariosβ€”prefer constructor injection

  8. Always register services before using them (in Program.cs)

  9. Use IEnumerable to inject all implementations of an interface

  10. DI makes unit testing straightforward with mock implementations


Interview Tips πŸ’Ό

πŸ€” Did you know? The ASP.NET Core DI container is intentionally simple. For advanced features (property injection, named registrations, interceptors), many teams use third-party containers like Autofac, although .NET 8's keyed services address many of these needs.

Common interview questions:

  • "Explain the difference between AddScoped and AddSingleton"
  • "What is a captive dependency and why is it problematic?"
  • "How would you test a service that depends on DbContext?"
  • "When would you inject IServiceProvider directly?"

🧠 Remember: Think in terms of object lifecycles and dependency graphs. Draw diagrams if asked!


πŸ“š Further Study

  1. Microsoft Docs - Dependency Injection in ASP.NET Core:
    https://learn.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection

  2. Martin Fowler - Inversion of Control Containers:
    https://martinfowler.com/articles/injection.html

  3. Steve Smith - Service Lifetimes:
    https://ardalis.com/understanding-service-lifetimes-in-asp-net-core/


πŸ“‹ Quick Reference Card

╔══════════════════════════════════════════════════════════════╗
β•‘            DEPENDENCY INJECTION CHEAT SHEET                  β•‘
╠══════════════════════════════════════════════════════════════╣
β•‘ LIFETIMES:                                                   β•‘
β•‘ β€’ Transient   β†’ New instance every time                      β•‘
β•‘ β€’ Scoped      β†’ One per request/scope                        β•‘
β•‘ β€’ Singleton   β†’ One per application (thread-safe!)           β•‘
β•‘                                                              β•‘
β•‘ REGISTRATION:                                                β•‘
β•‘ β€’ AddTransient<IService, Implementation>()                   β•‘
β•‘ β€’ AddScoped<IService, Implementation>()                      β•‘
β•‘ β€’ AddSingleton<IService, Implementation>()                   β•‘
β•‘                                                              β•‘
β•‘ LIFETIME RULES:                                              β•‘
β•‘ βœ… Singleton  β†’ Singleton                                    β•‘
β•‘ βœ… Scoped     β†’ Scoped, Singleton                            β•‘
β•‘ βœ… Transient  β†’ Any                                          β•‘
β•‘ ❌ Singleton  β†’ Scoped, Transient (captive dependency!)      β•‘
β•‘                                                              β•‘
β•‘ BEST PRACTICES:                                              β•‘
β•‘ β€’ Prefer constructor injection                               β•‘
β•‘ β€’ Avoid Service Locator pattern                              β•‘
β•‘ β€’ Make singletons thread-safe                                β•‘
β•‘ β€’ Use IEnumerable<T> for multiple implementations            β•‘
β•‘ β€’ Test with mock implementations                             β•‘
β•‘                                                              β•‘
β•‘ COMMON REGISTRATIONS:                                        β•‘
β•‘ β€’ DbContext              β†’ Scoped                            β•‘
β•‘ β€’ HttpClient             β†’ Use IHttpClientFactory            β•‘
β•‘ β€’ Logging                β†’ Singleton                         β•‘
β•‘ β€’ Business Services      β†’ Scoped                            β•‘
β•‘ β€’ Caching                β†’ Singleton                         β•‘
β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•