Chuyển tới nội dung chính

7.4 — 2. Ba cách tiêm dependency

Tóm tắt

Có ba kiểu tiêm phụ thuộc, nhưng chúng không ngang hàng nhau. Constructor injection là mặc định cho gần như mọi trường hợp, và là kiểu duy nhất container .NET hỗ trợ sẵn — nó cho bạn readonly, đảm bảo đối tượng không bao giờ ở trạng thái thiếu phụ thuộc, và biến constructor thành tài liệu trung thực. Property injection phá vỡ cả ba điều đó và container .NET không hỗ trợ. Method injection hẹp nhưng có chỗ đứng thật: khi framework tự tiêm vào từng lời gọi, như [FromServices] trong action hay InvokeAsync của middleware.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Viết constructor injection đúng chuẩn với primary constructor của C# 12.
  • Giải thích ba lý do constructor injection thắng property injection.
  • Nhận ra đúng bốn chỗ method injection là cách làm chính thống.
  • Xử lý phụ thuộc tuỳ chọn mà không cần property injection.

Nội dung bài học​

7.4.1 — Constructor injection​

public class CustomerService
{
private readonly ICustomerRepository _repository;
private readonly IEmailService _email;
private readonly ILogger<CustomerService> _logger;

public CustomerService(
ICustomerRepository repository,
IEmailService email,
ILogger<CustomerService> logger)
{
_repository = repository;
_email = email;
_logger = logger;
}
}

Từ C# 12, primary constructor gọn hơn nhiều:

public class CustomerService(
ICustomerRepository repository,
IEmailService email,
ILogger<CustomerService> logger)
{
public async Task<Customer> CreateAsync(CreateCustomerRequest request, CancellationToken ct)
{
var customer = new Customer(request.Name, request.Email);
await repository.AddAsync(customer, ct);
await email.SendWelcomeAsync(customer.Email, ct);
logger.LogInformation("Customer {CustomerId} created", customer.Id);
return customer;
}
}

Một lưu ý: tham số của primary constructor không phải readonly — bạn có thể gán lại chúng trong phương thức, và trình biên dịch không ngăn. Nếu muốn giữ đảm bảo đó thì vẫn viết constructor truyền thống, hoặc gán ra field readonly.

Ba lý do nó là mặc định:

  1. Bất biến — field readonly không ai đổi được sau khi dựng.
  2. Không có trạng thái nửa vời — đối tượng tồn tại nghĩa là nó đã đủ phụ thuộc.
  3. Trung thực — đọc constructor là biết lớp này cần gì. Constructor dài là tín hiệu lớp làm quá nhiều việc, chứ không phải lỗi của DI.

Còn kiểm tra null thì sao?

_repository = repository ?? throw new ArgumentNullException(nameof(repository));

Với lớp do container tạo, việc này thừa: container không bao giờ truyền null — nó ném exception ngay nếu thiếu đăng ký. Kiểm tra null chỉ đáng giá trong thư viện công khai, nơi người khác có thể gọi constructor trực tiếp. Trong code ứng dụng, bật nullable reference types là đủ.

7.4.2 — Property injection và vì sao nên tránh​

public class ReportGenerator
{
public IReportFormatter? Formatter { get; set; } // container .NET KHÔNG tiêm cái này
}

Container .NET không hỗ trợ property injection. Autofac có, nhưng cả khi có thì nó vẫn phá đúng ba điều ở mục trên:

  • Không readonly — bất kỳ ai cũng gán lại được, kể cả nhầm.
  • Có trạng thái nửa vời — giữa lúc new và lúc gán property, đối tượng tồn tại nhưng chưa dùng được.
  • Phụ thuộc bị giấu — constructor rỗng, người đọc không biết lớp cần gì.

Điểm thứ hai gây ra NullReferenceException ở rất xa chỗ thật sự sai: ai đó quên gán property, và lỗi nổ ba tầng phía dưới.

Phụ thuộc tuỳ chọn thì làm thế nào? Không dùng property injection. Có ba cách tốt hơn:

// 1. Null Object — implementation không làm gì
public sealed class NullReportFormatter : IReportFormatter
{
public string Format(ReportData d) => d.ToString();
}
services.AddSingleton<IReportFormatter, NullReportFormatter>();

