Search

Dependency Injection Trong .NET: Hiểu Sâu Và Dùng Đúng

Dependency Injection Trong .NET: Hiểu Sâu Và Dùng Đúng

Dependency Injection (DI) là thứ mà hầu hết .NET developer dùng hàng ngày nhưng ít người thực sự hiểu sâu. Đăng ký service trong Program.cs, inject qua constructor, chạy được — xong. Nhưng khi dự án lớn lên, bắt đầu xuất hiện những bug kỳ lạ: data cũ xuất hiện ở request khác, DbContext bị disposed giữa chừng, memory leak không rõ nguyên nhân.

Hầu hết những bug đó đều liên quan đến service lifetime — và cụ thể là dùng sai lifetime. Bài viết này mình đi sâu vào cách DI container của .NET hoạt động thực sự, những sai lầm phổ biến, và pattern mình dùng trong dự án thực tế.


DI là gì — Giải thích bằng code, không bằng lý thuyết

Không có DI:

public class InvoiceService
{
    private readonly AppDbContext _context;
    private readonly ILogger<InvoiceService> _logger;

    public InvoiceService()
    {
        // Service tự tạo dependency của mình
        _context = new AppDbContext(/* options ở đâu? */);
        _logger = new Logger<InvoiceService>(/* factory ở đâu? */);
    }
}

Vấn đề rõ ràng: InvoiceService phải biết cách tạo AppDbContext — cần DbContextOptions, connection string, provider. Nếu test, bạn không thể thay thế AppDbContext bằng in-memory database. Nếu đổi từ PostgreSQL sang SQL Server, phải sửa InvoiceService — dù logic nghiệp vụ không thay đổi gì.

Với DI:

public class InvoiceService
{
    private readonly AppDbContext _context;
    private readonly ILogger<InvoiceService> _logger;

    // Service NHẬN dependency từ bên ngoài
    public InvoiceService(AppDbContext context, ILogger<InvoiceService> logger)
    {
        _context = context;
        _logger = logger;
    }
}

InvoiceService không biết và không quan tâm AppDbContext được tạo ra sao. Ai đó (DI container) lo chuyện đó. Service chỉ khai báo "tôi cần những thứ này để hoạt động" — và nhận được chúng khi được tạo.

Đó là toàn bộ ý tưởng của DI. Phần còn lại là chi tiết implement.


DI Container của .NET — Cách nó hoạt động

Đăng ký service

// Program.cs
var builder = WebApplication.CreateBuilder(args);

// Đăng ký DbContext
builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseNpgsql(builder.Configuration
        .GetConnectionString("DefaultConnection")));

// Đăng ký service với interface
builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddScoped<ICustomerService, CustomerService>();

// Đăng ký service không có interface
builder.Services.AddScoped<TenantProvider>();

// Đăng ký HttpClient
builder.Services.AddHttpClient<VippsPaymentClient>(client =>
{
    client.BaseAddress = new Uri("https://api.vipps.no");
});

var app = builder.Build();

Resolve service

DI container tự resolve khi framework tạo controller hoặc service:

// Controller — framework inject tự động
[ApiController]
[Route("api/[controller]")]
public class InvoicesController : ControllerBase
{
    private readonly IInvoiceService _invoiceService;

    public InvoicesController(IInvoiceService invoiceService)
    {
        _invoiceService = invoiceService;
    }
}

Khi request đến /api/invoices, framework cần tạo InvoicesController → thấy constructor cần IInvoiceService → tìm trong container → thấy đã đăng ký InvoiceService implement IInvoiceServiceInvoiceService cần AppDbContextILogger<InvoiceService> → container tạo AppDbContext với options đã đăng ký → tạo ILogger → tạo InvoiceService → inject vào controller.

Toàn bộ cây dependency được resolve tự động. Bạn chỉ đăng ký, container lo phần còn lại.


Ba Service Lifetime — Phần quan trọng nhất

Mỗi service đăng ký với một lifetime, quyết định khi nào instance được tạo mới và khi nào được tái sử dụng.

Transient — Mỗi lần inject là một instance mới

builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();

Mỗi lần ai đó yêu cầu IEmailSender, container tạo instance SmtpEmailSender mới. Nếu trong một request, ServiceAServiceB đều inject IEmailSender, mỗi service nhận instance khác nhau.

Khi nào dùng: service nhẹ, không giữ state, tạo nhanh. Ví dụ: validator, mapper, formatter.

