Skip to content

7.13 - Ôn tập và đánh giá Module 7 ​

Tóm tắt: Bài ôn tập cuối Module 7 gồm ba phần: 10 câu quiz bám vào những chỗ dễ sai nhất của DI trong .NET (lifetime, captive dependency, thứ tự đăng ký, ValidateScopes so với ValidateOnBuild, Options, keyed services), ba bài lab chạy được bằng project console và xUnit, có lời giải và tiêu chí chấm kiểm tra được, và một rubric mô tả từng mức điểm. Làm quiz trước khi xem đáp án, làm lab trước khi mở lời giải.

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

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

  • Trả lời đúng ít nhất 8/10 câu quiz và giải thích lý do cho từng câu.
  • Refactor một lớp tự new phụ thuộc thành constructor injection và chạy nó bằng Host.CreateApplicationBuilder.
  • Viết unit test cho service bằng bản giả (NSubstitute), không cần container.
  • Đọc cấu hình qua Options pattern, có kiểm tra lúc khởi động và có test cho cả quy tắc lẫn cấu hình sai.
  • Tự chấm bài lab theo rubric và chỉ ra bài nào trong module cần đọc lại.

Cần biết trước: toàn bộ Module 7, từ bài 7.1 tới bài 7.12; viết test xUnit (bài 0.4).

Nội dung bài học ​

7.13.1 — Cách làm bài ​

  1. Làm Phần A (quiz) trên giấy hoặc file nháp, ghi đáp án kèm một dòng lý do. Xong cả 10 câu mới mở đáp án.
  2. Làm Phần B (lab) trong một solution gồm hai project: Crm.Lab (console) và Crm.Lab.Tests (xUnit, tham chiếu Crm.Lab), dựng như bài 0.4. Ba bài lab nối tiếp nhau trên cùng solution.
  3. Tự chấm theo rubric ở mục 7.13.4. Câu quiz sai và tiêu chí lab bị trừ điểm cho biết bài nào cần đọc lại.

7.13.2 — Phần A: Quiz (20 điểm, mỗi câu 2 điểm) ​

Câu 1. AddDbContext<CrmDbContext>() đăng ký CrmDbContext với lifetime nào, và vì sao đó là lựa chọn đúng cho ứng dụng web? A. Singleton, để dùng chung kết nối B. Scoped, để mỗi request có một change tracker riêng C. Transient, để mỗi repository có context riêng D. Tuỳ provider database

Đáp án câu 1

B. Mỗi request một DbContext: các repository trong cùng request dùng chung nó nên SaveChangesAsync ghi mọi thay đổi trong một lần, và context được dispose khi request kết thúc. Singleton làm change tracker phình mãi và trả dữ liệu cũ; Transient làm các repository trong cùng request không chia sẻ được thay đổi. Xem bài 7.4.

Câu 2. Đoạn code sau in ra gì?

csharp
var s = new ServiceCollection();
s.AddSingleton<IEmail, SmtpEmail>();
s.AddSingleton<IEmail, SendGridEmail>();
var p = s.BuildServiceProvider();
Console.WriteLine(p.GetRequiredService<IEmail>().GetType().Name);
Console.WriteLine(p.GetServices<IEmail>().Count());

A. SmtpEmail rồi 1 B. SendGridEmail rồi 2 C. SmtpEmail rồi 2 D. Ném lỗi vì đăng ký trùng

Đáp án câu 2

B. Khi resolve một instance, đăng ký sau cùng thắng; GetServices trả về tất cả theo thứ tự đăng ký. Container không bao giờ báo lỗi vì đăng ký trùng. Xem bài 7.2.

Câu 3. Captive dependency là gì? A. Service không được đăng ký B. Service có lifetime dài giữ tham chiếu tới service có lifetime ngắn hơn, ví dụ Singleton giữ Scoped C. Hai constructor cùng giải được D. Vòng phụ thuộc A cần B, B cần A

Đáp án câu 3

B. Service bị giam sống theo lifetime của service giữ nó, nên một DbContext hay ITenantContext vốn chỉ sống một request bị dùng mãi. Xem bài 7.4 và bài 7.11.