// 2. Constructor cho phép null, rõ ràng trong chữ ký
public class ReportGenerator(IReportFormatter? formatter = null) { }

// 3. Tách thành hai lớp, mỗi lớp một trách nhiệm

Cách 1 là tốt nhất: code dùng không cần kiểm tra null ở đâu cả.

7.4.3 — Method injection và bốn chỗ nó đúng​

Method injection là truyền phụ thuộc vào từng lời gọi thay vì vào đối tượng. Nó đúng khi framework tự làm việc đó cho bạn:

// 1. Middleware — InvokeAsync được tiêm theo từng request
public class AuditMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context, IAuditService audit)
{
await audit.LogAsync(context.Request.Path);
await next(context);
}
}

Chỗ này bắt buộc dùng method injection: middleware được dựng một lần lúc khởi động (singleton), nên tiêm IAuditService (scoped) vào constructor là lỗi captive dependency — xem bài 7.5.

// 2. Minimal API — tiem thang vao handler
app.MapGet("/customers/{id}", async (int id, ICustomerService service, CancellationToken ct)
=> await service.GetAsync(id, ct));

// 3. [FromServices] trong controller — service chi mot action can
[HttpPost("export")]
public async Task<IActionResult> Export([FromServices] IExportService export, CancellationToken ct)
=> File(await export.BuildAsync(ct), "text/csv", "customers.csv");

// 4. IHostedService/Program.cs — resolve tai composition root
using var scope = app.Services.CreateScope();
var migrator = scope.ServiceProvider.GetRequiredService<IDbMigrator>();
await migrator.MigrateAsync();

Trường hợp 3 đáng dùng khi một service nặng mà chỉ một action trong controller cần: đưa vào constructor thì mọi request tới controller đó đều phải dựng nó.

Ngoài bốn chỗ này, method injection trong code của bạn thường là dấu hiệu lớp đang làm hai việc khác nhau.

7.4.4 — Bảng so sánh​

ConstructorPropertyMethod
Container .NET hỗ trợCóKhôngChỉ ở framework hook
readonly đượcCóKhôngKhông áp dụng
Phụ thuộc hiện rõCóKhôngCó, ở chữ ký hàm
Trạng thái nửa vờiKhôngCóKhông
Khi nào dùngMặc địnhGần như không bao giờMiddleware, minimal API, [FromServices]

7.4.5 — Rà lại code của bạn​

Danh sách rà soát cách tiêm dependency

  • •Mọi phụ thuộc bắt buộc đều đi qua constructor.
  • •Mỗi lớp chỉ có một constructor công khai.
  • •Field phụ thuộc là readonly, hoặc dùng primary constructor có ý thức.
  • •Không có public setter nào dùng để tiêm phụ thuộc.
  • •Phụ thuộc tuỳ chọn dùng Null Object thay vì property nullable.
  • •Middleware tiêm service scoped qua InvokeAsync, không qua constructor.
  • •[FromServices] chỉ dùng cho service nặng mà một action cần.
  • •Không kiểm tra null thủ công trong constructor của lớp nội bộ.

Bài tập áp dụng​

Bài 1 — Primary constructor và điều nó không bảo đảm​

Lấy một service có 4 phụ thuộc, viết lại bằng primary constructor của C# 12. Thử gán lại một tham số trong phương thức và quan sát trình biên dịch không báo lỗi.

Tiêu chí hoàn thành: bạn nêu được vì sao điều đó là một khác biệt có ý nghĩa so với trường readonly.

Gợi ý và lời giải — Bài 1

Gợi ý. Tham số của primary constructor được trình biên dịch nâng thành trường ẩn — nhưng trường đó có readonly không?

Lời giải — trước:

public class OrderService
{
private readonly IOrderRepository _repo;
private readonly IEmailSender _mail;
private readonly ITaxCalculator _tax;
private readonly ILogger<OrderService> _log;

public OrderService(IOrderRepository repo, IEmailSender mail,
ITaxCalculator tax, ILogger<OrderService> log)
{
_repo = repo; _mail = mail; _tax = tax; _log = log;
}
}

