avatar

Le Do Nghiem

Software Engineer

  • About me
  • Books
  • Snippets
  • Blog

© 2026 Le Do Nghiem. All rights reserved.

Contact |

Back to Blog

Dependency Injection in ASP.NET Core

avatar
Le Do NghiemSoftware Engineer
2025-09-10 11 min read

Introduction

Dependency Injection (DI) is a fundamental design pattern and a core feature of ASP.NET Core that enables you to write more maintainable, testable, and loosely coupled applications. Instead of having classes create their own dependencies directly, dependencies are "injected" from the outside, typically through constructors, properties, or method parameters.

This inversion of control (IoC) pattern has been a cornerstone of modern software architecture for decades, and ASP.NET Core was built with DI in mind from the ground up. The framework includes a lightweight, built-in IoC container that makes implementing DI straightforward, though you can also integrate third-party containers like Autofac, Unity, or Ninject for more advanced scenarios.

In this comprehensive guide, we'll explore how to leverage dependency injection effectively in your ASP.NET Core applications, covering everything from basic registration to advanced patterns and best practices.

Understanding Dependency Injection

The Problem Without DI

Consider a traditional approach where classes create their dependencies directly:

public class UserService
{
    private readonly EmailService _emailService;
    private readonly DatabaseContext _context;

    public UserService()
    {
        _emailService = new EmailService();
        _context = new DatabaseContext();
    }

    public async Task CreateUserAsync(User user)
    {
        await _context.Users.AddAsync(user);
        await _context.SaveChangesAsync();
        await _emailService.SendWelcomeEmailAsync(user.Email);
    }
}

This approach has several problems:

  • Tight Coupling: UserService is directly dependent on concrete implementations, making it difficult to change or extend.
  • Hard to Test: You can't easily substitute EmailService or DatabaseContext with mock objects for unit testing.
  • Limited Flexibility: Changing the email provider or database requires modifying the UserService class.
  • Violates SOLID Principles: Specifically the Dependency Inversion Principle (DIP), which states that high-level modules should not depend on low-level modules; both should depend on abstractions.

The Solution: Dependency Injection

With dependency injection, dependencies are provided from outside:

public interface IEmailService
{
    Task SendWelcomeEmailAsync(string email);
}

public interface IUserRepository
{
    Task AddUserAsync(User user);
    Task SaveChangesAsync();
}

public class UserService
{
    private readonly IEmailService _emailService;
    private readonly IUserRepository _userRepository;

    public UserService(IEmailService emailService, IUserRepository userRepository)
    {
        _emailService = emailService ?? throw new ArgumentNullException(nameof(emailService));
        _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
    }

    public async Task CreateUserAsync(User user)
    {
        await _userRepository.AddUserAsync(user);
        await _userRepository.SaveChangesAsync();
        await _emailService.SendWelcomeEmailAsync(user.Email);
    }
}

Now UserService depends on abstractions (IEmailService and IUserRepository), making it:

  • Easier to test (you can inject mock implementations)
  • More flexible (you can swap implementations without changing UserService)
  • More maintainable (dependencies are explicit and clear)

The ASP.NET Core DI Container

ASP.NET Core includes a built-in, lightweight dependency injection container that is sufficient for most applications. The container manages the creation and lifetime of service instances, automatically resolving dependencies when you request a service.

Basic Service Registration

Services are registered in the Program.cs file (or Startup.cs in older versions) using the IServiceCollection:

using Microsoft.AspNetCore.Builder;
using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IUserRepository, UserRepository>();

var app = builder.Build();

app.MapGet("/user/{id}", async (int id, IUserService userService) => {
    var user = await userService.GetUserByIdAsync(id);
    return user != null ? Results.Ok(user) : Results.NotFound();
});

app.Run();

Registration Methods

The IServiceCollection interface provides several extension methods for registering services:

1. Interface to Implementation

builder.Services.AddScoped<IUserService, UserService>();

This registers UserService as the implementation for IUserService. When a component requests IUserService, the container will create and inject an instance of UserService.

2. Direct Type Registration

builder.Services.AddScoped<UserService>();

You can register a concrete type directly, though this makes it harder to swap implementations later. This is useful for types that don't have interfaces or are not meant to be abstracted.

3. Factory Registration

For services that require complex initialization, you can use a factory function:

builder.Services.AddScoped<IEmailService>(serviceProvider => 
{
    var configuration = serviceProvider.GetRequiredService<IConfiguration>();
    var smtpServer = configuration["Email:SmtpServer"];
    return new EmailService(smtpServer);
});

4. Multiple Implementations

You can register multiple implementations and retrieve them as a collection:

builder.Services.AddScoped<IMessageSender, EmailSender>();
builder.Services.AddScoped<IMessageSender, SmsSender>();
builder.Services.AddScoped<IMessageSender, PushNotificationSender>();

// Later, inject IEnumerable<IMessageSender> to get all implementations
public class NotificationService
{
    private readonly IEnumerable<IMessageSender> _senders;