Câu 4. Ứng dụng có một Singleton nhận một Scoped qua constructor. Chỉ bật ValidateOnBuild = true, không bật ValidateScopes. Điều gì xảy ra lúc khởi động? A. Ném lỗi "Cannot consume scoped service ... from singleton ..." B. Không báo lỗi gì C. Ném lỗi "Unable to resolve service" D. Container tự đổi Scoped thành Singleton

Đáp án câu 4

B. ValidateOnBuild chỉ kiểm tra mọi service có dựng được không (bắt thiếu đăng ký). Luật lifetime thuộc về ValidateScopes; chỉ khi bật cả hai thì captive dependency mới nổ ngay lúc build. Bài 2 của bài 7.11 đo điều này trên .NET 10.

Câu 5. Trong Program.cs của ứng dụng web .NET 10, bạn gọi builder.Services.AddScoped<IFoo, Foo>() sau var app = builder.Build();. Kết quả? A. Biên dịch lỗi B. Chạy bình thường, IFoo dùng được C. Ứng dụng ném InvalidOperationException ngay tại dòng đó: danh sách đăng ký đã chỉ đọc D. Chạy bình thường, nhưng request đầu tiên dùng IFoo ném lỗi

Đáp án câu 5

C. Build() khoá builder.Services; thông báo là "The service collection cannot be modified because it is read-only." Xem bài 7.6.

Câu 6. Factory AddSingleton<IReportService>(sp => new ReportService(sp.GetRequiredService<ICustomerRepository>())), với ICustomerRepository là Scoped. sp ở đây là gì? A. Provider của scope đang xin B. Root provider C. Một scope mới tạo riêng cho factory D. null

Đáp án câu 6

B. Factory của Singleton luôn chạy với root provider. Bật ValidateScopes thì lần resolve đầu ném "Cannot resolve scoped service ... from root provider"; tắt kiểm tra thì repository bị giam vĩnh viễn và không bao giờ được dispose. ValidateOnBuild không nhìn vào lambda nên không bắt được lúc khởi động. Xem bài 7.5.

Câu 7. Một Singleton muốn đọc ITenantContext (Scoped, được middleware gán tenant cho request) nên gọi scopeFactory.CreateScope() rồi lấy ITenantContext từ scope đó. Nó nhận được gì? A. Đúng ITenantContext của request hiện tại B. Một ITenantContext mới, chưa có tenant của request C. ITenantContext của request đầu tiên D. Ném lỗi

Đáp án câu 7

B. CreateScope() tạo scope mới, độc lập với scope của request. Với dữ liệu theo request: truyền tham số, đổi service sang Scoped, hoặc đọc IHttpContextAccessor trong phương thức. Xem bài 7.11.

Câu 8. Service Singleton cần thấy giá trị mới khi appsettings.json đổi lúc đang chạy. Tiêm gì? A. IOptions<T> B. IOptionsSnapshot<T> C. IOptionsMonitor<T> và đọc CurrentValue mỗi lần dùng D. IConfiguration rồi tự parse

Đáp án câu 8

C. IOptions<T> không bao giờ đọc lại. IOptionsSnapshot<T> là Scoped nên tiêm vào Singleton là captive dependency (ở Development ứng dụng không khởi động được). Xem bài 7.7.

Câu 9. Đăng ký AddScoped<IValidator, EmailValidator>() và AddKeyedScoped<IValidator, PhoneValidator>("phone"). Constructor nhận IEnumerable<IValidator> thấy bao nhiêu phần tử? A. 0 B. 1 C. 2 D. Ném lỗi

Đáp án câu 9

B. Chỉ EmailValidator. Service có khoá và không khoá nằm ở hai không gian tách biệt, nên một validator lỡ đăng ký có khoá sẽ không bao giờ chạy trong vòng foreach. Xem bài 7.8.

Câu 10. Lớp nghiệp vụ nhận IServiceProvider qua constructor rồi tự GetRequiredService ba service bên trong phương thức. Vấn đề chính là gì? A. Chậm hơn constructor injection B. Constructor không còn cho biết lớp cần gì, test phải dựng container, và thiếu đăng ký chỉ lộ ra lúc chạy C. Không biên dịch được D. Không dùng được với Scoped

