Cú lừa sự tiện lợi

Có bao giờ nhìn vào đoạn code và thấy nó quá bình thường chưa?

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

Sau đó chỉ cần viết:

[ApiController]
[Route("api/users")]
public class UserController : ControllerBase
{
    private readonly IUserService _userService;

    public UserController(IUserService userService)
    {
        _userService = userService;
    }
}

ASP.NET sẽ tự động inject IUserService vào constructor.

Nhìn qua như quá quen thuộc nhưng nó là "ma thuật framework"

Thử bắt đầu với vài câu hỏi:

  • Ai là người tạo IUserService?
  • Ai là người tạo UserRepository?
  • Object được tạo lúc nào?
  • Tại sao Scoped sống theo request?
  • Vì sao inject Scoped vào Singleton sẽ "nổ" source?
  • Object được dispose khi nào?
  • Framework có dùng Reflection mỗi request không?

Dependency Injection khiến vô tình bỏ qua machinery phía sau. Thực tế, để inject được đúng một dependency, ASP.NET Core phải kéo theo cả một hệ thống build object graph khá phức tạp.

Có thể hình dung như sau:

Request đến
    ↓
Controller cần dependency
    ↓
IServiceProvider resolve object
    ↓
Tạo dependency graph
    ↓
Cache theo lifetime
    ↓
Inject vào constructor
    ↓
Dispose cuối request

"Mổ bụng" từng phần nhé.

AddScoped chưa tạo object nào cả

Nhìn thử đoạn code này

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

Nghĩa là framework đang tạo sẵn một object UserService

Hoàn toàn KHÔNG. Ở thời điểm startup, ASP.NET Core chưa hề tạo object thật. Nó chỉ đang lưu metadata:

  • Interface nào map với implement nào
  • Lifetime là gì
  • Constructor nào cần dùng
  • Object nên được cache ra sao

Nói đơn giản hơn:

AddScoped() không tạo object. Nó chỉ đăng ký "công thức chế tạo object" vào DI Container. Giống việc ghi menu vào nhà hàng chứ chưa nấu món ăn.

IServiceProvider - "nhà máy" phía sau ASP.NET Core

Khi request tới Controller, framework mới bắt đầu cần object.

Ví dụ:

public UserController(IUserService service)
{

}

Lúc này ASP.NET Core sẽ chạy tới IServiceProvider và nói:

Tao cần một IUserService

Đây là lúc mà DI Container bắt đầu làm việc.

Nó sẽ:

  • Tìm mapping IUserService -> UserService
  • Đọc constructor của UserService
  • Phát hiện UserService cần dependency khác
  • Resolve tiếp Dependency con
  • Build toàn bộ object graph

Ví dụ:

public class UserService : IUserService
{
    private readonly IUserRepository _userRepository;
    private readonly ILogger<UserService> _logger;

    public UserService(
        IUserRepository userRepository,
        ILogger<UserService> logger)
    {

    }
}

DI Container sẽ tạo:

  • UserRepository
  • ILogger<UserService>

Giả sử UserRepository lại cần DbContext, container nó sẽ resolve tiếp.

Đây là lý do Dependency Injection không tạo object. Nó tạo cả cây dependency.

Constructor Injection chỉ là bề nổi

public UserController(IUserService service)
{

}

Ở phía dưới, nó đang làm rất nhiều việc:

UserController
    ↓
IUserService
    ↓
IUserRepository
    ↓
ApplicationDbContext
    ↓
ILogger

Một constructor có thể kéo theo hàng chục object được tạo ra phía sau.

Đây là một trong những lý do khiến application startup rất chậm hoặc request đầu tiên cực lâu.

Framework nó phải:

  • Phân tích constructor
  • Resolve dependency
  • Build object graph
  • Cache delegate
  • Quản lý lifetime

Nếu dependency graph quá sâu, việc resolve sẽ tốn chi phí đáng kể.

Lifetime - phần gây "production issue" nhất

ASP.NET Core có 3 lifetime phổ biến:

Transient

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

Mỗi lần resolve -> tạo object mới.

Giải thích đơn giản:

Resolve lần 1 -> object A
Resolve lần 2 -> object B
Resolve lần 3 -> object C

Transient giống đồ dùng 1 lần, xài xong bỏ.

Scoped

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

Mỗi request (HTTP) sẽ dùng chung một object.

Ví dụ:

Request A -> UserService A
Request B -> UserService B

Nhưng bên trong cùng một object:

Controller
    ↓