Sau — 13 dòng xuống 1:

public class OrderService(
IOrderRepository repo,
IEmailSender mail,
ITaxCalculator tax,
ILogger<OrderService> log)
{
public async Task ProcessAsync(Order o, CancellationToken ct)
{
await repo.AddAsync(o, ct);
log.LogInformation("Đã xử lý đơn {Id}", o.Id);
}
}

Thử gán lại:

public void Sabotage()
{
repo = new EfOrderRepository(null!); // BIÊN DỊCH ĐƯỢC
}

Trình biên dịch không báo lỗi. Với bản dùng trường readonly, cùng dòng đó cho lỗi:

error CS0191: A readonly field cannot be assigned to

Vì sao điều này có ý nghĩa. Tham số primary constructor được nâng thành trường ẩn không có readonly. Hệ quả là một lớp có thể tự thay đổi phụ thuộc của mình giữa chừng, và không có gì ngăn cản:

public class Svc(IEmailSender mail)
{
public void Reset() => mail = new NullEmailSender(); // hợp lệ
public Task SendAsync() => mail.SendAsync(...); // giờ là bản khác
}

Đây là một thoái lui nhỏ về tính bất biến so với cách viết cũ. Trong thực tế hiếm ai cố tình làm vậy, nhưng nó có thể xảy ra do nhầm lẫn — ví dụ gõ repo = ... khi định viết var repo = ....

Cách lấy lại bảo đảm khi cần:

public class OrderService(IOrderRepository repo)
{
private readonly IOrderRepository _repo = repo; // gán vào trường readonly
// dùng _repo thay vì repo
}

Đổi lại thì mất phần lớn sự gọn gàng. Với phần lớn service, primary constructor vẫn là đánh đổi tốt.

Ba lưu ý khác về primary constructor:

  1. Tham số được ghi lại thành trường chỉ khi được dùng. Nếu bạn chỉ truyền nó cho lớp cơ sở, trình biên dịch không tạo trường — đó là tối ưu tốt, và là lý do cảnh báo CS9113 xuất hiện khi tham số không được dùng.

  2. Cẩn thận với record có primary constructor. Với record, tham số trở thành thuộc tính công khai; với class thì không. Đây là khác biệt dễ nhầm:

    public record R(int X);      // X là thuộc tính CÔNG KHAI
    public class C(int X); // X là trường ẩn, KHÔNG công khai
  3. Không dùng được với xác thực trong constructor. Muốn kiểm tra tham số trước khi gán thì phải viết constructor thường — hoặc dùng cách gán vào trường có xác thực:

    public class Svc(string ten)
    {
    private readonly string _ten = string.IsNullOrWhiteSpace(ten)
    ? throw new ArgumentException(null, nameof(ten))
    : ten;
    }

Bài 2 — Tái hiện lỗi property injection​

Viết một lớp có phụ thuộc qua thuộc tính, quên gán nó, và quan sát NullReferenceException nổ ra ở đâu so với chỗ thật sự sai. Thay bằng Null Object và so sánh.

Tiêu chí hoàn thành: bạn chỉ ra được khoảng cách giữa nơi lỗi nổ và nơi nguyên nhân.

Gợi ý và lời giải — Bài 2

Gợi ý. Với constructor injection, thiếu phụ thuộc thì không tạo được đối tượng. Với property injection, đối tượng được tạo bình thường và lỗi chỉ xuất hiện khi thuộc tính đó được dùng.

Lời giải.

public class OrderService
{
public IEmailSender? Mail { get; set; } // property injection

public async Task ProcessAsync(Order o, CancellationToken ct)
{
await _repo.AddAsync(o, ct); // dòng 12
// ... 40 dòng logic nghiệp vụ ...
await Mail!.SendConfirmationAsync(o, ct); // dòng 53 — NỔ Ở ĐÂY
}
}

// Ở Program.cs — quên gán
builder.Services.AddScoped<OrderService>(); // dòng 27 — NGUYÊN NHÂN Ở ĐÂY

Khoảng cách:

Nơi
Nguyên nhân thậtProgram.cs dòng 27 — quên đăng ký hoặc quên gán
Nơi lỗi nổOrderService.cs dòng 53
Thời điểmKhi request đầu tiên đi tới nhánh gửi email