Đáp án câu 10

B. Đó là service locator. Chỗ hợp lệ duy nhất để chạm IServiceProvider là composition root (Program.cs), hạ tầng như BackgroundService qua IServiceScopeFactory, và một factory hẹp chọn keyed service. Xem bài 7.9.

7.13.3 — Phần B: Lab (80 điểm) ​

Dựng solution:

bash
dotnet new console -n Crm.Lab
dotnet new xunit -n Crm.Lab.Tests
dotnet add Crm.Lab.Tests reference Crm.Lab
dotnet add Crm.Lab package Microsoft.Extensions.Hosting
dotnet add Crm.Lab package Microsoft.Extensions.Options.DataAnnotations
dotnet add Crm.Lab.Tests package NSubstitute

Microsoft.Extensions.Hosting cho Host.CreateApplicationBuilder: phiên bản console của WebApplication.CreateBuilder, cũng có container, cấu hình từ appsettings.json và logging. Microsoft.Extensions.Options.DataAnnotations cung cấp ValidateDataAnnotations(); project web có sẵn nó, project console thì phải cài.

Bài 1 — Refactor CustomerService sang DI (Dễ, 25 điểm) ​

Cho lớp dính chặt sau (cùng ý với bài 7.1):

csharp
public class CustomerService
{
    private readonly SmtpEmailService _email = new("smtp.company.com", 587);
    private readonly PostgresCustomerRepository _repository =
        new("Host=db.company.com;Database=crm;Username=crm;Password=...");

    public async Task<Customer> CreateCustomerAsync(string name, string email) { /* ... */ }
}

Yêu cầu:

  1. Tạo ICustomerRepository (có AddAsync, GetByIdAsync, GetAllAsync) và IEmailService (có SendWelcomeEmailAsync).
  2. Viết InMemoryCustomerRepository (lưu trong bộ nhớ) và ConsoleEmailService (in ra màn hình thay cho gửi thật).
  3. CustomerService nhận ICustomerRepository, IEmailService, ILogger<CustomerService> qua constructor; CreateCustomerAsync(CreateCustomerRequest request, CancellationToken ct) kiểm tra tên không rỗng và email có @, rồi lưu và gửi email.
  4. Program.cs dùng Host.CreateApplicationBuilder, đăng ký với lifetime phù hợp, tạo một scope và tạo một khách hàng.

Tiêu chí chấm:

Tiêu chíĐiểm
CustomerService không còn new service nào, không còn chuỗi kết nối hay host trong code8
InMemoryCustomerRepository đăng ký Singleton (giữ dữ liệu) và an toàn đa luồng (ConcurrentDictionary)6
CustomerService đăng ký Scoped hoặc Transient, resolve trong một scope tạo bằng CreateScope()5
dotnet run in dòng email và dòng "Đã tạo" như output mẫu6
Lời giải — Bài 1

Crm.Lab/Customers.cs:

csharp
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;

namespace Crm.Lab;

public record Customer(Guid Id, string Name, string Email);
public record CreateCustomerRequest(string Name, string Email);

public interface ICustomerRepository
{
    Task AddAsync(Customer customer, CancellationToken ct = default);
    Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default);
    Task<IReadOnlyList<Customer>> GetAllAsync(CancellationToken ct = default);
}

public interface IEmailService
{
    Task SendWelcomeEmailAsync(string email, CancellationToken ct = default);
}

// Lưu trong bộ nhớ: Singleton nên phải an toàn khi nhiều luồng dùng cùng lúc
public sealed class InMemoryCustomerRepository : ICustomerRepository
{
    private readonly ConcurrentDictionary<Guid, Customer> _customers = new();

    public Task AddAsync(Customer customer, CancellationToken ct = default)
    {
        _customers[customer.Id] = customer;
        return Task.CompletedTask;
    }

    public Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default)
        => Task.FromResult(_customers.GetValueOrDefault(id));

    public Task<IReadOnlyList<Customer>> GetAllAsync(CancellationToken ct = default)
        => Task.FromResult<IReadOnlyList<Customer>>(_customers.Values.ToList());
}