Service
    ↓
Repository

Nếu tất cả dùng cùng DbContext, framework sẽ reuse đúng một instance. Đây là thứ giúp transaction hoạt động nhất quán trong một request

Singleton

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

Object được tạo đúng một lần xuyên suốt ứng dụng.

Nghĩa là:

1000 request
    ↓
xài chung 1 object

Vì lý do này, rất dễ dính race condition nếu object có mutable state.

Ví dụ:

public class CounterService
{
    public int Count = 0;
}

Nếu request cùng sửa Count, dữ liệu có thể sai do chạy song song.

Vì sao inject Scoped vào Singleton lại nổ?

Đây là bug rất phổ biến.

Ví dụ:

public class CacheService
{
    public CacheService(ApplicationDbContext context)
    {

    }
}

Trong khi:

builder.Services.AddSingleton<CacheService>();
builder.Services.AddScoped<ApplicationDbContext>();

Lúc này framework sẽ báo lỗi:

Cannot consume scoped service from singleton

Tại sao?

  • Singleton sống xuyên suốt application
  • Còn Scoped chỉ sống trong một request

Nếu giữ Singleton giữ reference tới Scoped object:

Request A kết thúc
    ↓
DbContext bị dispose
    ↓
Singleton vẫn giữ reference cũ

Lúc này application có thể giữ object “xác chết” trong bộ nhớ.

DI Container chặn chuyện này ngay từ đầu để tránh bug production cực khó debug.

Bẫy Circular Dependency

Một bug cũng rất “tâm linh”:

UserService cần AuthService
AuthService cần TokenService
TokenService cần UserService

Container sẽ resolve kiểu:

UserService
    ↓
AuthService
    ↓
TokenService
    ↓
UserService
    ↓
AuthService
    ↓
...........

Nó như vòng lặp vô tận.

Và kết quả thường:

  • Stack overflow
  • Exception lúc startup
  • Application fail ngay khi boot

Đây là lý do dependency graph càng phức tạp càng dễ sinh architecture smell.

ASP.NET Core có dùng Reflection mỗi request không?

Có. Nhưng không hoàn toàn theo cách nhiều người nghĩ.

Nếu mỗi request framework đều:

  • Scan constructor bằng Reflection
  • Parse parameter
  • Invoke constructor động

Performance sẽ rất tệ.

ASP.NET Core tối ưu bằng cách:

  • Cache constructor metadata
  • Compile expression tree
  • Build delegate
  • Reuse ObjectFactory

Nói cách khác:

Reflection chủ yếu dùng lúc “chuẩn bị”.

Khi request thật sự chạy, framework cố chuyển mọi thứ sang delegate để tăng tốc resolve object. Đây là một trong những lý do DI của ASP.NET Core nhanh hơn nhiều container đời cũ.

Dispose object - phần nhiều người quên mất

DI Container không chỉ tạo object.

Nó còn phải dọn rác đúng thời điểm.

Ví dụ:

public class UserRepository : IDisposable
{

}

Nếu object được tạo theo Scoped, cuối request framework sẽ tự dispose toàn bộ dependency liên quan.

Điều này cực kỳ quan trọng với:

  • DbContext
  • Database connection
  • Stream
  • HttpClient handler
  • Unmanaged resource

Nếu lifecycle bị quản lý sai:

  • Memory leak
  • Connection leak
  • Socket exhaustion

sẽ xuất hiện rất nhanh ở production.

Kết luận

Nhìn bề ngoài, Dependency Injection chỉ giống:

public UserController(IUserService service)
{

}

Nhưng để inject được đúng object đó, ASP.NET Core phải kéo theo cả một hệ thống:

  • Build dependency graph
  • Resolve đệ quy
  • Cache theo lifetime
  • Quản lý scope
  • Dispose resource
  • Optimize constructor invocation

Thứ trông như “framework tự động new object giúp mình” thực ra là một object factory cực kỳ phức tạp đang vận hành phía dưới mỗi request.

Hiểu được flow này không những giúp code CRUD nhanh hơn. Nó cho thấy thứ quan trọng hơn nhiều:

Khả năng debug bằng tư duy hệ thống

Khi application bắt đầu:

  • Inject lỗi
  • Memory leak
  • Circular dependency
  • Object sống sai lifetime
  • DbContext bị dispose sớm
  • Singleton bị race condition

Lúc này sẽ biết chính xác framework đang “kẹt” ở tầng nào thay vì debug trong tuyệt vọng.