Ba khoảng cách cùng lúc: khác file, khác thời điểm, và đơn hàng đã được lưu rồi — nên hệ thống ở trạng thái dở dang.

Với constructor injection, cùng lỗi đó cho:

InvalidOperationException: Unable to resolve service for type 'IEmailSender'
while attempting to activate 'OrderService'.

Thông báo nói rõ thiếu gì và cho lớp nào, và nó xuất hiện lúc khởi động nếu bật ValidateOnBuild như bài 7.6 trình bày.

Null Object — giảm nhẹ nhưng không sửa gốc:

public sealed class NullEmailSender : IEmailSender
{
public static readonly NullEmailSender Instance = new();
public Task SendConfirmationAsync(Order o, CancellationToken ct) => Task.CompletedTask;
}

public class OrderService
{
public IEmailSender Mail { get; set; } = NullEmailSender.Instance; // mặc định an toàn
}

Giờ không còn NullReferenceException. Nhưng hãy chú ý điều đã đổi:

Trước (null)Sau (Null Object)
Triệu chứngNgoại lệ rõ ràngKhông có triệu chứng
Hậu quảRequest thất bạiEmail âm thầm không được gửi
Phát hiệnNgayKhi khách hàng phàn nàn

Null Object đổi một lỗi ồn ào thành một lỗi im lặng. Với thứ không quan trọng như ghi log, đó là đánh đổi tốt — NullLogger<T>.Instance tồn tại chính vì lý do đó. Với thứ quan trọng như gửi email xác nhận, đó là đánh đổi tệ.

Ba chỗ property injection chính đáng:

  1. Thuộc tính thật sự tuỳ chọn, và hành vi mặc định là hợp lý — ví dụ ILogger.
  2. Framework yêu cầu, ví dụ một số bộ lọc của ASP.NET Core.
  3. Phá vòng phụ thuộc — nhưng vòng phụ thuộc thường là dấu hiệu thiết kế sai, nên hãy sửa gốc trước.

Ngoài ba trường hợp trên, constructor injection luôn tốt hơn vì nó biến "quên cấu hình" thành lỗi lúc khởi động thay vì lỗi lúc chạy.

Bài 3 — Captive dependency trong middleware​

Tiêm một service Scoped vào constructor của middleware, chạy ứng dụng và ghi lại ngoại lệ. Chuyển sang tham số của InvokeAsync và xác nhận nó chạy.

Tiêu chí hoàn thành: bạn nêu được vì sao middleware là Singleton dù bạn không đăng ký nó như vậy.

Gợi ý và lời giải — Bài 3

Gợi ý. Middleware được dựng một lần khi đường ống được xây, không phải mỗi request. Vậy vòng đời thực tế của nó là gì?

Lời giải — bản sai:

public class TenantMiddleware
{
private readonly RequestDelegate _next;
private readonly ITenantContext _tenant; // Scoped

public TenantMiddleware(RequestDelegate next, ITenantContext tenant)
{
_next = next;
_tenant = tenant;
}

public async Task InvokeAsync(HttpContext ctx) { /* ... */ }
}
InvalidOperationException: Cannot resolve scoped service 'ITenantContext'
from root provider.

Bản đúng — nhận qua tham số của InvokeAsync:

public class TenantMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext ctx, ITenantContext tenant)
{
// tenant được lấy từ phạm vi CỦA REQUEST NÀY
ctx.Items["TenantId"] = tenant.TenantId;
await next(ctx);
}
}

Vì sao middleware là Singleton. Đường ống middleware được xây một lần lúc khởi động. Mỗi middleware được khởi tạo đúng một lần và tồn tại suốt vòng đời ứng dụng — dù bạn không viết AddSingleton ở đâu cả.

Đây là điểm dễ gây bất ngờ: vòng đời không đến từ đăng ký DI mà đến từ cách framework xây đường ống.

ASP.NET Core biết điều này, nên nó cho phép InvokeAsync nhận thêm tham số và tự giải quyết chúng từ phạm vi của request. Đây là một trong số ít chỗ mà method injection là cách làm đúng và được khuyến khích.

