Skip to main content

7.2 — Vấn đề DI giải quyết

Summary

Dependency Injection không phải là "dùng interface cho sang". Nó giải quyết một vấn đề rất cụ thể: khi một lớp tự tạo thứ nó cần, lớp đó bị dính chặt vào một implementation, một cấu hình và một vòng đời — cả ba đều không thay đổi được từ bên ngoài. Hậu quả đo được: unit test gửi email thật, đổi SMTP sang SendGrid phải sửa mã nguồn của nghiệp vụ, và mỗi lớp tự mở một connection riêng. DI đảo chiều: lớp khai báo thứ nó cần qua constructor, còn việc tạo ra chúng thuộc về một nơi duy nhất — composition root.

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

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

  • Chỉ ra đúng năm vấn đề của việc new dependency trong constructor.
  • Phân biệt "phụ thuộc vào abstraction" với "phụ thuộc vào implementation".
  • Giải thích vì sao thêm interface mà vẫn new bên trong thì chẳng giải quyết được gì.
  • Nhận ra khi nào không cần DI.

Nội dung bài học​

7.2.1 — Code dính chặt​

public class CustomerService
{
private readonly EmailService _email;
private readonly SqlCustomerRepository _repository;
private readonly ILogger _logger;

public CustomerService()
{
_email = new EmailService("smtp.company.com", 587);
_repository = new SqlCustomerRepository("Server=.;Database=CRM");
_logger = new ConsoleLogger();
}

public async Task CreateAsync(Customer customer)
{
await _repository.AddAsync(customer);
await _email.SendWelcomeAsync(customer.Email);
_logger.Log($"Customer {customer.Id} created");
}
}

Đoạn code này chạy được. Vấn đề chỉ lộ ra khi bạn cần thay đổi thứ gì đó.

7.2.2 — Năm vấn đề, theo thứ tự mức độ đau​

1. Không viết được unit test.

[Fact]
public async Task CreateAsync_ShouldAddCustomer()
{
var service = new CustomerService(); // kết nối SQL thật + gửi email thật
await service.CreateAsync(new Customer("An", "an@example.com"));
}

Không có cách nào chen vào giữa. Test này cần một SQL Server đang chạy, cần SMTP thật, và nó gửi email tới địa chỉ trong dữ liệu test. Đây là lý do nhiều team nói "code cũ không test được" — không phải vì thiếu thời gian, mà vì kiến trúc chặn họ lại.

2. Không đổi được implementation.

Sếp quyết định chuyển từ SMTP sang SendGrid. Bạn phải sửa CustomerService — một lớp nghiệp vụ — vì lý do hạ tầng. Nếu có 40 lớp cùng new EmailService(...), bạn sửa 40 chỗ, và chỉ cần sót một chỗ là production gửi email qua hai đường khác nhau.

3. Cấu hình bị chôn trong mã nguồn.

"smtp.company.com" và "Server=.;Database=CRM" nằm trong code đã biên dịch. Môi trường staging dùng gì? Bạn phải build lại. Và connection string có mật khẩu thì nó nằm trong Git.

4. Vòng đời không kiểm soát được.

Mỗi CustomerService tạo một SqlCustomerRepository riêng, tức một connection riêng. 100 request đồng thời là 100 connection, trong khi lẽ ra chúng nên dùng chung pool. Không ai quản lý việc giải phóng, nên IDisposable chẳng bao giờ được gọi.

5. Phụ thuộc ẩn.

Nhìn new CustomerService() bạn nghĩ nó không cần gì. Thực tế nó cần SQL Server, cần SMTP và cần quyền ghi. Constructor nói dối về những gì lớp này cần — và bạn chỉ biết sự thật khi chạy.

Vấn đềBiểu hiện thực tế
Không test đượcUnit test cần database thật, gửi email thật
Không đổi được implementationĐổi nhà cung cấp email phải sửa lớp nghiệp vụ
Cấu hình chôn trong codeMỗi môi trường phải build một bản khác
Vòng đời mất kiểm soátConnection không dùng chung, Dispose không được gọi
Phụ thuộc ẩnConstructor không cho biết lớp này thật sự cần gì

7.2.3 — Thêm interface chưa phải là giải pháp​

Đây là bước nửa vời rất hay gặp:

public class CustomerService
{
private readonly IEmailService _email;

public CustomerService()
{
_email = new SmtpEmailService("smtp.company.com", 587); // VAN CON new
}
}

