Skip to main content

7.6 — 4. Registering Services

Summary

Sáu cách đăng ký, mỗi cách cho một tình huống. Cách cơ bản (AddScoped<IFoo, Foo>()) phủ 90% trường hợp; phần còn lại là nơi mọi người vấp. Factory registration cho khởi tạo cần logic — và ở đây có bẫy: sp bạn nhận được là provider của scope đang xin, nên resolve một service Scoped bên trong factory của một Singleton là captive dependency, chỉ khác là container không phát hiện được. Open generic cho phép một dòng đăng ký phục vụ mọi IRepository<T>. Và nhiều implementation cùng interface là tính năng, không phải lỗi: container sẽ tiêm cả tập vào IEnumerable<IFoo>.

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

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

  • Chọn đúng dạng đăng ký cho từng tình huống khởi tạo.
  • Viết factory registration mà không tạo captive dependency ẩn.
  • Đăng ký open generic và biết giới hạn của nó.
  • Tiêm một tập implementation qua IEnumerable<T>.
  • Viết decorator bằng container .NET thuần.

Nội dung bài học​

7.6.1 — Đăng ký cơ bản​

// Interface + implementation — 90% truong hop
services.AddScoped<ICustomerRepository, EfCustomerRepository>();
services.AddSingleton<IEmailService, SmtpEmailService>();
services.AddTransient<ILeadScorer, RuleBasedLeadScorer>();

// Chỉ lớp cụ thể, không interface
services.AddScoped<CustomerService>();

// Instance có sẵn — CHÚ Ý: container KHÔNG Dispose cái này
services.AddSingleton<IClock>(new SystemClock());

Dòng cuối có một điểm dễ bỏ sót: khi bạn đưa vào một instance đã tạo, container không coi mình là chủ sở hữu, nên nó không gọi Dispose. Với đối tượng giữ tài nguyên, dùng dạng factory thay thế.

AddScoped<CustomerService>() không cần interface — container dựng được mọi lớp cụ thể miễn là giải được constructor. Đừng tạo interface chỉ để có interface: nếu một interface chỉ có đúng một implementation và bạn không mock nó trong test, nó chỉ là một lớp gián tiếp thừa.

7.6.2 — Factory registration và cái bẫy trong đó​

services.AddSingleton<IEmailService>(sp =>
{
var settings = sp.GetRequiredService<IOptions<EmailSettings>>().Value;
return new SmtpEmailService(settings.Host, settings.Port, settings.UseSsl);
});

Dùng khi việc dựng cần logic: đọc cấu hình, chọn implementation theo môi trường, hoặc gọi một factory của thư viện bên thứ ba.

Cái bẫy:

services.AddSingleton<IReportService>(sp =>
{
var repo = sp.GetRequiredService<ICustomerRepository>(); // Scoped!
return new ReportService(repo); // CAPTIVE
});

sp trong factory là provider của scope đang xin. Với Singleton, lần xin đầu tiên có thể đến từ một request, nên bạn lấy được một Scoped thật — và giam nó vĩnh viễn (bài 7.5).

Nguy hiểm hơn constructor injection ở chỗ: ValidateScopes bắt được captive dependency qua constructor, nhưng không bắt được qua factory, vì nó không nhìn vào bên trong lambda.

// DUNG — tiem scope factory, tao scope khi dung
services.AddSingleton<IReportService>(sp =>
new ReportService(sp.GetRequiredService<IServiceScopeFactory>()));

Quy tắc: trong factory của một Singleton, chỉ resolve Singleton.

7.6.3 — Open generic​

services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));

Một dòng phục vụ mọi IRepository<Customer>, IRepository<Lead>, IRepository<Order>… Container ghép kiểu tại thời điểm resolve. Đây là cách ILogger<T> hoạt động.

Đăng ký cụ thể thắng open generic cho đúng kiểu đó:

services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));
services.AddScoped<IRepository<Customer>, CachedCustomerRepository>(); // rieng Customer

Hai giới hạn: không dùng được với generic có ràng buộc mà container không kiểm tra được, và không có cú pháp generic cho nó — phải viết typeof.