Cùng vấn đề, ba chỗ khác:

Thành phầnVòng đời thực tếCách nhận service Scoped
MiddlewareSingletonTham số của InvokeAsync
BackgroundServiceSingletonIServiceScopeFactory
IHostedServiceSingletonIServiceScopeFactory
Bộ lọc dạng thuộc tínhTuỳ cách đăng kýServiceFilter hoặc TypeFilter

Middleware dựa trên factory — lựa chọn thay thế. Khi bạn muốn middleware có vòng đời Scoped:

public class TenantMiddleware(ITenantContext tenant) : IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
ctx.Items["TenantId"] = tenant.TenantId;
await next(ctx);
}
}

builder.Services.AddScoped<TenantMiddleware>(); // BẮT BUỘC phải đăng ký
app.UseMiddleware<TenantMiddleware>();

Với IMiddleware, ASP.NET Core lấy middleware từ container cho mỗi request, nên constructor injection service Scoped trở nên hợp lệ.

So sánh hai cách:

Quy ước (InvokeAsync)IMiddleware
Phải đăng ký DIKhôngCó
Vòng đờiSingletonTheo đăng ký
Nhận service ScopedQua tham số phương thứcQua constructor
Hiệu năngNhanh hơn chútMột lần resolve mỗi request
Kiểm tra lúc biên dịchKhông — dựa trên quy ước tênCó — cài interface

Với middleware đơn giản, cách quy ước là đủ và nhẹ hơn. Với middleware có nhiều phụ thuộc, IMiddleware rõ ràng hơn và được trình biên dịch kiểm tra.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao constructor injection là mặc định?

Ba lý do. Nó cho phép field readonly nên phụ thuộc bất biến sau khi dựng. Nó không tạo ra trạng thái nửa vời, tức đối tượng tồn tại nghĩa là đã đủ phụ thuộc. Và nó trung thực, đọc constructor là biết lớp cần gì. Đây cũng là kiểu duy nhất container .NET hỗ trợ sẵn.

Primary constructor của C# 12 có nhược điểm gì?

Tham số của nó không phải readonly, bạn có thể gán lại trong phương thức và trình biên dịch không ngăn. Nếu muốn giữ đảm bảo bất biến thì viết constructor truyền thống hoặc gán ra field readonly.

Có nên kiểm tra null trong constructor không?

Với lớp do container tạo thì thừa, vì container không bao giờ truyền null mà sẽ ném exception ngay nếu thiếu đăng ký. Kiểm tra null chỉ đáng giá trong thư viện công khai nơi người khác gọi constructor trực tiếp. Trong code ứng dụng, bật nullable reference types là đủ.

Vì sao nên tránh property injection?

Container .NET không hỗ trợ nó, và ngay cả khi dùng container khác thì nó phá cả ba lợi ích của constructor injection: không readonly được, có trạng thái nửa vời giữa lúc new và lúc gán, và phụ thuộc bị giấu khỏi constructor. Hậu quả điển hình là NullReferenceException nổ ra rất xa chỗ thật sự sai.

Phụ thuộc tuỳ chọn thì xử lý thế nào?

Tốt nhất là Null Object, tức một implementation không làm gì được đăng ký bình thường, để code dùng không phải kiểm tra null ở đâu cả. Hai cách khác là tham số constructor nullable có giá trị mặc định, hoặc tách thành hai lớp mỗi lớp một trách nhiệm.

Method injection đúng ở những chỗ nào?

Bốn chỗ: tham số của InvokeAsync trong middleware, handler của minimal API, thuộc tính FromServices trong action của controller, và việc resolve tại composition root qua CreateScope. Với middleware thì đây là bắt buộc chứ không phải lựa chọn, vì middleware là singleton nên tiêm service scoped vào constructor là captive dependency.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Constructor injection là mặc định — và là kiểu duy nhất container .NET hỗ trợ.
  2. Property injection giấu phụ thuộc và tạo trạng thái nửa vời. Dùng Null Object cho phụ thuộc tuỳ chọn.
  3. Method injection đúng ở đúng bốn chỗ, và với middleware thì nó bắt buộc.

Tham khảo​

Điều hướng​