Kiểu của field đã là interface, nhưng lớp vẫn tự quyết định implementation nào được dùng. Cả năm vấn đề ở trên vẫn còn nguyên.

Thứ thật sự thay đổi mọi chuyện không phải là interface, mà là ai gọi new.

7.2.4 — Đảo chiều: lớp khai báo, nơi khác cung cấp​

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, _email, _logger) = (repository, email, logger);
}

Năm vấn đề biến mất cùng lúc:

  • Test được: truyền mock vào constructor.
  • Đổi được implementation: sửa một dòng ở composition root.
  • Cấu hình ở ngoài: appsettings.json theo từng môi trường.
  • Vòng đời do container quản: nó biết khi nào tạo, khi nào dùng lại, khi nào Dispose.
  • Phụ thuộc hiện rõ: đọc constructor là biết lớp này cần gì.

Điểm cuối đáng nói thêm: constructor trở thành tài liệu trung thực. Một constructor có 9 tham số không phải là lỗi của DI — đó là DI đang cho bạn thấy lớp này đang làm quá nhiều việc. Xem bài 7.10 — Anti-patterns.

7.2.5 — Khi nào không cần DI​

DI không miễn phí: thêm interface, thêm đăng ký, thêm một lớp gián tiếp khi đọc code. Đừng dùng khi:

  • Kiểu chỉ chứa dữ liệu: new Customer(...), new OrderDto(...). Đừng bao giờ tiêm entity hay DTO.
  • Chỉ có một implementation và sẽ mãi như vậy: StringBuilder, List<T>, các kiểu trong BCL.
  • Hàm thuần không có tác dụng phụ: một lớp tính thuế chỉ nhận số và trả số thì new thẳng cũng được.
  • Script, tool nhỏ, console một lần dùng: chi phí lớn hơn lợi ích.

Tiêu chí thực dụng: có I/O, có thời gian, có ngẫu nhiên, hoặc có thể thay thế thì tiêm. Ngoài ra thì new thoải mái.

DateTime.Now chính là ví dụ kinh điển của "có thời gian": nó khiến test không lặp lại được. .NET 8 có TimeProvider để tiêm đúng chỗ đó.

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

Danh sách rà soát phụ thuộc

  • •Không có lời gọi new nào tới service có I/O bên trong lớp nghiệp vụ.
  • •Không có connection string hay host name nào nằm trong mã nguồn.
  • •Mọi phụ thuộc đều xuất hiện trong constructor, không ẩn đâu đó.
  • •Lớp nghiệp vụ phụ thuộc vào interface, và không tự chọn implementation.
  • •Không tiêm entity hay DTO vào container.
  • •Mọi chỗ dùng DateTime.Now trong logic có thể test đã chuyển sang TimeProvider.
  • •Constructor nào quá 5 tham số đều được xem lại xem lớp có làm quá nhiều việc không.

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

Bài 1 — Thử viết kiểm thử cho code dính chặt​

Lấy đoạn CustomerService đầu bài, viết một unit test kiểm tra rằng email được gửi. Ghi lại chính xác chỗ bạn bị chặn.

Tiêu chí hoàn thành: bạn nêu được rằng "không kiểm thử được" là triệu chứng, và gọi tên được căn bệnh.

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

Gợi ý. Cứ thử viết bài kiểm thử thật. Chỗ bạn bị chặn sẽ tự nói lên vấn đề rõ hơn mọi lời giải thích.

Lời giải — lớp cần kiểm thử:

public class CustomerService
{
public void CreateCustomer(string name, string email)
{
if (!email.Contains('@')) throw new ArgumentException("Email sai");

using var conn = new SqlConnection("Server=prod;Database=Crm;...");
conn.Execute("INSERT INTO Customers ...");

var smtp = new SmtpClient("smtp.company.com");
smtp.Send("noreply@company.com", email, "Chào mừng", "...");

File.AppendAllText(@"C:\logs\customers.txt", $"{DateTime.Now}: {name}\n");
}
}

Thử viết kiểm thử:

[Fact]
public void CreateCustomer_SendsWelcomeEmail()
{
var svc = new CustomerService();
svc.CreateCustomer("An", "an@company.com");

// Assert gì? Không có cách nào quan sát email đã được gửi.
}

Bốn chỗ bị chặn, theo thứ tự gặp phải:

ChỗBị chặn vì
Chuỗi kết nối cứngBài kiểm thử cần database production đang chạy
new SmtpClientCần máy chủ SMTP thật, và sẽ gửi email thật
File.AppendAllText(@"C:\logs\...")Không chạy được trên Linux hay trong CI
Không có gì trả vềKhông có cách nào quan sát kết quả để khẳng định

Chỗ thứ tư là chỗ chặn cuối cùng và tuyệt đối: ngay cả khi bạn dựng được đủ hạ tầng, bài kiểm thử vẫn không có gì để kiểm tra.

Gọi tên căn bệnh. "Không kiểm thử được" là triệu chứng. Bệnh là: lớp này tự quyết định mọi phụ thuộc của nó. Nó không nhận cái gì từ bên ngoài, nên bên ngoài không thay thế được gì.

Điều đó dẫn tới bốn hậu quả, và việc không kiểm thử được chỉ là một:

  1. Không kiểm thử được — không thay được phụ thuộc bằng bản giả.
  2. Không dùng lại được — muốn dùng lớp này trong một tác vụ nền không có SMTP thì không có cách nào.
  3. Không cấu hình được — đổi nhà cung cấp email phải sửa bên trong lớp.
  4. Không chạy song song được — mọi bài kiểm thử đều đụng vào cùng một database và cùng một file log.

Phiên bản có thể kiểm thử:

public class CustomerService(
ICustomerRepository repo,
IEmailSender mail,
ILogger<CustomerService> log)
{
public async Task<Result<Customer>> CreateCustomerAsync(
string name, string email, CancellationToken ct)
{
var customer = Customer.Create(name, email);
if (customer.IsFailure) return customer;

await repo.AddAsync(customer.Value, ct);
await mail.SendWelcomeAsync(customer.Value.Email, ct);
log.LogInformation("Đã tạo khách hàng {Id}", customer.Value.Id);
return customer;
}
}
[Fact]
public async Task CreateCustomer_SendsWelcomeEmail()
{
var repo = Substitute.For<ICustomerRepository>();
var mail = Substitute.For<IEmailSender>();
var svc = new CustomerService(repo, mail, NullLogger<CustomerService>.Instance);

await svc.CreateCustomerAsync("An", "an@company.com", default);

await mail.Received(1).SendWelcomeAsync(
Arg.Is<Email>(e => e.Value == "an@company.com"), Arg.Any<CancellationToken>());
}

Chạy trong vài mili-giây, không cần database, không gửi email thật, chạy được trên mọi hệ điều hành.

Điều đáng nhớ nhất. Khả năng kiểm thử không phải mục tiêu tự thân — nó là thước đo cho mức độ tách rời của thiết kế. Một lớp khó kiểm thử gần như luôn là một lớp khó thay đổi, và cái khó thứ hai mới là cái tốn tiền.

Bài 2 — Đếm điểm chạm khi đổi nhà cung cấp​

Trong một dự án bạn đang làm, tìm mọi chỗ new theo sau bởi tên một lớp service hoặc repository. Đếm bao nhiêu chỗ phải sửa nếu đổi nhà cung cấp email.

Tiêu chí hoàn thành: bạn có con số cụ thể cho cả hai phương án — có DI và không DI.

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

Gợi ý. Tìm bằng:

grep -rn "new \(Smtp\|SendGrid\|Email\|Sql\|Http\)\w*(" --include=*.cs . | grep -v Test

Mỗi kết quả là một điểm chạm — một chỗ code biết tới một lớp cài đặt cụ thể.

Lời giải — kết quả điển hình:

src/Services/CustomerService.cs:34     new SmtpClient(...)
src/Services/OrderService.cs:89 new SmtpClient(...)
src/Services/LeadService.cs:52 new SmtpClient(...)
src/Jobs/DailyReportJob.cs:41 new SmtpClient(...)
src/Jobs/ReminderJob.cs:28 new SmtpClient(...)
src/Controllers/AuthController.cs:117 new SmtpClient(...)
src/Infrastructure/Notifier.cs:19 new SmtpClient(...)

7 điểm chạm. Đổi sang SendGrid nghĩa là sửa 7 file, và mỗi file là một cơ hội để:

  • Quên một chỗ — chỗ đó vẫn dùng SMTP cũ, và không ai phát hiện cho tới khi nó hỏng.
  • Cấu hình khác nhau giữa các chỗ — một chỗ có thử lại, một chỗ không.
  • Gây xung đột Git, vì bảy người có thể đang sửa bảy file đó.