7.6.4 — Nhiều implementation cho một interface​

Đây là tính năng, không phải xung đột:

services.AddScoped<IValidator<Customer>, EmailValidator>();
services.AddScoped<IValidator<Customer>, PhoneValidator>();
services.AddScoped<IValidator<Customer>, TaxCodeValidator>();
public class CustomerService(IEnumerable<IValidator<Customer>> validators)
{
public async Task<Customer> CreateAsync(Customer customer, CancellationToken ct)
{
foreach (var v in validators) // ca ba, dung thu tu dang ky
await v.ValidateAsync(customer, ct);
...
}
}

Thêm một luật kiểm tra mới = thêm một dòng đăng ký, không sửa CustomerService. Đây chính là Open/Closed trong thực tế.

Nhớ hai điều từ bài 7.3: GetRequiredService<IValidator<Customer>>() chỉ trả về cái cuối cùng, còn IEnumerable<> trả về tất cả theo thứ tự đăng ký.

7.6.5 — TryAdd và Replace​

services.TryAddScoped<IEmailService, SmtpEmailService>();            // chỉ thêm nếu chưa có
services.TryAddEnumerable(
ServiceDescriptor.Scoped<IValidator<Customer>, EmailValidator>()); // không thêm trùng

services.Replace(ServiceDescriptor.Scoped<IEmailService, FakeEmailService>()); // thay the
  • TryAdd: thư viện nên luôn dùng, để ứng dụng đè lên được lựa chọn mặc định.
  • TryAddEnumerable: khi một AddXxx() bị gọi hai lần, Add thường sẽ nhân đôi và validator chạy hai lần. TryAddEnumerable so cả ServiceType lẫn ImplementationType nên không trùng.
  • Replace: rất hữu ích trong integration test để thay hạ tầng thật bằng fake.

7.6.6 — Decorator bằng container .NET thuần​

Container .NET không có decorator sẵn, nhưng làm được bằng factory:

services.AddScoped<EfCustomerRepository>();                 // lop that

services.AddScoped<ICustomerRepository>(sp =>
new CachedCustomerRepository(
sp.GetRequiredService<EfCustomerRepository>(), // inner
sp.GetRequiredService<IMemoryCache>()));

Lớp thật được đăng ký bằng kiểu cụ thể, còn interface trỏ tới lớp bọc. CachedCustomerRepository cài ICustomerRepository và nhận EfCustomerRepository làm inner.

Cách này đọc được nhưng dài dòng khi bạn có nhiều lớp cần bọc. Khi đó Scrutor cho bạn một dòng:

services.AddScoped<ICustomerRepository, EfCustomerRepository>();
services.Decorate<ICustomerRepository, CachedCustomerRepository>();

7.6.7 — Tóm tắt​

DạngKhi dùng
Add<IFoo, Foo>()Mặc định
Add<Foo>()Lớp cụ thể không cần interface
Add<IFoo>(sp => ...)Khởi tạo cần logic — chỉ resolve Singleton nếu đăng ký Singleton
Add(typeof(IFoo<>), typeof(Foo<>))Open generic
Đăng ký nhiều lầnTiêm cả tập qua IEnumerable<IFoo>
TryAdd / TryAddEnumerableTrong thư viện, để không đè lựa chọn của ứng dụng
ReplaceIntegration test

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

Danh sách rà soát đăng ký service

  • •Không factory Singleton nào resolve service Scoped từ sp.
  • •Không có interface nào chỉ tồn tại để có interface.
  • •Instance tự new và đăng ký không phải là thứ cần Dispose.
  • •Open generic đăng ký một lần, không lặp cho từng kiểu cụ thể.
  • •Nhóm luật kiểm tra hay handler được tiêm qua IEnumerable, không switch-case.
  • •Thư viện nội bộ dùng TryAdd thay vì Add.
  • •Extension method AddXxx() an toàn khi bị gọi hai lần.
  • •Integration test dùng Replace thay vì đăng ký chồng lên.

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