Scoped — Một instance cho mỗi request (scope)

builder.Services.AddScoped<IInvoiceService, InvoiceService>();
builder.Services.AddDbContext<AppDbContext>(...); // AddDbContext mặc định là Scoped

Trong cùng một HTTP request, mọi nơi inject IInvoiceService đều nhận cùng một instance. Request khác nhận instance khác.

Đây là lifetime quan trọng nhất trong ASP.NET Core. DbContext đăng ký Scoped vì: trong một request, tất cả service cần share cùng DbContext để transaction hoạt động đúng. Nếu InvoiceServicePaymentService dùng khác DbContext, SaveChanges() ở một service không bao gồm changes ở service kia.

// Cùng request → cùng DbContext → cùng transaction
public class InvoiceService
{
    private readonly AppDbContext _context; // instance A
    public InvoiceService(AppDbContext context) => _context = context;
}

public class PaymentService
{
    private readonly AppDbContext _context; // cũng instance A (cùng request)
    public PaymentService(AppDbContext context) => _context = context;
}

Khi nào dùng: service có state per-request, service dùng DbContext, hầu hết business service.

Singleton — Một instance cho toàn bộ application lifetime

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

Chỉ tạo một lần, dùng cho mọi request, mọi thread. Instance tồn tại cho đến khi application shutdown.

Khi nào dùng: service không có state per-request, thread-safe, đắt để tạo. Ví dụ: cache, configuration, HttpClientFactory (đã được framework quản lý).

Cẩn thận: Singleton service phải thread-safe vì nhiều request truy cập đồng thời. Nếu service có mutable state mà không synchronize — bug race condition.


Captive Dependency — Bug nguy hiểm nhất

Đây là sai lầm mà mình đã gặp trên production, mất cả ngày debug. Quy tắc: service không được inject dependency có lifetime ngắn hơn mình.

// NGUY HIỂM — Singleton giữ reference đến Scoped service
builder.Services.AddSingleton<ICacheWarmupService, CacheWarmupService>();
builder.Services.AddScoped<IProductRepository, ProductRepository>();

public class CacheWarmupService : ICacheWarmupService
{
    private readonly IProductRepository _repo; // Scoped bị "bắt" bởi Singleton!

    public CacheWarmupService(IProductRepository repo)
    {
        _repo = repo;
    }
}

Chuyện gì xảy ra: CacheWarmupService là Singleton — tạo một lần, sống mãi. ProductRepository là Scoped — đáng lẽ chỉ sống trong một request rồi bị dispose. Nhưng vì Singleton giữ reference, ProductRepository (và DbContext bên trong nó) không bao giờ được dispose. Kết quả: DbContext cũ bị giữ mãi, data stale, connection pool leak, và cuối cùng database connection hết.

Bug này không crash ngay — nó âm thầm gây ra data không chính xác và memory leak. Có thể mất hàng tuần mới phát hiện trên production.

Quy tắc lifetime

Singleton chỉ được inject: Singleton.

Scoped được inject: Singleton, Scoped.

Transient được inject: Singleton, Scoped, Transient.

Nhớ đơn giản: mũi tên chỉ được đi từ ngắn đến dài, không được ngược lại. Transient → Scoped → Singleton — inject theo hướng này là an toàn.

Cách .NET giúp phát hiện

Trong Development, .NET tự detect captive dependency và throw exception:

// Program.cs — mặc định đã bật trong Development
var builder = WebApplication.CreateBuilder(args);
// builder.Host.UseDefaultServiceProvider(options =>
// {
//     options.ValidateScopes = true;      // Bật validation
//     options.ValidateOnBuild = true;      // Validate ngay khi build
// });

ValidateScopes throw exception khi Singleton cố resolve Scoped service. Mặc định bật trong Development nhưng tắt trong Production vì có performance cost. Đây là lý do nên test kỹ trong Development — bug captive dependency chỉ hiện ở Development, Production im lặng và gây hậu quả.

Giải pháp: IServiceScopeFactory

Nếu Singleton cần dùng Scoped service, tạo scope thủ công:

public class CacheWarmupService : ICacheWarmupService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public CacheWarmupService(IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;  // IServiceScopeFactory là Singleton — an toàn
    }

    public async Task WarmupAsync()
    {
        // Tạo scope mới, dùng xong dispose
        using var scope = _scopeFactory.CreateScope();
        var repo = scope.ServiceProvider
            .GetRequiredService<IProductRepository>();

        var products = await repo.GetAllAsync();
        // Cache products...
    }
    // scope bị dispose → repo bị dispose → DbContext bị dispose
    // Không leak!
}