Với DI, con số là 1:

// Chỉ một dòng trong Program.cs hoặc trong extension đăng ký
builder.Services.AddSingleton<IEmailSender, SendGridEmailSender>();

Không một file nghiệp vụ nào bị sửa, vì chúng chỉ biết tới IEmailSender.

Nhưng con số 1 chưa phải điều quan trọng nhất. Ba lợi ích khác đáng kể hơn:

  1. Chạy song song hai nhà cung cấp. Muốn thử SendGrid cho 10% lưu lượng trước khi chuyển hẳn:

    builder.Services.AddSingleton<IEmailSender>(sp =>
    Random.Shared.Next(100) < 10
    ? sp.GetRequiredService<SendGridEmailSender>()
    : sp.GetRequiredService<SmtpEmailSender>());
  2. Khác nhau theo môi trường. Ghi ra file khi phát triển, gửi thật khi chạy production — một dòng if.

  3. Bọc thêm hành vi mà không sửa gì. Thêm thử lại, ghi log, giới hạn tần suất bằng decorator như bài 7.6 trình bày.

Một lưu ý về việc đếm. Không phải mọi new đều là vấn đề. new các đối tượng sau hoàn toàn bình thường:

new Customer("An", email)          // thực thể nghiệp vụ — nên new
new List<Order>() // cấu trúc dữ liệu
new Email("an@x.com") // value object
new OrderCreatedEvent(id) // thông điệp

Chỉ new các service có phụ thuộc bên ngoài mới là dấu hiệu code dính chặt: thứ nào chạm tới database, mạng, hệ thống file, đồng hồ, hoặc số ngẫu nhiên.

Phép thử một câu. Nếu tôi thay thứ này bằng một bản giả trong kiểm thử, bài kiểm thử có ý nghĩa hơn không? Có thì nó nên được tiêm vào; không thì cứ new thoải mái.

Bài 3 — Làm bài kiểm thử lặp lại được​

Viết một hàm dùng DateTime.Now để quyết định có áp dụng khuyến mãi cuối tháng hay không, rồi viết kiểm thử cho nó. Sửa lại bằng TimeProvider và so sánh hai bài kiểm thử.

Tiêu chí hoàn thành: bài kiểm thử mới chạy đúng vào mọi ngày trong năm, kể cả ngày cuối tháng 2 của năm nhuận.

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

Gợi ý. Bài này lặp lại ý ở bài 1.5 và bài 4.5, nhưng ở đây trọng tâm là tiêm qua DI thay vì truyền tham số.

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

public class PromotionService
{
public decimal CalculateDiscount(decimal amount)
{
var today = DateTime.Now;
var lastDayOfMonth = DateTime.DaysInMonth(today.Year, today.Month);

return today.Day == lastDayOfMonth
? amount * 0.10m // khuyến mãi ngày cuối tháng
: 0m;
}
}

Bài kiểm thử cho phiên bản này:

[Fact]
public void LastDayOfMonth_Gives10PercentOff()
{
var svc = new PromotionService();
Assert.Equal(100_000m, svc.CalculateDiscount(1_000_000m));
}

Bài kiểm thử này chỉ đúng vào ngày cuối tháng. Ba mươi ngày còn lại trong tháng nó đỏ. Kết quả thực tế: ai đó đánh dấu nó [Skip] và nó không bao giờ chạy nữa.

Sau:

public class PromotionService(TimeProvider time)
{
public decimal CalculateDiscount(decimal amount)
{
var today = time.GetLocalNow();
var lastDayOfMonth = DateTime.DaysInMonth(today.Year, today.Month);

return today.Day == lastDayOfMonth ? amount * 0.10m : 0m;
}
}

Đăng ký:

builder.Services.AddSingleton(TimeProvider.System);

Bài kiểm thử mới:

[Theory]
[InlineData(2026, 1, 31, true)] // cuối tháng 1
[InlineData(2026, 1, 30, false)] // áp chót
[InlineData(2026, 2, 28, true)] // cuối tháng 2, năm thường
[InlineData(2024, 2, 29, true)] // cuối tháng 2, NĂM NHUẬN
[InlineData(2024, 2, 28, false)] // áp chót năm nhuận — KHÔNG phải cuối tháng
[InlineData(2026, 4, 30, true)] // tháng 30 ngày
public void LastDayOfMonthCases(int year, int month, int day, bool hasDiscount)
{
var time = new FakeTimeProvider(new DateTimeOffset(year, month, day, 12, 0, 0, TimeSpan.Zero));
var svc = new PromotionService(time);

Assert.Equal(hasDiscount ? 100_000m : 0m, svc.CalculateDiscount(1_000_000m));
}