Bài 1 — Ranh giới của ValidateOnBuild và ValidateScopes​

Viết hai cách đăng ký cùng một captive dependency — một bằng constructor injection, một bằng factory — rồi bật cả ValidateScopes lẫn ValidateOnBuild. So sánh thời điểm lỗi xuất hiện.

Tiêu chí hoàn thành: bạn nêu đúng cách nào bị bắt lúc khởi động và cách nào chỉ lộ ra lúc chạy.

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

Gợi ý. ValidateOnBuild phân tích kiểu của các đăng ký để suy ra đồ thị phụ thuộc. Với một factory là lambda, nó thấy được gì bên trong lambda đó?

Lời giải.

static void Thu(string ten, Action<IServiceCollection> dangKy)
{
var s = new ServiceCollection();
s.AddScoped<IScopedBar, ScopedBar>();
dangKy(s);
try
{
var p = s.BuildServiceProvider(
new ServiceProviderOptions { ValidateScopes = true, ValidateOnBuild = true });
Console.WriteLine(" Build : không báo lỗi");
try { p.GetRequiredService<IFoo>(); Console.WriteLine(" Resolve : KHÔNG báo lỗi"); }
catch (Exception e) { Console.WriteLine($" Resolve : {e.GetType().Name}"); }
}
catch (Exception e) { Console.WriteLine($" Build : {e.GetType().Name} -> BẮT LÚC KHỞI ĐỘNG"); }
}

Thu("constructor", s => s.AddSingleton<IFoo, Foo>());
Thu("factory", s => s.AddSingleton<IFoo>(sp => new Foo(sp.GetRequiredService<IScopedBar>())));

Kết quả đo thật trên .NET 9:

--- constructor injection  AddSingleton<IFoo, Foo>()
Build : AggregateException -> BẮT LÚC KHỞI ĐỘNG

--- factory AddSingleton<IFoo>(sp => new Foo(sp.GetRequiredService<IScopedBar>()))
Build : không báo lỗi
Resolve : InvalidOperationException
Cannot resolve scoped service 'IScopedBar' from root provider.

Kết luận chính xác:

Cách đăng kýValidateOnBuild bắt được?ValidateScopes bắt được?
Constructor injectionCó — lúc khởi độngCó
Factory (lambda)KhôngCó, nhưng chỉ lúc resolve

Vì sao khác nhau. ValidateOnBuild đọc kiểu của lớp cài đặt và dò constructor của nó để dựng đồ thị phụ thuộc. Với AddSingleton<IFoo, Foo>(), nó thấy Foo cần IScopedBar và phát hiện vi phạm ngay.

Với factory, thứ được đăng ký chỉ là một delegate. Container không thể nhìn vào thân lambda để biết nó sẽ yêu cầu những gì — điều đó chỉ lộ ra khi lambda thật sự chạy.

Ý nghĩa với code review và vận hành:

  • Constructor injection an toàn hơn không chỉ vì rõ ràng hơn, mà vì nó kiểm chứng được tự động. Đây là một lý do nữa để ưu tiên nó, bên cạnh các lý do ở bài 7.4.
  • Factory là điểm mù. Ứng dụng khởi động bình thường, và lỗi chỉ xuất hiện khi có request đầu tiên chạm tới đường dẫn đó. Nếu đó là một tính năng ít dùng, có thể mất nhiều ngày.
  • Vì vậy factory phải được rà bằng mắt. Mỗi sp.GetRequiredService<T>() bên trong một đăng ký AddSingleton là một chỗ cần kiểm tra vòng đời của T.

Khi buộc phải dùng factory cho Singleton, đừng lấy service Scoped ra rồi giữ. Lấy IServiceScopeFactory và tạo phạm vi khi cần:

// SAI
builder.Services.AddSingleton<IFoo>(sp => new Foo(sp.GetRequiredService<IScopedBar>()));

// ĐÚNG
builder.Services.AddSingleton<IFoo>(sp => new Foo(sp.GetRequiredService<IServiceScopeFactory>()));