IServiceScopeFactory là Singleton, inject vào Singleton an toàn. Khi cần Scoped service, tạo scope, dùng, rồi dispose. Pattern này phổ biến trong background service (IHostedService), middleware custom, và Singleton service cần truy cập database.


Interface hay không Interface?

Câu hỏi mình thấy nhiều developer tranh luận: có nên tạo interface cho mọi service?

Khi NÊN dùng interface

// Có nhiều implementation
builder.Services.AddScoped<IPaymentProvider, VippsPaymentProvider>();
// Sau này có thể đổi thành:
// builder.Services.AddScoped<IPaymentProvider, StripePaymentProvider>();

// Cần mock trong unit test
builder.Services.AddScoped<IInvoiceService, InvoiceService>();

Interface hữu ích khi: có thể có nhiều implementation (payment provider, notification sender), cần mock trong unit test, muốn ẩn implementation detail khỏi consumer.

Khi KHÔNG CẦN interface

// Service chỉ có một implementation, không cần mock
builder.Services.AddScoped<TenantProvider>();
builder.Services.AddScoped<InvoiceNumberGenerator>();

Tạo ITenantProvider chỉ vì "best practice" nhưng luôn chỉ có TenantProvider implement nó — thừa. Interface thêm file, thêm indirection, không thêm giá trị. Inject concrete class trực tiếp đơn giản hơn.

Quy tắc mình dùng: tạo interface khi có lý do cụ thể (multiple implementations, testability). Không tạo "phòng xa" — khi cần, extract interface chỉ mất 30 giây với IDE.


Đăng ký nhiều implementation cho một interface

// Đăng ký nhiều implementation
builder.Services.AddScoped<INotificationSender, EmailNotificationSender>();
builder.Services.AddScoped<INotificationSender, SmsNotificationSender>();
builder.Services.AddScoped<INotificationSender, PushNotificationSender>();

Inject tất cả

public class NotificationService
{
    private readonly IEnumerable<INotificationSender> _senders;

    // Inject TẤT CẢ implementation
    public NotificationService(IEnumerable<INotificationSender> senders)
    {
        _senders = senders;
    }

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

Inject một (cái cuối cùng)

public class SomeService
{
    private readonly INotificationSender _sender;

    // Inject MỘT — lấy implementation đăng ký cuối cùng (Push)
    public SomeService(INotificationSender sender)
    {
        _sender = sender;
    }
}

Khi inject single interface, .NET trả về implementation đăng ký cuối cùng. Đây là behavior quan trọng cần biết — nó cho phép override registration:

// Registration gốc
builder.Services.AddScoped<IPaymentProvider, VippsPaymentProvider>();

// Override cho testing hoặc feature flag
if (useStripe)
    builder.Services.AddScoped<IPaymentProvider, StripePaymentProvider>();
// Bây giờ IPaymentProvider resolve thành StripePaymentProvider

Keyed Services — .NET 8+

.NET 8 thêm keyed services — resolve implementation theo key thay vì lấy cái cuối cùng:

// Đăng ký với key
builder.Services.AddKeyedScoped<IPaymentProvider, VippsPaymentProvider>("vipps");
builder.Services.AddKeyedScoped<IPaymentProvider, StripePaymentProvider>("stripe");

// Inject theo key
public class PaymentController : ControllerBase
{
    public PaymentController(
        [FromKeyedServices("vipps")] IPaymentProvider vipps,
        [FromKeyedServices("stripe")] IPaymentProvider stripe)
    {
        // Mỗi parameter nhận đúng implementation cần thiết
    }
}

Trước .NET 8, phải dùng factory pattern hoặc Autofac. Giờ built-in luôn.


Options Pattern — Config cho service

Đừng inject IConfiguration trực tiếp vào service. Dùng Options pattern:

// Sai — service biết quá nhiều về config structure
public class VippsPaymentClient
{
    public VippsPaymentClient(IConfiguration config)
    {
        var clientId = config["Vipps:ClientId"];     // Magic string
        var secret = config["Vipps:ClientSecret"];   // Không type-safe
        var baseUrl = config["Vipps:BaseUrl"];       // Quên key → null, không lỗi
    }
}
// Đúng — strongly typed options
public class VippsOptions
{
    public const string SectionName = "Vipps";
    public string ClientId { get; set; } = string.Empty;
    public string ClientSecret { get; set; } = string.Empty;
    public string BaseUrl { get; set; } = "https://api.vipps.no";
    public int TimeoutSeconds { get; set; } = 30;
}

// Đăng ký
builder.Services.Configure<VippsOptions>(
    builder.Configuration.GetSection(VippsOptions.SectionName));

// Inject
public class VippsPaymentClient
{
    private readonly VippsOptions _options;