So sánh hai bài kiểm thử:

TrướcSau
Chạy đúng vào1 ngày mỗi thángMọi ngày
Số trường hợp kiểm tra16
Kiểm tra được năm nhuậnKhôngCó
Kiểm tra được ranh giớiKhôngCó
Chạy được trong CIKhông tin cậyCó

Dòng thứ tư đáng chú ý: trường hợp 2024-02-28 — áp chót của tháng 2 năm nhuận — là một ca biên mà không ai nghĩ ra nếu không có khả năng đặt thời gian tuỳ ý. Đây là ví dụ cụ thể cho việc bài kiểm thử tốt không chỉ xác nhận code đúng, nó còn giúp bạn phát hiện ca biên.

Ba nguồn bất định khác cần xử lý cùng cách:

// Số ngẫu nhiên
public class AbTestService(Random rnd) { }
builder.Services.AddSingleton(Random.Shared);

// Mã định danh sinh mới
public interface IGuidGenerator { Guid New(); }

// Múi giờ — TimeProvider có sẵn
time.LocalTimeZone

Vì sao tiêm qua DI tốt hơn truyền tham số. Với hàm nhỏ thì truyền DateTime làm tham số là đủ, như bài 1.5 đã nêu. Nhưng khi chuỗi gọi sâu — controller gọi service gọi repository — truyền tay qua mọi tầng là không khả thi. DI giải quyết đúng vấn đề đó: khai báo một lần ở constructor, container lo phần còn lại.

Tự kiểm tra​

Frequently asked questions

Vấn đề lớn nhất của việc new dependency trong constructor là gì?

Không viết được unit test. Không có cách nào chen vào giữa để thay thế dependency, nên test cần database thật, SMTP thật và sẽ gửi email tới địa chỉ trong dữ liệu test. Đây là lý do nhiều codebase cũ bị coi là không test được: không phải thiếu thời gian mà là kiến trúc chặn lại.

Thêm interface có đủ để giải quyết vấn đề không?

Không. Nếu kiểu của field là interface nhưng constructor vẫn gọi new một implementation cụ thể thì lớp vẫn tự quyết định implementation nào được dùng, và cả năm vấn đề vẫn còn nguyên. Thứ thật sự thay đổi là ai gọi new, chứ không phải có interface hay không.

Phụ thuộc ẩn nghĩa là gì?

Là khi constructor không cho biết lớp thật sự cần những gì. Nhìn new CustomerService bạn nghĩ nó không cần gì, nhưng thực tế nó cần SQL Server, cần SMTP và cần quyền ghi. Constructor nói dối, và bạn chỉ biết sự thật khi chạy. Với DI thì đọc constructor là biết ngay.

Constructor có 9 tham số có phải lỗi của DI không?

Không, đó là DI đang cho bạn thấy lớp này làm quá nhiều việc. Trước khi có DI thì vấn đề đó vẫn tồn tại nhưng bị giấu đi. Cách chữa là tách lớp, không phải giấu bớt phụ thuộc.

Khi nào không nên dùng DI?

Với kiểu chỉ chứa dữ liệu như entity và DTO, với kiểu chỉ có một implementation và sẽ mãi như vậy như StringBuilder hay List, với hàm thuần không có tác dụng phụ, và với script hay tool nhỏ dùng một lần. Tiêu chí thực dụng là có I/O, có thời gian, có ngẫu nhiên hoặc có thể thay thế thì tiêm; ngoài ra cứ new.

Vì sao DateTime.Now là vấn đề?

Vì nó khiến test không lặp lại được: cùng một bài test cho kết quả khác nhau tuỳ vào lúc bạn chạy nó. Đây là dạng phụ thuộc vào thời gian, và .NET 8 có TimeProvider để tiêm nó vào như một dependency bình thường.

Kết luận​

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

  1. Vấn đề không phải là thiếu interface, mà là lớp tự gọi new.
  2. Constructor phải nói thật về những gì lớp cần — đó là lợi ích bị đánh giá thấp nhất của DI.
  3. DI có chi phí. Chỉ tiêm thứ có I/O, có thời gian, có ngẫu nhiên hoặc có thể thay thế.

Tham khảo​

Điều hướng​