public class Foo(IServiceScopeFactory sf) : IFoo
{
public async Task LamViecAsync(CancellationToken ct)
{
using var scope = sf.CreateScope();
var bar = scope.ServiceProvider.GetRequiredService<IScopedBar>();
// dùng bar trong phạm vi này, không giữ lại
}
}

Khi nào factory là lựa chọn đúng. Khi việc khởi tạo cần logic mà constructor injection không diễn đạt được — đọc cấu hình, chọn cài đặt theo điều kiện, hoặc gọi một hàm khởi tạo bất đồng bộ. Ngoài ra thì đăng ký theo kiểu vừa gọn hơn vừa được kiểm chứng.

Bài 2 — Đăng ký nhân đôi​

Viết một extension AddTinhNang() dùng Add cho một validator, gọi nó hai lần, và đếm số lần validator chạy. Sửa bằng TryAddEnumerable và đếm lại.

Tiêu chí hoàn thành: bạn phân biệt được Add, TryAdd và TryAddEnumerable, và nêu được khi nào dùng cái nào.

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

Gợi ý. Ba phương thức này xử lý trường hợp "đã có đăng ký cho kiểu này" theo ba cách hoàn toàn khác nhau. Hãy thử từng cái và quan sát GetServices<T>() trả về gì.

Lời giải — bản có vấn đề:

public static IServiceCollection AddTinhNang(this IServiceCollection s)
{
s.AddScoped<IValidator<Order>, OrderValidator>(); // dùng Add
return s;
}

// Program.cs — vô tình gọi hai lần
builder.Services.AddTinhNang();
builder.Services.AddTinhNang(); // ví dụ: một lần trong AddCrmCore, một lần trực tiếp
var vs = sp.GetServices<IValidator<Order>>();
Console.WriteLine(vs.Count()); // 2

Validator chạy hai lần cho mỗi đơn hàng. Với validator thì hậu quả là thông báo lỗi bị lặp. Với một trình xử lý sự kiện hoặc một bộ lọc thì hậu quả nghiêm trọng hơn nhiều — email gửi hai lần, bản ghi tạo hai lần.

Ba phương thức và hành vi:

// Add — luôn thêm, dù đã có
s.AddScoped<IValidator<Order>, OrderValidator>();
// GetServices -> [OrderValidator, OrderValidator]
// GetRequiredService -> cái CUỐI cùng

// TryAdd — chỉ thêm nếu CHƯA CÓ đăng ký nào cho kiểu dịch vụ này
s.TryAddScoped<IValidator<Order>, OrderValidator>();
// GetServices -> [OrderValidator] (gọi hai lần cũng chỉ một)

// TryAddEnumerable — chỉ thêm nếu chưa có đăng ký nào với CÙNG KIỂU CÀI ĐẶT
s.TryAddEnumerable(ServiceDescriptor.Scoped<IValidator<Order>, OrderValidator>());
// GetServices -> [OrderValidator]
// nhưng thêm EmailValidator thì -> [OrderValidator, EmailValidator]

Khác biệt then chốt giữa TryAdd và TryAddEnumerable:

TryAddTryAddEnumerable
So sánh theoKiểu dịch vụKiểu dịch vụ + kiểu cài đặt
Thêm cài đặt thứ haiBị bỏ quaĐược thêm
Dùng choChỉ có một cài đặt đúngNhiều cài đặt cùng tồn tại

Đây là điểm dễ sai nhất: dùng TryAdd cho validator sẽ khiến validator thứ hai — cho một kiểu khác — bị âm thầm bỏ qua.

Bảng chọn:

Tình huốngDùng
Cho phép người dùng thư viện ghi đè mặc định của bạnTryAdd
Nhiều cài đặt cùng chạy: validator, handler, bộ lọcTryAddEnumerable
Cố ý muốn thay thế đăng ký trướcReplace
Chắc chắn chỉ gọi một lần, và muốn lỗi nếu trùngAdd

Quy tắc cho tác giả thư viện. Mọi extension AddXxx công khai nên dùng TryAdd hoặc TryAddEnumerable, không dùng Add. Lý do: bạn không kiểm soát được người dùng gọi nó bao nhiêu lần, và một extension gọi hai lần mà gây lỗi là một API dễ dùng sai.