// Thay cho SMTP thật khi học: chỉ in ra màn hình
public sealed class ConsoleEmailService : IEmailService
{
    public Task SendWelcomeEmailAsync(string email, CancellationToken ct = default)
    {
        Console.WriteLine($"[email] Gửi thư chào mừng tới {email}");
        return Task.CompletedTask;
    }
}

public class CustomerService(
    ICustomerRepository repository,
    IEmailService emailService,
    ILogger<CustomerService> logger)
{
    public async Task<Customer> CreateCustomerAsync(CreateCustomerRequest request, CancellationToken ct = default)
    {
        if (string.IsNullOrWhiteSpace(request.Name))
            throw new ArgumentException("Tên không được rỗng", nameof(request));
        if (!request.Email.Contains('@'))
            throw new ArgumentException("Email không hợp lệ", nameof(request));

        var customer = new Customer(Guid.NewGuid(), request.Name.Trim(), request.Email.Trim());
        await repository.AddAsync(customer, ct);
        await emailService.SendWelcomeEmailAsync(customer.Email, ct);
        logger.LogInformation("Đã tạo khách hàng {CustomerId}", customer.Id);
        return customer;
    }
}

Crm.Lab/Program.cs (phần Bài 1; Bài 3 thêm vài dòng vào đây):

csharp
using Crm.Lab;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

var builder = Host.CreateApplicationBuilder(args);

builder.Services.AddSingleton<ICustomerRepository, InMemoryCustomerRepository>();  // giữ dữ liệu: Singleton
builder.Services.AddSingleton<IEmailService, ConsoleEmailService>();              // không trạng thái
builder.Services.AddScoped<CustomerService>();

using var host = builder.Build();

using (var scope = host.Services.CreateScope())
{
    var customers = scope.ServiceProvider.GetRequiredService<CustomerService>();
    var created = await customers.CreateCustomerAsync(new("Nguyễn Văn An", "[email protected]"));
    Console.WriteLine($"Đã tạo: {created.Name} <{created.Email}>");
}

Output khi chạy trên .NET 10 (Guid khác mỗi lần; dòng info: có thể xen ở vị trí khác vì logger ghi ra màn hình ở luồng nền):

text
[email] Gửi thư chào mừng tới [email protected]
Đã tạo: Nguyễn Văn An <[email protected]>
info: Crm.Lab.CustomerService[0]
      Đã tạo khách hàng cde7a7b0-63b5-42a1-ab2c-6cdec507935a

Vì sao InMemoryCustomerRepository là Singleton: nếu đăng ký Scoped, mỗi scope có một Dictionary rỗng mới, khách hàng tạo ở request này biến mất ở request sau. Singleton thì nhiều request dùng chung, nên phải là ConcurrentDictionary (bài 7.4).

Bài 2 — Unit test và kiểm chứng lifetime (Trung bình, 25 điểm) ​

  1. Trong Crm.Lab.Tests, viết test cho CustomerService.CreateCustomerAsync bằng NSubstitute: một test cho trường hợp hợp lệ (đã lưu đúng một lần, đã gửi email đúng một lần tới đúng địa chỉ), một test cho email sai (ném ArgumentException, không lưu, không gửi).
  2. Viết một test dựng ServiceCollection để chứng minh: Scoped cùng instance trong một scope, khác instance giữa hai scope; Transient luôn là instance mới. Dùng ReferenceEquals.

Tiêu chí chấm:

Tiêu chíĐiểm
Test hợp lệ kiểm tra cả kết quả trả về, lời gọi AddAsync và SendWelcomeEmailAsync8
Test email sai kiểm tra cả ngoại lệ lẫn việc không gọi repository và email7
Test lifetime có đủ ba khẳng định (cùng scope, khác scope, transient)6
Test không dựng container cho CustomerService (dùng bản giả qua constructor) và dotnet test xanh4
Lời giải — Bài 2

Crm.Lab.Tests/CustomerServiceTests.cs:

csharp
using Crm.Lab;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;

namespace Crm.Lab.Tests;

