Le Do Nghiem

Le Do Nghiem

AI Engineer

About meBooksSnippetsBlog

© 2026 Le Do Nghiem. All rights reserved.

Contact |

Back to Blog

Dependency Injection in ASP.NET Core

Le Do Nghiem
Le Do NghiemAI Engineer
2025-09-10 10 min read
Share

The short version

Dependency Injection in ASP.NET Core is not optional ceremony — the framework assumes you inject services through constructors, and fighting that makes everything harder.

I use the built-in container for every API I ship. I rarely need Autofac unless the project already standardized on it. DI is how I keep scalable .NET + React apps testable as they grow.

This guide is what I wish I had when I first registered DbContext as a singleton and wondered why data looked "stuck."


Understanding Dependency Injection

The problem without DI

When I started, services looked like this — new everywhere:

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 I hit in production:

  • Tight coupling — swapping email or database meant editing UserService
  • Hard to test — no seam for mocks
  • Hidden dependencies — constructor does not tell the story

The solution: constructor injection

With DI, dependencies come 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

What I memorize: Transient = new every time. Scoped = one per HTTP request. Singleton = one per app.

Getting this wrong caused my worst DI bugs — especially scoped DbContext inside singleton services.

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

What I actually follow on teams:

  1. Prefer interface-based design — mocks and swaps stay easy.

  2. Use appropriate lifetimes — most services scoped; DbContext always scoped.

  3. Avoid service locator — don't inject IServiceProvider unless you must. Prefer constructor injection.

  4. Don't store scoped services in singletons — use IServiceScopeFactory if a singleton needs a scoped dependency.

  5. Validate dependencies — null-check in constructors:

public UserService(IUserRepository userRepository)
{
    _userRepository = userRepository ?? throw new ArgumentNullException(nameof(userRepository));
}
  1. Organize registrations — extension methods like AddUserServices() keep Program.cs readable.

  2. Watch circular dependencies — usually a sign the class does too much.

Common Pitfalls

These are not theoretical — I have shipped each mistake at least once:

  1. Wrong service lifetime — singleton DbContext → corrupted reads and concurrency weirdness.

  2. Capturing scoped services — holding a scoped instance in a singleton field → leaks and stale state.

  3. Over-injection — five constructor params often means split the class.

  4. Manual dispose of injected services — the container owns disposal when the scope ends.


Bugs I introduced with lifetimes

  • Singleton hosting scoped DbContext — the classic captive dependency. Fixed with scoped services and IServiceScopeFactory for background work.
  • Service locator for "convenience" — hid dependencies until runtime failures.
  • Registering concrete classes only — tests became integration tests whether I wanted that or not.

Parting notes

Register interfaces in Program.cs, inject through constructors, default to scoped for web requests, and draw lifetimes on a whiteboard before you debug production at 2am.

The built-in container is enough for most apps I build. If you are structuring APIs for growth, read how I build scalable web apps next — DI is the seam that keeps that layout from collapsing.

DI is not a checkbox — it is how ASP.NET Core wants you to compose behavior. Learn lifetimes once; pay yourself back on every test and every swap.

On this page

  • The short version
  • Understanding Dependency Injection
  • The problem without DI
  • The solution: constructor injection
  • The ASP.NET Core DI Container
  • Basic Service Registration
  • Registration Methods
  • Service Lifetimes
  • 1. Transient
  • 2. Scoped
  • 3. Singleton
  • Injection Methods
  • 1. Constructor Injection (Recommended)
  • 2. Method Injection (Minimal APIs)
  • 3. Property Injection (Less Common)
  • Advanced Scenarios
  • Generic Service Registration
  • Conditional Registration
  • Decorator Pattern
  • Service Locator Pattern (Anti-pattern)
  • Testing with Dependency Injection
  • Unit Testing
  • Integration Testing
  • Best Practices
  • Common Pitfalls
  • Bugs I introduced with lifetimes
  • Parting notes
Share
Previous Post

Deploying Apps with Docker and DigitalOcean

Next Post

JWT vs Session Authentication: Choosing the Right Approach