    public VippsPaymentClient(IOptions<VippsOptions> options)
    {
        _options = options.Value;
        // Type-safe, compile-time check, IntelliSense
    }
}

IOptions vs IOptionsSnapshot vs IOptionsMonitor

Đây là chi tiết ít người phân biệt rõ:

IOptions<T> là Singleton — đọc config một lần lúc startup, không thay đổi. Dùng cho config ít thay đổi.

IOptionsSnapshot<T> là Scoped — đọc lại config mỗi request. Nếu bạn đổi appsettings.json lúc runtime, request tiếp theo nhận giá trị mới. Dùng cho config có thể hot-reload.

IOptionsMonitor<T> là Singleton nhưng có change notification — đọc lại khi config thay đổi và notify qua callback. Dùng trong Singleton service cần react to config changes.

// Singleton service cần config thay đổi → dùng IOptionsMonitor
public class CacheService : ICacheService
{
    private readonly IOptionsMonitor<CacheOptions> _options;

    public CacheService(IOptionsMonitor<CacheOptions> options)
    {
        _options = options;
        _options.OnChange(newOptions =>
        {
            // React khi config thay đổi
            _logger.LogInformation("Cache TTL changed to {Ttl}", 
                newOptions.TtlMinutes);
        });
    }

    public TimeSpan GetTtl() => 
        TimeSpan.FromMinutes(_options.CurrentValue.TtlMinutes);
}

Background Service và DI

IHostedServiceBackgroundService là Singleton — không inject Scoped service trực tiếp:

public class InvoiceReminderService : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<InvoiceReminderService> _logger;

    // Inject Singleton dependencies trực tiếp
    public InvoiceReminderService(
        IServiceScopeFactory scopeFactory,
        ILogger<InvoiceReminderService> logger)
    {
        _scopeFactory = scopeFactory;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                // Tạo scope cho mỗi iteration
                using var scope = _scopeFactory.CreateScope();
                var invoiceService = scope.ServiceProvider
                    .GetRequiredService<IInvoiceService>();
                var emailSender = scope.ServiceProvider
                    .GetRequiredService<IEmailSender>();

                var overdueInvoices = await invoiceService
                    .GetOverdueInvoicesAsync(ct);

                foreach (var invoice in overdueInvoices)
                {
                    await emailSender.SendReminderAsync(invoice, ct);
                }
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Error sending invoice reminders");
            }

            await Task.Delay(TimeSpan.FromHours(1), ct);
        }
    }
}

// Đăng ký
builder.Services.AddHostedService<InvoiceReminderService>();

Mỗi iteration tạo scope mới → dùng xong dispose → DbContext được giải phóng đúng cách. Không có scope, DbContext sống mãi trong Singleton, tracking mọi entity từ lần đầu chạy — memory leak và data stale.


Unit Testing với DI

DI giúp test dễ hơn nhiều — mock dependency thay vì chạy database thật:

public class InvoiceServiceTests
{
    [Fact]
    public async Task CreateInvoice_ShouldGenerateCorrectNumber()
    {
        // Arrange — tạo mock dependencies
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase("TestDb_" + Guid.NewGuid())
            .Options;
        var context = new AppDbContext(options);

        var mockLogger = new Mock<ILogger<InvoiceService>>();
        var mockTenant = new Mock<ITenantProvider>();
        mockTenant.Setup(t => t.GetCurrentTenantId()).Returns(1);

        var service = new InvoiceService(
            context,
            mockLogger.Object,
            mockTenant.Object
        );

        // Act
        var invoice = await service.CreateAsync(new CreateInvoiceRequest
        {
            CustomerId = 1,
            LineItems = [new() { ProductName = "Michelin", Quantity = 4 }]
        });

        // Assert
        Assert.StartsWith("INV-", invoice.InvoiceNumber);
        Assert.Equal(InvoiceStatus.Draft, invoice.Status);
    }
}