public class CustomerServiceTests
{
    private readonly ICustomerRepository _repository = Substitute.For<ICustomerRepository>();
    private readonly IEmailService _emailService = Substitute.For<IEmailService>();
    private readonly CustomerService _sut;

    public CustomerServiceTests()
        => _sut = new CustomerService(_repository, _emailService, NullLogger<CustomerService>.Instance);

    [Fact]
    public async Task CreateCustomerAsync_ValidRequest_SavesAndSendsWelcomeEmail()
    {
        var request = new CreateCustomerRequest("Nguyễn Văn An", "[email protected]");

        var result = await _sut.CreateCustomerAsync(request);

        Assert.Equal("[email protected]", result.Email);
        await _repository.Received(1).AddAsync(
            Arg.Is<Customer>(c => c.Email == "[email protected]"), Arg.Any<CancellationToken>());
        await _emailService.Received(1).SendWelcomeEmailAsync("[email protected]", Arg.Any<CancellationToken>());
    }

    [Fact]
    public async Task CreateCustomerAsync_InvalidEmail_ThrowsAndDoesNothing()
    {
        var request = new CreateCustomerRequest("An", "khong-co-a-cong");

        await Assert.ThrowsAsync<ArgumentException>(() => _sut.CreateCustomerAsync(request));

        await _repository.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
        await _emailService.DidNotReceiveWithAnyArgs().SendWelcomeEmailAsync(default!, default);
    }
}

DidNotReceiveWithAnyArgs() khẳng định phương thức không được gọi với bất kỳ tham số nào; các giá trị default! chỉ để cú pháp hợp lệ.

Crm.Lab.Tests/LifetimeTests.cs:

csharp
using Microsoft.Extensions.DependencyInjection;

namespace Crm.Lab.Tests;

public class LifetimeTests
{
    private sealed class ScopedThing;
    private sealed class TransientThing;

    [Fact]
    public void Scoped_SameInsideScope_DifferentAcrossScopes_TransientAlwaysNew()
    {
        var services = new ServiceCollection();
        services.AddScoped<ScopedThing>();
        services.AddTransient<TransientThing>();
        using var root = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });

        using var scope1 = root.CreateScope();
        using var scope2 = root.CreateScope();
        var a = scope1.ServiceProvider.GetRequiredService<ScopedThing>();
        var b = scope1.ServiceProvider.GetRequiredService<ScopedThing>();
        var c = scope2.ServiceProvider.GetRequiredService<ScopedThing>();

        Assert.True(ReferenceEquals(a, b));     // cùng scope: cùng instance
        Assert.False(ReferenceEquals(a, c));    // khác scope: khác instance
        Assert.False(ReferenceEquals(
            scope1.ServiceProvider.GetRequiredService<TransientThing>(),
            scope1.ServiceProvider.GetRequiredService<TransientThing>()));   // transient: luôn mới
    }
}

Cả ba test xanh trên .NET 10. Để ý test của CustomerService không cần container nào: đó chính là lợi ích của constructor injection mà bài 7.1 hứa.

Bài 3 — Options pattern cho CrmSettings (Khá, 30 điểm) ​

Mỗi nhân viên kinh doanh (agent) chỉ được giữ tối đa MaxLeadsPerAgent lead đang mở.

  1. Tạo lớp CrmSettings với hằng SectionName = "Crm" và thuộc tính MaxLeadsPerAgent, giá trị hợp lệ từ 1 tới 1000. Thêm "Crm": { "MaxLeadsPerAgent": 50 } vào appsettings.json (đặt CopyToOutputDirectory để file được chép khi build).
  2. Tạo ILeadRepository với Task<int> CountOpenLeadsAsync(int agentId, CancellationToken ct) và một bản in-memory trả dữ liệu mẫu.
  3. Tạo LeadService nhận ILeadRepository và IOptions<CrmSettings>, có Task<bool> CanAssignLeadAsync(int agentId, CancellationToken ct): trả true khi số lead đang mở nhỏ hơn hạn mức.
  4. Đăng ký Options với ValidateDataAnnotations() và ValidateOnStart(), gọi await host.StartAsync() trước khi dùng service.
  5. Viết test: CanAssignLeadAsync đúng ở ba trường hợp dưới, bằng và trên hạn mức; và một test chứng minh cấu hình MaxLeadsPerAgent = 0 làm host không khởi động được.