    public NotificationService(IEnumerable<IMessageSender> senders)
    {
        _senders = senders;
    }

    public async Task SendNotificationAsync(string message)
    {
        foreach (var sender in _senders)
        {
            await sender.SendAsync(message);
        }
    }
}

5. Options Pattern with DI

The options pattern is commonly used with dependency injection:

// In Program.cs
builder.Services.Configure<EmailOptions>(builder.Configuration.GetSection("Email"));

// In your service
public class EmailService : IEmailService
{
    private readonly EmailOptions _options;

    public EmailService(IOptions<EmailOptions> options)
    {
        _options = options.Value;
    }
}

Service Lifetimes

One of the most important aspects of dependency injection is understanding service lifetimes. ASP.NET Core supports three lifetimes, each serving different purposes:

1. Transient

A new instance is created every time the service is requested. This is the most lightweight option.

builder.Services.AddTransient<IValidator, UserValidator>();

Use Transient when:

  • The service is stateless
  • The service is lightweight and cheap to create
  • Each operation needs a fresh instance

Example:

public class DataProcessor
{
    private readonly IValidator _validator;

    public DataProcessor(IValidator validator)
    {
        _validator = validator;
    }

    public void Process(string data)
    {
        if (_validator.IsValid(data))
        {
            // Process data
        }
    }
}

2. Scoped

A new instance is created once per HTTP request (or per scope in non-web scenarios). The same instance is reused throughout the request lifecycle.

builder.Services.AddScoped<IUserRepository, UserRepository>();

Use Scoped when:

  • The service holds request-specific state
  • The service needs to share data across components in the same request
  • You're working with Entity Framework DbContext (which should always be scoped)

Example:

public class OrderService
{
    private readonly IOrderRepository _orderRepository;
    private readonly IPaymentService _paymentService;

    public OrderService(IOrderRepository orderRepository, IPaymentService paymentService)
    {
        _orderRepository = orderRepository;
        _paymentService = paymentService;
    }

    public async Task<Order> CreateOrderAsync(Order order)
    {
        // Both repository and payment service share the same DbContext instance
        // if they both depend on it, ensuring transactional consistency
        await _orderRepository.AddAsync(order);
        await _paymentService.ProcessPaymentAsync(order.Payment);
        return order;
    }
}

3. Singleton

A single instance is created for the entire application lifetime. The same instance is reused across all requests.

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

Use Singleton when:

  • The service is stateless and thread-safe
  • The service is expensive to create
  • The service needs to maintain application-wide state (like a cache or configuration)

Example:

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

    public void Set(string key, object value)
    {
        _cache[key] = value;
    }

    public T Get<T>(string key)
    {
        return _cache.TryGetValue(key, out var value) ? (T)value : default(T);
    }
}

⚠️ Important Warning: Never inject a scoped service into a singleton service directly, as this can cause issues with service disposal and state management. If you must use a scoped service within a singleton, use IServiceScopeFactory:

public class BackgroundService : IHostedService
{
    private readonly IServiceScopeFactory _serviceScopeFactory;

    public BackgroundService(IServiceScopeFactory serviceScopeFactory)
    {
        _serviceScopeFactory = serviceScopeFactory;
    }

    public async Task DoWorkAsync()
    {
        using var scope = _serviceScopeFactory.CreateScope();
        var scopedService = scope.ServiceProvider.GetRequiredService<IScopedService>();
        // Use scopedService here
    }
}

Injection Methods

ASP.NET Core supports three ways to inject dependencies:

1. Constructor Injection (Recommended)

This is the most common and recommended approach:

public class UserService : IUserService
{
    private readonly IUserRepository _userRepository;
    private readonly IEmailService _emailService;

    public UserService(IUserRepository userRepository, IEmailService emailService)
    {
        _userRepository = userRepository;
        _emailService = emailService;
    }

    public async Task<User> GetUserByIdAsync(int id)
    {
        return await _userRepository.GetByIdAsync(id);
    }
}

Advantages:

  • Dependencies are explicit and required
  • Easy to test (you can see all dependencies)
  • Enforces immutability (fields can be readonly)

2. Method Injection (Minimal APIs)

In minimal APIs, you can inject services directly as method parameters:

app.MapGet("/user/{id}", async (int id, IUserService userService) => 
{
    var user = await userService.GetUserByIdAsync(id);
    return user != null ? Results.Ok(user) : Results.NotFound();
});

3. Property Injection (Less Common)

While supported, property injection is generally not recommended as it makes dependencies implicit:

public class UserService : IUserService
{
    [FromServices] // Required attribute in some scenarios
    public IUserRepository UserRepository { get; set; }
}

Advanced Scenarios

Generic Service Registration

You can register generic types:

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

// Usage
public class UserService
{
    private readonly IRepository<User> _userRepository;

    public UserService(IRepository<User> userRepository)
    {
        _userRepository = userRepository;
    }
}

Conditional Registration

Register different implementations based on configuration:

if (builder.Environment.IsDevelopment())
{
    builder.Services.AddScoped<IEmailService, MockEmailService>();
}
else
{
    builder.Services.AddScoped<IEmailService, SmtpEmailService>();
}