Không có DI, InvoiceService tự tạo AppDbContext bên trong — bạn không thể thay bằng in-memory database. Với DI, inject mock hoặc test double qua constructor — clean và predictable.


Tổ chức registration — Khi Program.cs quá dài

Dự án lớn có hàng chục service registration. Mình tổ chức bằng extension methods:

// Extensions/ServiceCollectionExtensions.cs
public static class ServiceCollectionExtensions
{
    public static IServiceCollection AddApplicationServices(
        this IServiceCollection services)
    {
        services.AddScoped<IInvoiceService, InvoiceService>();
        services.AddScoped<ICustomerService, CustomerService>();
        services.AddScoped<IPaymentService, PaymentService>();
        services.AddScoped<IInventoryService, InventoryService>();
        return services;
    }

    public static IServiceCollection AddInfrastructureServices(
        this IServiceCollection services, IConfiguration config)
    {
        services.AddDbContext<AppDbContext>(options =>
            options.UseNpgsql(config.GetConnectionString("DefaultConnection")));

        services.AddScoped<ITenantProvider, TenantProvider>();
        services.AddSingleton<ICacheService, InMemoryCacheService>();

        services.AddHttpClient<VippsPaymentClient>();

        return services;
    }
}

// Program.cs — sạch và rõ ràng
builder.Services.AddApplicationServices();
builder.Services.AddInfrastructureServices(builder.Configuration);

Mỗi layer (Application, Infrastructure, Presentation) có extension method riêng. Program.cs chỉ gọi 2-3 methods thay vì 30 dòng registration.


Những sai lầm phổ biến — Tóm tắt

Captive dependency

Singleton inject Scoped/Transient. Hậu quả: DbContext không bao giờ dispose, data stale, connection leak. Giải pháp: IServiceScopeFactory.

DbContext inject vào Singleton

Trường hợp đặc biệt của captive dependency nhưng phổ biến đến mức cần nhắc riêng. DbContext là Scoped — không bao giờ inject trực tiếp vào Singleton hay BackgroundService.

Resolve service trong constructor

// SAI — resolve trong constructor
public class MyService
{
    private readonly IOtherService _other;
    public MyService(IServiceProvider sp)
    {
        _other = sp.GetRequiredService<IOtherService>(); // Service Locator anti-pattern
    }
}

// ĐÚNG — inject trực tiếp
public class MyService
{
    private readonly IOtherService _other;
    public MyService(IOtherService other) => _other = other;
}

Inject IServiceProvider rồi resolve thủ công gọi là Service Locator pattern — anti-pattern vì dependency bị ẩn, không thể biết service cần gì chỉ bằng nhìn constructor.

Transient service implement IDisposable

// NGUY HIỂM — ai dispose?
builder.Services.AddTransient<IFileProcessor, FileProcessor>();

public class FileProcessor : IFileProcessor, IDisposable
{
    private readonly FileStream _stream;
    public void Dispose() => _stream?.Dispose();
}

DI container theo dõi tất cả Transient IDisposable và dispose chúng khi scope kết thúc. Nếu tạo nhiều Transient IDisposable trong một request, tất cả sống đến cuối request — tương đương memory leak trong request đó. Giải pháp: nếu service cần dispose, đăng ký Scoped thay vì Transient.


Kết luận

DI trong .NET không chỉ là AddScoped rồi inject. Phần quan trọng nhất là hiểu service lifetime và hậu quả của dùng sai. Scoped cho hầu hết business service và DbContext. Singleton cho cache, config, service không có state per-request. Transient cho service nhẹ, stateless, không implement IDisposable.

Nhớ quy tắc captive dependency: không inject dependency có lifetime ngắn hơn service hiện tại. Khi Singleton cần Scoped service, dùng IServiceScopeFactory. Khi cần config, dùng Options pattern thay vì inject IConfiguration trực tiếp.

Và điều cuối: DI không phải mục đích, mà là công cụ. Mục đích thật sự là code dễ test, dễ thay đổi, và dễ hiểu. Nếu thêm interface và DI registration mà code phức tạp hơn mà không dễ test hơn — bạn đang over-engineering.

Culi Dev

Culi Dev

Enjoy coding, enjoy life!

Leave a comment

Your email address will not be published. Required fields are marked *

Your experience on this site will be improved by allowing cookies Cookie Policy