Đây cũng chính là cách mọi gói của Microsoft được viết — AddLogging, AddOptions, AddHttpClient đều gọi được nhiều lần mà không sao.

Cách phát hiện đăng ký trùng trong dự án đang chạy:

var trung = builder.Services
.GroupBy(d => (d.ServiceType, d.ImplementationType))
.Where(g => g.Key.ImplementationType is not null && g.Count() > 1)
.ToList();

foreach (var g in trung)
Console.WriteLine($"TRÙNG {g.Count()} lần: {g.Key.ServiceType.Name} -> {g.Key.ImplementationType!.Name}");

Đặt đoạn này ngay trước builder.Build() ở môi trường Development. Nó bắt được lớp lỗi mà không công cụ nào khác bắt.

Bài 3 — Decorator hai tầng​

Bọc một repository bằng caching rồi bọc tiếp bằng logging, sao cho thứ tự là logging → caching → thật. Viết bằng container .NET thuần, rồi viết lại bằng Scrutor và so sánh.

Tiêu chí hoàn thành: thứ tự thực thi đúng như mô tả, và bạn nêu được vì sao container .NET thuần không có Decorate sẵn.

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

Gợi ý. Decorator là một lớp cài cùng interface và nhận chính interface đó qua constructor. Vấn đề với container .NET: khi LoggingRepo yêu cầu IOrderRepository, làm sao container biết phải đưa CachingRepo chứ không phải chính LoggingRepo?

Lời giải — các lớp:

public interface IOrderRepository { Task<Order?> GetAsync(int id, CancellationToken ct); }

public class EfOrderRepository(CrmDbContext db) : IOrderRepository
{
public async Task<Order?> GetAsync(int id, CancellationToken ct)
=> await db.Orders.FindAsync([id], ct);
}

public class CachingOrderRepository(IOrderRepository inner, IMemoryCache cache) : IOrderRepository
{
public async Task<Order?> GetAsync(int id, CancellationToken ct)
=> await cache.GetOrCreateAsync($"order:{id}", async e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return await inner.GetAsync(id, ct);
});
}

public class LoggingOrderRepository(IOrderRepository inner, ILogger<LoggingOrderRepository> log)
: IOrderRepository
{
public async Task<Order?> GetAsync(int id, CancellationToken ct)
{
log.LogInformation("Lấy đơn hàng {Id}", id);
var sw = Stopwatch.StartNew();
var order = await inner.GetAsync(id, ct);
log.LogInformation("Xong sau {Ms} ms", sw.ElapsedMilliseconds);
return order;
}
}

Bằng container .NET thuần — phải tự dựng chuỗi bằng factory:

builder.Services.AddScoped<EfOrderRepository>();     // đăng ký theo KIỂU CỤ THỂ

builder.Services.AddScoped<IOrderRepository>(sp =>
{
var that = sp.GetRequiredService<EfOrderRepository>();
var caching = new CachingOrderRepository(that, sp.GetRequiredService<IMemoryCache>());
var logging = new LoggingOrderRepository(caching, sp.GetRequiredService<ILogger<LoggingOrderRepository>>());
return logging; // ngoài cùng là logging
});

Thứ tự thực thi: logging → caching → EfOrderRepository. Đúng yêu cầu.

Bằng Scrutor:

builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.Decorate<IOrderRepository, CachingOrderRepository>();
builder.Services.Decorate<IOrderRepository, LoggingOrderRepository>();

Lớp bọc sau nằm ở ngoài — nên thứ tự vẫn là logging → caching → thật.

So sánh:

Container thuầnScrutor
Số dòng~83
Tự dựng phụ thuộc của decoratorPhải làm tayTự động
Thêm một tầng bọcSửa lambdaThêm một dòng
Phụ thuộc thêmKhôngMột gói NuGet

Khác biệt lớn nhất nằm ở dòng thứ hai: với container thuần, mọi phụ thuộc của decorator phải tự lấy ra bằng GetRequiredService, và danh sách đó dài ra theo thời gian.

