7.10 — 8. Anti-patterns
Sáu lỗi, và điểm chung của chúng là giấu phụ thuộc đi. Service locator tiêm IServiceProvider rồi tự đi lấy, khiến constructor không còn nói thật. Ambient context dùng static mutable, nên hai request song song ghi đè lẫn nhau. Over-injection không phải lỗi của DI mà là DI đang tố cáo một lớp làm quá nhiều việc. Còn interface chỉ có một implementation và không ai mock thì chỉ là một lớp gián tiếp khiến "Go to Definition" luôn dẫn tới đúng cái interface rỗng đó.
Mục tiêu bài học
Sau bài này bạn có thể:
- Nhận ra service locator kể cả khi nó được nguỵ trang.
- Giải thích vì sao
staticmutable hỏng trong môi trường đa luồng. - Sửa over-injection bằng tách lớp thay vì gom facade.
- Quyết định có nên tạo interface cho một lớp hay không.
- Rà một pull request DI với danh sách cụ thể.
Nội dung bài học
7.10.1 — Service locator
// SAI
public class CustomerService(IServiceProvider sp)
{
public async Task ProcessAsync(Guid id, CancellationToken ct)
{
var repo = sp.GetRequiredService<ICustomerRepository>();
var email = sp.GetRequiredService<IEmailService>();
var audit = sp.GetRequiredService<IAuditLog>();
...
}
}
Ba vấn đề:
- Constructor nói dối. Nó khai là cần một
IServiceProvider; thật ra nó cần ba service. Muốn biết thì phải đọc hết thân mọi phương thức. - Test khó và giòn. Phải mock
IServiceProvidervà dạy nó trả về đúng ba kiểu. Thêm một phụ thuộc là bài test cũ gãy dù hành vi không đổi. - Lỗi chuyển từ lúc khởi động sang lúc chạy. Thiếu đăng ký thì
ValidateOnBuildkhông bắt được, và bạn nhận exception ở giữa một nghiệp vụ.
Nó thường được nguỵ trang:
public static class ServiceLocator // van la service locator
{
public static IServiceProvider Current { get; set; } = null!;
public static T Get<T>() where T : notnull => Current.GetRequiredService<T>();
}
Chỉ có ba chỗ được phép chạm vào IServiceProvider:
- Composition root —
Program.cs, nơi dựng scope cho migration. BackgroundServicequaIServiceScopeFactory(bài 7.5).- Factory hẹp resolve đúng một kiểu đã biết, như ở bài 7.9.
Ngoài ba chỗ đó, sửa bằng constructor injection.
7.10.2 — Ambient context
// SAI
public static class CurrentTenant
{
public static string? Id { get; set; }
}
Trong ứng dụng web, static mutable là trạng thái chia sẻ giữa mọi request đồng thời. Request A đặt Id = "acme", request B đặt Id = "globex" ngay sau đó, và request A đọc lại thì thấy "globex" — nó vừa truy vấn dữ liệu của khách hàng khác.
Lỗi này gần như không xuất hiện khi test thủ công (một người dùng tại một thời điểm) và xuất hiện ngay khi có tải. Đó là kiểu lỗi tệ nhất.
// DUNG
public interface ITenantContext { string TenantId { get; } }
public sealed class HttpTenantContext(IHttpContextAccessor accessor) : ITenantContext
{
public string TenantId =>
accessor.HttpContext?.User.FindFirst("tenant_id")?.Value
?? throw new InvalidOperationException("Không xác định được tenant");
}
services.AddHttpContextAccessor();
services.AddScoped<ITenantContext, HttpTenantContext>();
Cùng họ với lỗi này: DateTime.Now (dùng TimeProvider), Random.Shared trong logic cần lặp lại được, và Thread.CurrentPrincipal.
AsyncLocal<T> không phải ngoại lệ được miễn trừ: nó an toàn hơn static vì bám theo luồng thực thi async, nhưng vẫn là phụ thuộc ẩn. Nó chỉ hợp cho hạ tầng cắt ngang như tracing, nơi việc truyền qua mọi chữ ký hàm là bất khả thi.
7.10.3 — Over-injection
public class CustomerService(
ICustomerRepository customerRepo,
ILeadRepository leadRepo,
IOrderRepository orderRepo,
IEmailService email,
ISmsService sms,
IPushService push,
ILeadScorer scorer,
IReportGenerator reports,
IExportService export,
ICacheService cache,
ILogger<CustomerService> logger)
{ }
DI không gây ra vấn đề này — nó chỉ làm cho vấn đề nhìn thấy được. Trước khi có DI, lớp này cũng làm 11 việc, chỉ là 11 lời gọi new nằm rải rác trong thân phương thức.
Cách sửa đúng là tách theo trách nhiệm:
public class CustomerService(ICustomerRepository repo, ILogger<CustomerService> logger);
public class CustomerNotificationService(IEmailService email, ISmsService sms, IPushService push);
public class LeadManagementService(ILeadRepository repo, ILeadScorer scorer);
public class CustomerReportService(IReportGenerator reports, IExportService export);
Cách sửa sai là gom lại cho gọn mắt:
// SAI — chỉ giấu bớt, không giảm phụ thuộc
public class CustomerService(INotificationFacade notifications, IDataFacade data) { }
Facade chỉ chuyển 11 phụ thuộc xuống một tầng. Lớp vẫn làm 11 việc, chỉ là constructor không còn tố cáo nữa.
Ngưỡng thực dụng: quá 5 phụ thuộc thì dừng lại xem xét. Đó là tín hiệu, không phải luật — ILogger gần như luôn có mặt và không tính, còn một handler điều phối thật sự có thể cần 6 thứ.
7.10.4 — Interface chỉ để có interface
public interface ICustomerMapper { CustomerDto Map(Customer c); }
public class CustomerMapper : ICustomerMapper { ... } // duy nhất, không ai mock
Nếu một interface có đúng một implementation, không ai mock nó trong test, và bạn không thấy trước một implementation thứ hai — thì nó chỉ là chi phí: thêm một file, thêm một dòng đăng ký, và "Go to Definition" luôn dẫn tới một khai báo rỗng.
Ba lý do chính đáng để tạo interface:
- Cần mock trong test — thường vì lớp có I/O.
- Có nhiều implementation thật — ngay bây giờ, không phải "có thể sau này".
- Ranh giới kiến trúc — layer application định nghĩa interface, infrastructure hiện thực nó (Module 16 — Clean Architecture).
Hàm ánh xạ thuần, hàm tính toán thuần, extension method: đăng ký lớp cụ thể là đủ, hoặc thậm chí static.
7.10.5 — Hai lỗi còn lại
Container leak sang domain. Entity và value object không được biết gì về IServiceProvider hay [FromKeyedServices]. Domain phải dựng được bằng new trong một unit test không có container nào.
Tự new service dù đã có DI.
public class OrderService(ICustomerRepository repo)
{
public async Task ProcessAsync(Order order, CancellationToken ct)
{
var email = new SmtpEmailService("smtp.company.com", 587); // bỏ qua cả hệ thống DI
...
}
}
Chỗ này thường xuất hiện khi ai đó thêm tính năng gấp. Nó mang lại đầy đủ năm vấn đề ở bài 7.2, chỉ khác là bây giờ chúng nằm lẫn giữa code đã làm đúng.
7.10.6 — Rà lại code của bạn
Danh sách rà soát anti-pattern DI
- •Không có lớp nghiệp vụ nào tiêm IServiceProvider.
- •Không có lớp static nào giữ trạng thái thay đổi được.
- •Thông tin theo request lấy qua service Scoped, không qua static.
- •Không có constructor nào quá 5 phụ thuộc mà chưa được xem xét.
- •Không có facade nào được tạo chỉ để giảm số tham số constructor.
- •Mọi interface đều có lý do: cần mock, có nhiều implementation, hoặc là ranh giới kiến trúc.
- •Entity và value object không biết gì về container.
- •Không còn new nào tới service có I/O bên trong lớp đã dùng DI.
Bài tập áp dụng
Bài 1 — Tái hiện lỗi ambient context
Dùng một lớp static giữ mã tổ chức, bắn 50 request đồng thời với 2 tổ chức khác nhau, đếm số request đọc sai. Chuyển sang scoped và đếm lại.
Tiêu chí hoàn thành: bạn có con số cụ thể và giải thích được vì sao AsyncLocal đúng còn static thường thì sai.
Gợi ý và lời giải — Bài 1
Gợi ý. Một biến static thường được mọi luồng dùng chung. Khi 50 thao tác xen kẽ nhau, giá trị cuối cùng ghi vào là giá trị mọi thao tác đọc được.
Lời giải.
public static class AmbientStatic
{
public static string? Tenant; // dùng chung mọi luồng
}
public static class AmbientAsyncLocal
{
private static readonly AsyncLocal<string?> _t = new();
public static string? Tenant { get => _t.Value; set => _t.Value = value; }
}
int saiStatic = 0, saiAsync = 0;
await Task.WhenAll(Enumerable.Range(0, 50).Select(async i =>
{
var tenant = i % 2 == 0 ? "A" : "B";
AmbientStatic.Tenant = tenant;
AmbientAsyncLocal.Tenant = tenant;
await Task.Delay(Random.Shared.Next(1, 20)); // mô phỏng công việc
if (AmbientStatic.Tenant != tenant) Interlocked.Increment(ref saiStatic);
if (AmbientAsyncLocal.Tenant != tenant) Interlocked.Increment(ref saiAsync);
}));
Kết quả đo thật trên .NET 9:
static : 25/50 request đọc SAI tenant
AsyncLocal : 0/50 request đọc SAI tenant
Một nửa số request đọc sai tổ chức. Trong hệ thống nhiều tổ chức dùng chung, đó là rò rỉ dữ liệu giữa các khách hàng — loại sự cố nghiêm trọng nhất có thể xảy ra với một hệ thống SaaS.
Vì sao AsyncLocal đúng. Nó lưu giá trị theo luồng thực thi logic chứ không theo luồng vật lý. Mỗi nhánh async có bản sao riêng, và bản sao đó đi theo qua các lần await kể cả khi luồng vật lý thay đổi. Đây cũng là cơ chế mà LogContext của Serilog ở bài 3.10 dùng.
Nhưng AsyncLocal vẫn chưa phải giải pháp tốt nhất. Nó đúng về mặt kỹ thuật, nhưng vẫn là một dạng ambient context — một phụ thuộc ẩn. Ba vấn đề còn lại:
- Không nhìn thấy trong chữ ký. Đọc constructor của một lớp, bạn không biết nó phụ thuộc vào mã tổ chức.
- Kiểm thử phải nhớ đặt giá trị. Quên là bài kiểm thử thất bại theo cách khó hiểu.
- Quên đặt giá trị ở một đường vào. Một tác vụ nền hay một consumer hàng đợi không đi qua middleware sẽ có giá trị
null, và lỗi chỉ lộ ra ở đó.
Giải pháp đúng — service Scoped tường minh:
public interface ITenantContext { string TenantId { get; } }
public sealed class TenantContext : ITenantContext
{
public string TenantId { get; }
public TenantContext(IHttpContextAccessor accessor)
=> TenantId = accessor.HttpContext?.User.FindFirstValue("tenant_id")
?? throw new InvalidOperationException("Request không có tenant_id");
}
builder.Services.AddScoped<ITenantContext, TenantContext>();
Giờ mọi lớp cần mã tổ chức khai báo điều đó:
public class OrderService(ITenantContext tenant, IOrderRepository repo)
{
public Task<List<Order>> GetAsync(CancellationToken ct)
=> repo.GetByTenantAsync(tenant.TenantId, ct);
}
Bốn lợi ích so với AsyncLocal:
AsyncLocal | Service Scoped | |
|---|---|---|
| Nhìn thấy trong chữ ký | Không | Có |
| Kiểm thử | Phải đặt biến tĩnh | Truyền một bản giả |
| Quên khởi tạo | null âm thầm | Ném ngay khi dựng, có thông báo rõ |
| Vòng đời | Tự quản lý | Container lo |
Lớp bảo vệ cuối cùng — đừng chỉ dựa vào code. Với dữ liệu nhiều tổ chức, hãy dùng bộ lọc truy vấn toàn cục của EF Core để mọi truy vấn tự động kèm điều kiện lọc theo tổ chức:
modelBuilder.Entity<Order>().HasQueryFilter(o => o.TenantId == _tenant.TenantId);
Như vậy ngay cả khi một lập trình viên quên lọc, dữ liệu vẫn không rò rỉ. Bài 13.11 trình bày đầy đủ.
Bài 2 — So sánh chi phí kiểm thử: Service Locator và constructor injection
Viết bài kiểm thử cho một lớp dùng Service Locator và cho cùng lớp đó sau khi chuyển sang constructor injection. So sánh số dòng chuẩn bị, và ghi lại chuyện gì xảy ra với bài kiểm thử cũ khi bạn thêm một phụ thuộc.
Tiêu chí hoàn thành: bạn nêu được vì sao Service Locator làm bài kiểm thử hỏng âm thầm khi thêm phụ thuộc.
Gợi ý và lời giải — Bài 2
Gợi ý. Với constructor injection, thêm một phụ thuộc làm lỗi biên dịch ở mọi chỗ khởi tạo. Với Service Locator thì sao?
Lời giải — bản Service Locator:
public class OrderService(IServiceProvider sp)
{
public async Task ProcessAsync(int id, CancellationToken ct)
{
var repo = sp.GetRequiredService<IOrderRepository>();
var mail = sp.GetRequiredService<IEmailSender>();
// ...
}
}
Bài kiểm thử phải dựng cả một container:
[Fact]
public async Task Process_SendsEmail()
{
var services = new ServiceCollection();
var repo = Substitute.For<IOrderRepository>();
var mail = Substitute.For<IEmailSender>();
services.AddSingleton(repo);
services.AddSingleton(mail);
var sp = services.BuildServiceProvider();
var svc = new OrderService(sp);
await svc.ProcessAsync(1, default);
await mail.Received(1).SendAsync(Arg.Any<Email>(), Arg.Any<CancellationToken>());
}
9 dòng chuẩn bị.
Bản constructor injection:
public class OrderService(IOrderRepository repo, IEmailSender mail) { }
[Fact]
public async Task Process_SendsEmail()
{
var repo = Substitute.For<IOrderRepository>();
var mail = Substitute.For<IEmailSender>();
var svc = new OrderService(repo, mail);
await svc.ProcessAsync(1, default);
await mail.Received(1).SendAsync(Arg.Any<Email>(), Arg.Any<CancellationToken>());
}
3 dòng chuẩn bị.
Chuyện gì xảy ra khi thêm một phụ thuộc — đây là phần chính.
Thêm IAuditLogger vào OrderService:
| Service Locator | Constructor injection | |
|---|---|---|
| Biên dịch | Vẫn xanh | Lỗi biên dịch ở mọi chỗ khởi tạo |
| Chạy bài kiểm thử | Ném lúc chạy: No service for type 'IAuditLogger' | Không chạy được cho tới khi sửa |
| Thời điểm phát hiện | Khi chạy bài kiểm thử, hoặc khi chạy production nếu bài kiểm thử không đi qua nhánh đó | Ngay khi gõ |
Dòng cuối là điểm quan trọng nhất. Với Service Locator, nếu phụ thuộc mới chỉ được dùng trong một nhánh mà bài kiểm thử không chạm tới, không có gì báo lỗi cả — cho tới khi một người dùng thật đi vào nhánh đó trên production.
Constructor injection biến việc này thành lỗi biên dịch: không thể tạo ra một OrderService thiếu phụ thuộc, nên không thể triển khai một cấu hình thiếu.
Ba vấn đề khác của Service Locator:
- Chữ ký nói dối.
OrderService(IServiceProvider sp)trông như chỉ cần một phụ thuộc, thực tế cần bốn. Mu ốn biết nó cần gì, phải đọc hết thân lớp. - Phụ thuộc thay đổi theo nhánh code. Lớp có thể cần
IEmailSenderchỉ trong một nhánhif— nên "lớp này phụ thuộc vào gì" trở thành câu hỏi không có câu trả lời cố định. - Kiểm tra lúc khởi động mất tác dụng.
ValidateOnBuildở bài 7.6 dựa trên constructor để dựng đồ thị phụ thuộc. Với Service Locator, đồ thị đó không tồn tại.
Khi nào IServiceProvider là chính đáng. Chỉ ba chỗ, và đều là những chỗ vòng đời thật sự phức tạp:
// 1. Tác vụ nền cần phạm vi riêng cho mỗi đơn vị công việc
public class Worker(IServiceScopeFactory sf) : BackgroundService { }
// 2. Factory chọn cài đặt theo dữ liệu lúc chạy — nhưng keyed services thường tốt hơn
// 3. Chính hạ tầng của framework
Lưu ý cả ba đều dùng IServiceScopeFactory hoặc nằm ở tầng hạ tầng, không nằm ở tầng nghiệp vụ.
Bài 3 — Tách God Service
Lấy lớp 11 phụ thuộc ở mục 7.10.3, tách theo trách nhiệm, ghi lại số phụ thuộc của từng lớp mới. Thử luôn cách gom vào một facade và giải thích vì sao nó không giải quyết được gì.
Tiêu chí hoàn thành: bạn nêu được vì sao facade chỉ giấu vấn đề chứ không sửa nó.
Gợi ý và lời giải — Bài 3
Gợi ý. Số phụ thuộc lớn là triệu chứng, không phải bệnh. Bệnh là lớp đang làm quá nhiều việc. Hãy hỏi lại câu hỏi ở bài 5.2: ai yêu cầu thay đổi lớp này?
Lời giải — lớp gốc:
public class OrderService(
IOrderRepository repo,
ICustomerRepository customerRepo,
IProductRepository productRepo,
IInventoryService inventory,
IPaymentGateway payment,
IEmailSender email,
ISmsSender sms,
IPdfGenerator pdf,
IErpSyncClient erp,
ITaxCalculator tax,
ILogger<OrderService> log)
{ }
Tách theo trách nhiệm:
OrderService -> repo, inventory, tax, log (4)
OrderNotifier -> email, sms, log (3)
OrderDocumentService -> pdf, customerRepo, log (3)
OrderPaymentService -> payment, repo, log (3)
ErpSyncService -> erp, repo, log (3)
11 xuống trung bình 3,2 phụ thuộc mỗi lớp. Và quan trọng hơn: m ỗi lớp giờ có một nhóm người yêu cầu thay đổi nó.
Cách facade — và vì sao nó không giải quyết gì:
public class OrderDependencies(
IOrderRepository repo,
ICustomerRepository customerRepo,
/* ... cả 11 ... */)
{
public IOrderRepository Repo => repo;
public ICustomerRepository CustomerRepo => customerRepo;
// ... 11 thuộc tính
}
public class OrderService(OrderDependencies deps) // chỉ còn 1 phụ thuộc!
{
public async Task ProcessAsync(...)
{
await deps.Repo.AddAsync(...);
await deps.Email.SendAsync(...);
}
}
Constructor giờ chỉ có một tham số. Nhưng không có gì được cải thiện:
| Trước | Sau facade | |
|---|---|---|
| Số phụ thuộc thật | 11 | 11 — chỉ chuyển vào trong |
| Số trách nhiệm của lớp | 5 | 5 |
| Số nhóm người yêu cầu đổi | 5 | 5 |
| Đọc constructor biết lớp cần gì | Có, dù dài | Không |
| Kiểm thử phải giả lập | 11 thứ | 11 thứ, cộng thêm lớp facade |
Facade còn làm tệ hơn ở hai điểm: chữ ký constructor không còn nói lên phụ thuộc thật, và nó tạo cảm giác vấn đề đã được giải quyết nên không ai quay lại tách nữa.
Cùng nhận xét cho hai "giải pháp" tương tự:
// Cũng không giải quyết gì
public class OrderService(IServiceProvider sp) { } // Service Locator
public class OrderService(IMediator mediator) { } // nếu chỉ để giấu số phụ thuộc
IMediator là một công cụ tốt khi bạn dùng nó để tách use case, nhưng dùng nó chỉ để giảm số tham số trong constructor thì cũng là facade dưới tên khác.
Ngưỡng nào là quá nhiều. Không có con số tuyệt đối, nhưng thực tế:
| Số phụ thuộc | Đánh giá |
|---|---|
| 1–3 | Bình thường |
| 4–5 | Chấp nhận được, nên để ý |
| 6–7 | Đáng xem lại |
| 8 trở lên | Gần như chắc chắn nhiều trách nhiệm |
Phép thử thay cho việc đếm. Liệt kê các nhóm người có thể yêu cầu sửa lớp này. Nhiều hơn một nhóm thì tách, bất kể số phụ thuộc là bao nhiêu. Một lớp 6 phụ thuộc phục vụ một nhóm duy nhất vẫn ổn; một lớp 3 phụ thuộc phục vụ ba nhóm thì không.
Lưu ý về ILogger. Nó xuất hiện ở mọi lớp và thường không tính vào số phụ thuộc "thật", vì nó là mối quan tâm xuyên suốt chứ không phải một trách nhiệm nghiệp vụ. Nếu muốn bỏ nó khỏi constructor hoàn toàn, có thể chuyển việc ghi log sang decorator như bài 7.6 trình bày.
Tự kiểm tra
Frequently asked questions
Vì sao service locator là anti-pattern?
Ba lý do. Constructor nói dối, nó khai là cần một IServiceProvider trong khi thật ra cần nhiều service cụ thể. Test khó và giòn vì phải mock IServiceProvider, và thêm một phụ thuộc là bài test cũ gãy dù hành vi không đổi. Và lỗi thiếu đăng ký chuyển từ lúc khởi động sang giữa một nghiệp vụ.
Những chỗ nào được phép chạm vào IServiceProvider?
Ba chỗ: composition root trong Program.cs nơi dựng scope cho migration, BackgroundService qua IServiceScopeFactory, và factory hẹp resolve đúng một kiểu đã biết như khi chọn keyed service theo dữ liệu lúc chạy. Ngoài ba chỗ đó thì dùng constructor injection.
Ambient context sai ở đâu?
Trong ứng dụng web, static mutable là trạng thái chia sẻ giữa mọi request đồng thời, nên request này ghi đè giá trị của request kia và bạn truy vấn nhầm dữ liệu của khách hàng khác. Lỗi này gần như không xuất hiện khi test thủ công và xuất hiện ngay khi có tải.
AsyncLocal có phải ngoại lệ không?
Không hẳn. Nó an toàn hơn static vì bám theo luồng thực thi async, nhưng vẫn là phụ thuộc ẩn. Nó chỉ hợp cho hạ tầng cắt ngang như tracing, nơi việc truyền qua mọi chữ ký hàm là bất khả thi.
Constructor có 11 phụ thuộc thì sửa thế nào?
Tách lớp theo trách nhiệm. Cách sai là gom lại sau một facade, vì facade chỉ chuyển 11 phụ thuộc xuống một tầng: lớp vẫn làm 11 việc, chỉ là constructor không còn tố cáo nữa. DI không gây ra vấn đề này, nó chỉ làm cho vấn đề nhìn thấy được.
Khi nào nên tạo interface cho một lớp?
Ba lý do chính đáng: cần mock trong test, thường vì lớp có I/O; có nhiều implementation thật ngay bây giờ chứ không phải có thể sau này; hoặc nó là ranh giới kiến trúc giữa layer application và infrastructure. Ngoài ra, một interface có đúng một implementation mà không ai mock chỉ là một lớp gián tiếp tốn chi phí.
Kết luận
Ba điều đáng nhớ nhất:
- Tiêm
IServiceProviderlà giấu phụ thuộc — chỉ đúng ở ba chỗ đã liệt kê. staticmutable trong web là bug chờ có tải. Thông tin theo request thuộc về serviceScoped.- Constructor dài là triệu chứng, không phải bệnh. Tách lớp, đừng gom facade.
Tham khảo
- Dependency injection guidelines
- Dependency injection in ASP.NET Core
- Architectural principles — Explicit dependencies
- Access HttpContext in ASP.NET Core
Điều hướng
- Bài trước: 7.8 — 7. Keyed Services (.NET 8)
- Bài tiếp theo: 7.10 — Mở rộng và đào sâu
- Về module: Trang mục lục
Bài liên quan
- Singleton, Scoped hay Transient? Chọn sai là DbContext sống mãi — Ba lifetime trong DI container của ASP.NET Core khác nhau ở thời điểm tạo và thời điểm dispose instance.