Tiêu chí chấm:

Tiêu chíĐiểm
CrmSettings có SectionName và [Range(1, 1000)]; không chỗ nào đọc IConfiguration["Crm:..."] trực tiếp6
CanAssignLeadAsync đúng ở ranh giới (bằng hạn mức thì false) và có test [Theory] cho ba trường hợp10
Cấu hình sai làm ứng dụng dừng lúc khởi động, có test tự động chứng minh10
dotnet run với cấu hình mẫu in kết quả đúng cho hai agent4
Lời giải — Bài 3

Crm.Lab/Leads.cs:

csharp
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;

namespace Crm.Lab;

public sealed class CrmSettings
{
    public const string SectionName = "Crm";

    [Range(1, 1000)]
    public int MaxLeadsPerAgent { get; init; }
}

public interface ILeadRepository
{
    Task<int> CountOpenLeadsAsync(int agentId, CancellationToken ct = default);
}

public sealed class InMemoryLeadRepository : ILeadRepository
{
    public Task<int> CountOpenLeadsAsync(int agentId, CancellationToken ct = default)
        => Task.FromResult(agentId == 1 ? 50 : 3);      // dữ liệu mẫu
}

public class LeadService(ILeadRepository leads, IOptions<CrmSettings> options)
{
    public async Task<bool> CanAssignLeadAsync(int agentId, CancellationToken ct = default)
    {
        var open = await leads.CountOpenLeadsAsync(agentId, ct);
        return open < options.Value.MaxLeadsPerAgent;
    }
}

Crm.Lab/appsettings.json:

json
{
  "Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning" } },
  "Crm": { "MaxLeadsPerAgent": 50 }
}

Trong Crm.Lab.csproj, thêm để file được chép vào thư mục chạy:

xml
<ItemGroup>
  <None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Thêm vào Program.cs của Bài 1, trước builder.Build():

csharp
builder.Services.AddOptions<CrmSettings>()
    .Bind(builder.Configuration.GetSection(CrmSettings.SectionName))
    .ValidateDataAnnotations()
    .ValidateOnStart();
builder.Services.AddSingleton<ILeadRepository, InMemoryLeadRepository>();
builder.Services.AddScoped<LeadService>();

Sau using var host = builder.Build(); thêm await host.StartAsync(); (đây là lúc ValidateOnStart chạy), và trong khối using (var scope ...):

csharp
    var leads = scope.ServiceProvider.GetRequiredService<LeadService>();
    Console.WriteLine($"Agent 1 nhận thêm lead được không? {await leads.CanAssignLeadAsync(1)}");
    Console.WriteLine($"Agent 2 nhận thêm lead được không? {await leads.CanAssignLeadAsync(2)}");

Cuối file thêm await host.StopAsync();. Output trên .NET 10 (agent 1 có 50 lead, đúng bằng hạn mức):

text
Agent 1 nhận thêm lead được không? False
Agent 2 nhận thêm lead được không? True

Chạy với cấu hình sai qua biến môi trường (Git Bash: Crm__MaxLeadsPerAgent=0 dotnet run):

text
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
      Hosting failed to start
      Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'CrmSettings' members: 'MaxLeadsPerAgent' with the error: 'The field MaxLeadsPerAgent must be between 1 and 1000.'.

Crm.Lab.Tests/LeadServiceTests.cs:

csharp
using Crm.Lab;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NSubstitute;

namespace Crm.Lab.Tests;

public class LeadServiceTests
{
    [Theory]
    [InlineData(2, true)]     // dưới hạn mức
    [InlineData(3, false)]    // đúng bằng hạn mức: không nhận thêm
    [InlineData(4, false)]    // vượt hạn mức
    public async Task CanAssignLeadAsync_ComparesOpenLeadsWithLimit(int openLeads, bool expected)
    {
        var leads = Substitute.For<ILeadRepository>();
        leads.CountOpenLeadsAsync(7, Arg.Any<CancellationToken>()).Returns(openLeads);
        var options = Options.Create(new CrmSettings { MaxLeadsPerAgent = 3 });
        var sut = new LeadService(leads, options);

        Assert.Equal(expected, await sut.CanAssignLeadAsync(7));
    }