Vì sao container .NET không có Decorate sẵn. Đây là lựa chọn thiết kế có chủ ý: container tích hợp được giữ tối giản, chỉ cung cấp những gì framework cần. Các tính năng nâng cao — decorator, đăng ký theo quy ước, chặn lời gọi, đặt tên đăng ký — được để cho container bên thứ ba.

Đánh đổi này hợp lý: container mặc định nhẹ, khởi động nhanh, và ai cần thêm thì cài thêm.

Vì sao decorator đáng dùng. Nó là cách áp dụng nguyên tắc Open/Closed một cách rất sạch: thêm caching hay logging không sửa một dòng nào trong EfOrderRepository. Mỗi mối quan tâm nằm trong một lớp riêng, kiểm thử riêng được, và bật tắt bằng một dòng đăng ký.

Một lưu ý về thứ tự. Đặt caching bên trong logging nghĩa là log ghi lại cả những lần trúng cache — đó thường là điều bạn muốn, vì bạn cần biết tỉ lệ trúng cache. Đảo lại thì log chỉ ghi khi phải xuống database, và bạn mất thông tin đó. Thứ tự bọc là một quyết định có ý nghĩa, không tuỳ tiện.

Tự kiểm tra​

Frequently asked questions

Factory registration có bẫy gì?

Tham số sp trong factory là provider của scope đang xin, nên resolve một service Scoped bên trong factory của một Singleton sẽ giam service đó vĩnh viễn. Nguy hiểm hơn constructor injection ở chỗ ValidateScopes không nhìn vào bên trong lambda nên không phát hiện được. Quy tắc là trong factory của Singleton chỉ resolve Singleton.

Đăng ký instance có sẵn khác gì đăng ký kiểu?

Khi bạn đưa vào một instance đã tạo, container không coi mình là chủ sở hữu nên không gọi Dispose cho nó. Với đối tượng giữ tài nguyên thì nên dùng dạng factory để container quản lý vòng đời.

Open generic đăng ký thế nào và có giới hạn gì?

Dùng Add với typeof của interface mở và typeof của implementation mở, một dòng phục vụ mọi kiểu cụ thể. Đăng ký cụ thể sẽ thắng open generic cho đúng kiểu đó. Giới hạn là không có cú pháp generic nên phải viết typeof, và không dùng được với ràng buộc mà container không kiểm tra được.

Đăng ký nhiều implementation cho cùng một interface để làm gì?

Để tiêm cả tập vào IEnumerable của interface đó, ví dụ một nhóm luật kiểm tra hay nhóm handler. Thêm một luật mới chỉ cần thêm một dòng đăng ký mà không sửa lớp sử dụng, đúng tinh thần Open/Closed. Lưu ý GetRequiredService chỉ trả về đăng ký cuối cùng còn IEnumerable trả về tất cả.

TryAdd và TryAddEnumerable khác nhau ra sao?

TryAdd chỉ thêm nếu chưa có đăng ký nào cho ServiceType đó, dùng trong thư viện để ứng dụng đè lên được. TryAddEnumerable so cả ServiceType lẫn ImplementationType nên tránh nhân đôi khi một extension AddXxx bị gọi hai lần, tránh việc validator chạy hai lần.

Làm decorator bằng container .NET thuần thế nào?

Đăng ký lớp thật bằng kiểu cụ thể, rồi đăng ký interface bằng một factory tạo lớp bọc và truyền lớp thật vào làm inner. Cách này đọc được nhưng dài dòng khi có nhiều lớp cần bọc, khi đó Scrutor cho phép viết gọn bằng phương thức Decorate.

Kết luận​

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

  1. Factory của Singleton chỉ được resolve Singleton — và không công cụ nào kiểm tra hộ bạn.
  2. Nhiều implementation là tính năng: tiêm qua IEnumerable<T> thay cho switch-case.
  3. Thư viện dùng TryAdd, ứng dụng dùng Add, test dùng Replace.

Tham khảo​

Điều hướng​

Bài liên quan​