// Or using configuration
var emailProvider = builder.Configuration["Email:Provider"];
if (emailProvider == "SendGrid")
{
    builder.Services.AddScoped<IEmailService, SendGridEmailService>();
}
else
{
    builder.Services.AddScoped<IEmailService, SmtpEmailService>();
}

Decorator Pattern

Implement the decorator pattern to add cross-cutting concerns:

// Register the concrete implementation
builder.Services.AddScoped<IUserService, UserService>();

// Register decorator
builder.Services.Decorate<IUserService, CachedUserService>();
builder.Services.Decorate<IUserService, LoggingUserService>();

// Note: Decorate is not built-in, but you can implement it or use a library

Service Locator Pattern (Anti-pattern)

While you can use IServiceProvider directly, it's generally considered an anti-pattern:

// ❌ Avoid this
public class UserService
{
    private readonly IServiceProvider _serviceProvider;

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

    public void DoWork()
    {
        var emailService = _serviceProvider.GetRequiredService<IEmailService>();
        // Use emailService
    }
}

// ✅ Prefer constructor injection
public class UserService
{
    private readonly IEmailService _emailService;

    public UserService(IEmailService emailService)
    {
        _emailService = emailService;
    }
}

Testing with Dependency Injection

One of the primary benefits of DI is improved testability. Here's how you can leverage it:

Unit Testing

[Fact]
public async Task GetUserByIdAsync_ReturnsUser_WhenUserExists()
{
    // Arrange
    var mockRepository = new Mock<IUserRepository>();
    var expectedUser = new User { Id = 1, Name = "John Doe" };
    mockRepository.Setup(r => r.GetByIdAsync(1))
                  .ReturnsAsync(expectedUser);

    var userService = new UserService(mockRepository.Object, Mock.Of<IEmailService>());

    // Act
    var result = await userService.GetUserByIdAsync(1);

    // Assert
    Assert.NotNull(result);
    Assert.Equal(expectedUser.Name, result.Name);
    mockRepository.Verify(r => r.GetByIdAsync(1), Times.Once);
}

Integration Testing

public class UserControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public UserControllerTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    [Fact]
    public async Task GetUser_ReturnsOk_WhenUserExists()
    {
        // Override service registration for testing
        var client = _factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                services.AddScoped<IUserRepository, InMemoryUserRepository>();
            });
        }).CreateClient();

        var response = await client.GetAsync("/user/1");
        response.EnsureSuccessStatusCode();
    }
}

Best Practices

  1. Prefer Interface-Based Design: Always use interfaces for services that might have multiple implementations or need to be mocked.

  2. Use Appropriate Lifetimes: Choose the right lifetime based on the service's purpose. Most services should be scoped.

  3. Avoid Service Locator: Don't inject IServiceProvider unless absolutely necessary. Prefer constructor injection.

  4. Don't Store Scoped Services in Singletons: Use IServiceScopeFactory if you need to create scoped services from a singleton.

  5. Validate Dependencies: Use null-checking in constructors to fail fast if dependencies are missing:

public UserService(IUserRepository userRepository)
{
    _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
}
  1. Organize Registrations: Consider using extension methods to group related service registrations:
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddUserServices(this IServiceCollection services)
    {
        services.AddScoped<IUserService, UserService>();
        services.AddScoped<IUserRepository, UserRepository>();
        return services;
    }
}

// In Program.cs
builder.Services.AddUserServices();
  1. Be Mindful of Circular Dependencies: If you find yourself needing circular dependencies, reconsider your design. This often indicates a violation of the Single Responsibility Principle.

Common Pitfalls

  1. Wrong Service Lifetime: Using singleton for services that should be scoped (like DbContext) can lead to data corruption or concurrency issues.

  2. Capturing Scoped Services: Storing scoped services in singleton fields can cause memory leaks and incorrect behavior.

  3. Over-injection: Having too many dependencies in a constructor (more than 3-4) might indicate that the class is doing too much.

  4. Not Disposing Services: If your service implements IDisposable, the container will dispose it automatically when the scope ends. Don't manually dispose injected services.

Conclusion

Dependency Injection is a powerful pattern that, when used correctly, significantly improves the quality of your ASP.NET Core applications. It promotes:

  • Loose Coupling: Components depend on abstractions, not concrete implementations
  • Testability: Easy to create unit tests with mock dependencies
  • Flexibility: Swap implementations without changing dependent code
  • Maintainability: Clear dependencies make code easier to understand and modify

By understanding service lifetimes, choosing appropriate registration methods, and following best practices, you can build robust, scalable, and maintainable applications. The built-in DI container in ASP.NET Core is sufficient for most scenarios, but don't hesitate to explore third-party containers if you need more advanced features.

Remember: DI is not just a framework feature—it's a design philosophy that encourages better software architecture and cleaner code.

Previous Post

Deploying Apps with Docker and DigitalOcean

Next Post

JWT vs Session Authentication: Choosing the Right Approach