    [Fact]
    public async Task InvalidSettings_HostFailsToStart()
    {
        var builder = Host.CreateApplicationBuilder();
        builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
        {
            ["Crm:MaxLeadsPerAgent"] = "0",
        });
        builder.Services.AddOptions<CrmSettings>()
            .Bind(builder.Configuration.GetSection(CrmSettings.SectionName))
            .ValidateDataAnnotations()
            .ValidateOnStart();
        using var host = builder.Build();

        var ex = await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
        Assert.Contains("MaxLeadsPerAgent", ex.Message);
    }
}

Options.Create(...) bọc một đối tượng thành IOptions<T>: test không cần container hay file cấu hình. Toàn bộ 7 test của ba bài lab xanh trên .NET 10.

Mở rộng (không chấm điểm): đổi IOptions<CrmSettings> thành IOptionsMonitor<CrmSettings>, giữ app chạy (ví dụ thêm await host.WaitForShutdownAsync();), sửa MaxLeadsPerAgent trong bin/Debug/net10.0/appsettings.json và in giá trị trong callback OnChange (bài 7.7).

7.13.4 — Rubric và ngưỡng điểm ​

Tổng điểm = quiz (20) + lab (80, theo bảng tiêu chí từng bài).

MứcTổng điểmMô tả
Chưa đạtdưới 60Lab chưa chạy được ít nhất hai bài, hoặc sai từ 5 câu quiz trở lên. Đọc lại các bài ứng với câu sai trước khi sang Project 1.
Đạt60 – 74Bài 1 và Bài 2 chạy đúng, dotnet test xanh. Bài 3 có thể thiếu test cấu hình sai. Giải thích được đáp án quiz bằng lời của mình.
Khá75 – 89Cả ba bài đủ tiêu chí, kể cả ranh giới "bằng hạn mức" và test host không khởi động. Lifetime của mọi đăng ký có lý do (ghi một dòng chú thích cạnh mỗi Add...).
Giỏi90 – 100Như mức Khá, cộng thêm: bật ValidateScopes và ValidateOnBuild cho host bằng UseDefaultServiceProvider hoặc ServiceProviderOptions và có test dựng container với cả hai kiểm tra (bài 7.11), hoặc làm phần mở rộng của Bài 3.

7.13.5 — Tóm tắt Module 7 ​

BàiĐiều cần mang theo
7.1Vấn đề là lớp tự new, không phải thiếu interface; chỉ tiêm thứ có I/O, thời gian, ngẫu nhiên
7.2ServiceCollection là công thức, ServiceProvider là container; đăng ký sau thắng; container chỉ dispose thứ nó tạo
7.3Constructor injection là mặc định; method injection ở middleware, minimal API, [FromServices]
7.4Lifetime là về Dispose; Singleton không nhận Scoped; DbContext bị giam trả dữ liệu cũ
7.5Factory Singleton nhận root provider; TryAdd cho thư viện; decorator bằng factory
7.6Mọi đăng ký trước Build(); không BuildServiceProvider() thủ công (ASP0000); thứ tự middleware là ngữ nghĩa
7.7ValidateOnStart(); Snapshot là Scoped, Singleton dùng Monitor; tắt reload bằng hostBuilder:reloadConfigOnChange
7.8Khoá dùng enum; IEnumerable<T> không thấy service có khoá
7.9Service locator, static mutable, facade giấu phụ thuộc đều là giấu phụ thuộc
7.10Decorate sau nằm ngoài; module theo tính năng; test resolve thật
7.11CreateScope() không phải scope của request; bật cả ValidateScopes và ValidateOnBuild
7.12IOptions không đọc lại; biến môi trường thắng file và không đọc lại

Tham khảo ​

Điều hướng ​

Phát hành theo giấy phép MIT.