Skip to main content

16.8 — 7. FluentValidation

Summary

Câu hỏi khó nhất về validation không phải "dùng thư viện nào" mà là "quy tắc này thuộc về đâu". Ranh giới rất rõ khi nhìn đúng góc: validator kiểm tra dữ liệu đầu vào có đúng hình dạng không — trường bắt buộc, định dạng email, khoảng giá trị; domain bảo vệ bất biến nghiệp vụ — trạng thái nào chuyển sang trạng thái nào được, hạn mức, tồn kho. Đặt bất biến vào validator là sai vì validator chỉ chạy ở một đường vào: job import, script sửa dữ liệu và một service khác đều bỏ qua nó. Và một quy tắc thực dụng: validator không được gọi database — nó chạy trước mọi thứ, không có transaction, và biến mỗi request thành một truy vấn thừa.

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

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

  • Đặt mỗi quy tắc vào đúng tầng.
  • Viết validator cho quy tắc có điều kiện.
  • Tránh gọi database trong validator.
  • Chuyển lỗi validation thành phản hồi HTTP đúng.
  • Test validator.

Nội dung bài học​

16.8.1 — Ba tầng, ba loại quy tắc​

TầngKiểm traVí dụ
ValidatorHình dạng dữ liệuEmail không rỗng, đúng định dạng, dài ≤ 320
ApplicationQuy tắc cần dữ liệuEmail đã tồn tại chưa, khách còn hạn mức không
DomainBất biếnLead đã mất thì không convert được

Phân biệt bằng một câu hỏi: "quy tắc này cần gì để kiểm tra?"

Chi can chinh request        -> Validator
Can truy van database -> Application
Can trang thai cua entity -> Domain

Và một câu hỏi thứ hai để phát hiện đặt sai chỗ: "nếu dữ liệu vào từ job import thay vì từ API, quy tắc này còn áp dụng không?"

Nếu có, nó không thuộc về validator — vì validator chỉ chạy ở đường API.

// SAI — bat bien nghiep vu trong validator
RuleFor(x => x.Value)
.LessThanOrEqualTo(500_000_000)
.When(x => x.CustomerTier == "Bronze");

Quy tắc này chỉ tồn tại ở endpoint đó. Job import tạo deal 2 tỷ cho khách Bronze mà không ai chặn.

// DUNG — bat bien nam trong domain
public sealed class Deal
{
public static Deal Create(CustomerTier tier, Money value)
{
if (tier == CustomerTier.Bronze && value.Amount > 500_000_000)
throw new DomainException("Khách hàng Bronze không được tạo deal trên 500 triệu");

return new Deal { ... };
}
}

Giờ mọi đường vào đều đi qua nó.

16.8.2 — Validator​

public sealed class CreateLeadValidator : AbstractValidator<CreateLeadCommand>
{
public CreateLeadValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Tên khách hàng là bắt buộc")
.Length(2, 200);

RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MaximumLength(320);

RuleFor(x => x.Value)
.GreaterThan(0).WithMessage("Giá trị phải lớn hơn 0");

RuleFor(x => x.Phone)
.Matches(@"^(0|\+84)[0-9]{9}$")
.When(x => !string.IsNullOrEmpty(x.Phone)); // chỉ khi CÓ giá trị
}
}

Ba điều FluentValidation làm tốt hơn DataAnnotations (bài 8.6):

1. Quy tắc có điều kiện rõ ràng:

When(x => x.Type == CustomerType.Business, () =>
{
RuleFor(x => x.TaxCode).NotEmpty().Length(10, 13);
RuleFor(x => x.CompanyName).NotEmpty();
});

Unless(x => x.IsDraft, () =>
{
RuleFor(x => x.Items).NotEmpty();
});

Với DataAnnotations, việc này cần IValidatableObject và một chuỗi if.

2. Quy tắc liên quan nhiều trường:

RuleFor(x => x.EndDate)
.GreaterThan(x => x.StartDate)
.WithMessage("Ngày kết thúc phải sau ngày bắt đầu");

3. Test được như một lớp bình thường — mục 16.8.5.

RuleLevelCascadeMode tránh thông báo lỗi trùng lặp:

public CreateLeadValidator()
{
RuleLevelCascadeMode = CascadeMode.Stop; // dừng ở lỗi ĐẦU TIÊN mỗi trường

RuleFor(x => x.Email).NotEmpty().EmailAddress();
// Email rỗng -> chỉ báo "bắt buộc", không báo thêm "không đúng định dạng"
}

16.8.3 — Validator không gọi database​

// SAI — truy van trong validator
RuleFor(x => x.Email)
.MustAsync(async (email, ct) => !await _db.Leads.AnyAsync(l => l.Email == email, ct))
.WithMessage("Email đã tồn tại");

Bốn vấn đề:

  1. Chạy cho mọi request, kể cả request sẽ bị từ chối vì trường khác.
  2. Không có transaction — giữa lúc kiểm tra và lúc lưu, ai đó có thể tạo trùng.
  3. Vẫn cần ràng buộc UNIQUE ở database, nên kiểm tra này chỉ để có thông báo đẹp hơn.
  4. Validator giờ phụ thuộc hạ tầng — không test được nếu không mock database.
// DUNG — trong handler, gan noi co transaction
public async Task<Result<Guid>> Handle(CreateLeadCommand command, CancellationToken ct)
{
if (await _leads.EmailExistsAsync(command.Email, ct))
return Result.Conflict<Guid>("Email đã tồn tại");

...
}

Và ràng buộc UNIQUE vẫn là hàng rào cuối (bài 12.2):

try
{
await _uow.SaveChangesAsync(ct);
}
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
return Result.Conflict<Guid>("Email đã tồn tại"); // thua race condition
}

Kiểm tra trước cho thông báo tốt; ràng buộc database cho tính đúng đắn. Cần cả hai.

16.8.4 — Chuyển lỗi thành phản hồi HTTP​

public sealed class ValidationExceptionHandler(IProblemDetailsService pds) : IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext context, Exception exception, CancellationToken ct)
{
if (exception is not ValidationException ve) return false;

var errors = ve.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(g => g.Key, g => g.Select(e => e.ErrorMessage).ToArray());

context.Response.StatusCode = StatusCodes.Status400BadRequest;

return await pds.TryWriteAsync(new ProblemDetailsContext
{
HttpContext = context,
ProblemDetails = new ValidationProblemDetails(errors)
{
Status = StatusCodes.Status400BadRequest,
Title = "Dữ liệu không hợp lệ",
}
});
}
}

Giữ nguyên ValidationProblemDetails để client sinh tự động parse được (bài 8.6).

Phân biệt ba mã trạng thái:

LoạiMã
Validator: hình dạng sai400
Application: email đã tồn tại409
Domain: vi phạm bất biến422

Ba mã khác nhau cho ba loại lỗi khác nhau giúp client xử lý đúng: 400 là "sửa form", 409 là "dữ liệu xung đột", 422 là "thao tác không hợp lệ ở trạng thái hiện tại".

16.8.5 — Test validator​

public sealed class CreateLeadValidatorTests
{
private readonly CreateLeadValidator _validator = new();

[Fact]
public void Should_HaveError_When_EmailIsEmpty()
{
var command = new CreateLeadCommand("Nguyen Van A", "", 1000);

_validator.TestValidate(command)
.ShouldHaveValidationErrorFor(x => x.Email);
}

[Theory]
[InlineData("not-an-email")]
[InlineData("@example.com")]
public void Should_HaveError_When_EmailInvalid(string email)
{
_validator.TestValidate(new CreateLeadCommand("A", email, 1000))
.ShouldHaveValidationErrorFor(x => x.Email);
}

[Fact]
public void Should_RequireTaxCode_ForBusinessCustomer()
{
var command = new CreateLeadCommand("A", "a@b.com", 1000)
{
Type = CustomerType.Business, TaxCode = null
};

_validator.TestValidate(command)
.ShouldHaveValidationErrorFor(x => x.TaxCode);
}
}

TestValidate từ FluentValidation.TestHelper cho API đọc được. Và validator không có phụ thuộc nào, nên test chạy trong mili giây — đó là lợi ích trực tiếp của việc không gọi database trong đó.

Đăng ký:

builder.Services.AddValidatorsFromAssemblyContaining<CreateLeadValidator>();

Và một validator mỗi command. Validator dùng chung cho nhiều command tạo ghép chặt giống như DTO dùng chung ở bài 16.5.

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

Danh sách rà soát validation

  • •Mỗi quy tắc đã trả lời được câu hỏi nó cần gì để kiểm tra.
  • •Quy tắc vẫn áp dụng khi dữ liệu vào từ job import thì nằm ở domain.
  • •Validator không gọi database.
  • •Kiểm tra trùng lặp nằm ở handler, và vẫn có ràng buộc UNIQUE ở database.
  • •Có xử lý DbUpdateException cho race condition sau kiểm tra.
  • •Quy tắc có điều kiện dùng When hoặc Unless, không dùng chuỗi if.
  • •CascadeMode được đặt để tránh thông báo lỗi trùng lặp.
  • •Lỗi validator trả 400, xung đột dữ liệu trả 409, vi phạm bất biến trả 422.
  • •Định dạng lỗi giữ nguyên ValidationProblemDetails.
  • •Mỗi command có validator riêng, không dùng chung.
  • •Validator có unit test và chạy không cần hạ tầng.

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

Bài 1 — Bất biến đặt sai chỗ​

Đặt một quy tắc nghiệp vụ vào validator, rồi tạo dữ liệu qua một job import bỏ qua tầng API. Xác nhận quy tắc bị bỏ qua hoàn toàn.

Tiêu chí hoàn thành: bạn nêu được ranh giới giữa validation đầu vào và bất biến domain, và có một câu hỏi để phân loại bất kỳ quy tắc nào.

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

Gợi ý. Validator chạy khi nào? Job import có đi qua đó không?

Lời giải — quy tắc đặt sai chỗ:

public class TaoLeadValidator : AbstractValidator<TaoLeadRequest>
{
public TaoLeadValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Email).NotEmpty().EmailAddress();

// BẤT BIẾN NGHIỆP VỤ đặt trong validator
RuleFor(x => x.Value)
.LessThanOrEqualTo(500_000_000)
.When(x => !x.DaDuyet)
.WithMessage("Lead trên 500 triệu cần được duyệt trước");
}
}
curl -X POST http://localhost:8080/leads -d '{"name":"ABC","value":600000000,"daDuyet":false}'
HTTP 400
{"errors":{"Value":["Lead trên 500 triệu cần được duyệt trước"]}}

Đường API hoạt động đúng. Nhưng:

public class ImportLeadJob
{
public async Task ChayAsync(Stream csv, CancellationToken ct)
{
await foreach (var row in DocCsvAsync(csv, ct))
{
var lead = new Lead
{
Name = row.Name,
Email = row.Email,
Value = row.Value, // 900 triệu, chưa duyệt — KHÔNG ai kiểm tra
DaDuyet = false,
};
_db.Leads.Add(lead);
}
await _db.SaveChangesAsync(ct);
}
}
SELECT COUNT(*) FROM Leads WHERE Value > 500000000 AND DaDuyet = 0;
1.847

1.847 lead vi phạm quy tắc, tất cả đến từ import. Và không đường nào báo lỗi.

Liệt kê mọi đường đi vòng qua validator:

1. Job import CSV                 -> không qua validator
2. Message consumer đồng bộ -> không qua
3. Migration có seed data -> không qua
4. Unit test tạo dữ liệu mẫu -> không qua
5. ExecuteUpdate cập nhật Value -> không qua
6. Script SQL chạy tay -> không qua
7. Một endpoint khác quên gắn validator -> không qua

Ranh giới giữa validation đầu vào và bất biến domain:

Validation đầu vàoBất biến domain
Trả lời câu hỏi"Request này có đọc được không?""Trạng thái này có hợp lệ không?"
Nằm ởValidator, DTOEntity, value object
Chạy khi nàoMỗi request HTTPMọi lần thay đổi trạng thái
Ví dụEmail đúng định dạng, tên không rỗng, số dươngLead trên ngưỡng cần duyệt, đơn đã giao không huỷ được
Nếu vi phạmHTTP 400, thông điệp cho người dùngException hoặc Result — không được phép xảy ra
Đường vòngCó — mọi đường không qua HTTPKhông — nếu entity được viết đúng

Câu hỏi để phân loại bất kỳ quy tắc nào:

"Nếu dữ liệu này đến từ một nguồn KHÔNG phải HTTP, quy tắc có còn phải đúng không?"

Có  -> BẤT BIẾN DOMAIN -> đặt trong entity
Không -> validation đầu vào -> đặt trong validator

Áp dụng:

"Email phải đúng định dạng"
-> job import cũng phải có email hợp lệ -> CÓ -> nhưng...

Trường hợp email đáng bàn kỹ vì nó thuộc cả hai:

// Validator — kiểm tra ĐẦU VÀO, cho thông điệp thân thiện
RuleFor(x => x.Email).NotEmpty().EmailAddress()
.WithMessage("Email không đúng định dạng");

// Value object — bảo vệ BẤT BIẾN, không thể tạo Email sai
public readonly record struct Email
{
public string Value { get; }

private Email(string value) => Value = value;

public static Result<Email> Tao(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return Result<Email>.Loi("Email không được rỗng");
if (!MailAddress.TryCreate(value, out _))
return Result<Email>.Loi("Email không đúng định dạng");
return Result<Email>.ThanhCong(new Email(value.Trim().ToLowerInvariant()));
}
}

Hai lớp này không thừa nhau:

Validator:     trả về 400 với thông điệp đọc được, cho MỌI lỗi cùng lúc
-> trải nghiệm người dùng tốt

Value object: bảo đảm không tồn tại Email không hợp lệ trong hệ thống
-> đúng đắn, cho mọi đường ghi

Không có validator, người dùng nhận một exception xấu xí và chỉ thấy lỗi đầu tiên. Không có value object, job import tạo được email rác.

Bản sửa cho quy tắc ngưỡng duyệt:

// Crm.Domain/Entities/Lead.cs
public class Lead
{
public static readonly Money NguongCanDuyet = Money.VND(500_000_000);

public Money Value { get; private set; }
public bool DaDuyet { get; private set; }

private Lead() { }

public static Result<Lead> Tao(string ten, Email email, Money giaTri, bool daDuyet)
{
if (string.IsNullOrWhiteSpace(ten))
return Result<Lead>.Loi("Tên lead là bắt buộc");

if (giaTri >= NguongCanDuyet && !daDuyet)
return Result<Lead>.Loi($"Lead trên {NguongCanDuyet} cần được duyệt trước");

return Result<Lead>.ThanhCong(new Lead
{
Name = ten, Email = email, Value = giaTri, DaDuyet = daDuyet,
});
}
}
// Job import giờ BUỘC phải xử lý
await foreach (var row in DocCsvAsync(csv, ct))
{
var email = Email.Tao(row.Email);
if (!email.ThanhCong) { GhiLoi(row, email.Loi); continue; }

var lead = Lead.Tao(row.Name, email.Value, Money.VND(row.Value), row.DaDuyet);
if (!lead.ThanhCong) { GhiLoi(row, lead.Loi); continue; }

_db.Leads.Add(lead.Value);
}

_logger.LogInformation("Import xong: {ThanhCong} thành công, {Loi} lỗi", soThanhCong, soLoi);

Dòng log cuối đáng chú ý: trước đây job im lặng tạo dữ liệu sai; giờ nó báo cáo. Bạn có thể phát hiện rằng 12% file import vốn đã không hợp lệ — thông tin nghiệp vụ quan trọng mà trước đó không ai biết.

Validator giữ lại phần thuộc về nó:

public class TaoLeadValidator : AbstractValidator<TaoLeadRequest>
{
public TaoLeadValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Value).GreaterThan(0);
// Quy tắc ngưỡng duyệt ĐÃ CHUYỂN sang Lead.Tao()
}
}

Và lớp thứ ba: ràng buộc database:

ALTER TABLE Leads ADD CONSTRAINT CK_Leads_NguongDuyet
CHECK (Value < 500000000 OR DaDuyet = 1);

Ba lớp, ba vai trò:

Validator:        trải nghiệm người dùng — thông điệp rõ, nhiều lỗi cùng lúc
Entity: đúng đắn cho mọi đường ghi TRONG ứng dụng
Ràng buộc DB: đúng đắn cho mọi đường ghi, kể cả NGOÀI ứng dụng

Không lớp nào thừa, và mỗi lớp bắt được một tập vấn đề mà hai lớp kia không bắt được.

Rà soát validator hiện có:

grep -rn "WithMessage" --include="*Validator.cs" src/ | wc -l

Đọc từng thông điệp và hỏi câu hỏi phân loại ở trên. Thông điệp nào nghe như một quy tắc nghiệp vụ — có nhắc tới ngưỡng, trạng thái, vai trò, hay điều kiện phụ thuộc dữ liệu khác — thì gần như chắc chắn đang ở sai chỗ.


Bài 2 — Race condition khi kiểm tra trùng​

Bỏ ràng buộc UNIQUE và chỉ kiểm tra trùng bằng truy vấn. Bắn 20 request đồng thời cùng email và đếm số bản ghi được tạo.

Tiêu chí hoàn thành: bạn giải thích được vì sao không có mức cô lập transaction nào sửa được điều này một cách thực dụng, và biết cách kết hợp hai lớp.

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

Gợi ý. Bạn đang khoá sự vắng mặt của một dòng. Khoá được không?

Lời giải — validator kiểm tra trùng:

public class TaoLeadValidator : AbstractValidator<TaoLeadRequest>
{
public TaoLeadValidator(CrmDbContext db)
{
RuleFor(x => x.Email)
.MustAsync(async (email, ct) =>
!await db.Leads.AnyAsync(l => l.Email == email, ct))
.WithMessage("Email đã tồn tại");
}
}
for i in $(seq 1 20); do
curl -s -X POST http://localhost:8080/leads \
-d '{"name":"ABC","email":"an@abc.com","value":1000000}' &
done; wait
SELECT COUNT(*) FROM Leads WHERE Email = 'an@abc.com';
14

14 bản ghi trùng. Và số này đổi theo mỗi lần chạy — dấu hiệu kinh điển của race condition.

Vì sao:

t=0,000  Request 1..20 cùng chạy SELECT -> tất cả thấy "chưa tồn tại"
t=0,015 Request 1..20 cùng chạy INSERT -> tất cả thành công

Đây lại là mẫu kiểm-tra-rồi-hành-động (bài 13.8, bài 14.9), lần này ở tầng validation.

Vì sao không mức cô lập nào sửa được một cách thực dụng — đây là phần chính của bài.

Bạn đang cố khoá thứ chưa tồn tại. Với một dòng đã có, database khoá được chính dòng đó. Với sự vắng mặt của một dòng, không có gì để khoá — cần khoá phạm vi (range lock).

await using var tx = await _db.Database.BeginTransactionAsync(IsolationLevel.Serializable, ct);

if (await _db.Leads.AnyAsync(l => l.Email == email, ct))
return Result.Loi("Email đã tồn tại");

_db.Leads.Add(lead);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);

Serializable có ngăn được trùng, nhưng với ba cái giá:

1. Deadlock thay vì trùng lặp:

Msg 1205: Transaction (Process ID 58) was deadlocked on lock resources...

Hai giao dịch cùng lấy khoá phạm vi chia sẻ trên cùng khoảng, rồi cùng muốn nâng lên khoá độc quyền để INSERT. Đây là deadlock chuyển đổi khoá ở bài 12.10.

Bạn đổi "14 bản ghi trùng" lấy "hầu hết request nhận lỗi 1205" — không tốt hơn.

2. Khoá phạm vi chặn rất rộng. Không có index trên Email, SQL Server khoá toàn bộ bảng:

Một người tạo lead -> mọi người khác chờ
-> throughput sụp đổ

3. Serializable áp cho cả transaction, nên mọi truy vấn khác trong cùng transaction cũng chịu chi phí đó.

Có một cách thực dụng hơn Serializable — khoá tường minh trên key:

await _db.Database.ExecuteSqlInterpolatedAsync($@"
EXEC sp_getapplock @Resource = {$"lead-email-{email}"},
@LockMode = 'Exclusive', @LockOwner = 'Transaction'", ct);

Nó hoạt động, nhưng thêm một khái niệm phải hiểu và bảo trì. So với một dòng CREATE UNIQUE INDEX, đó là đánh đổi tệ.

Bản sửa — hai lớp:

Lớp 1 — ràng buộc database, đây mới là thứ bảo đảm:

CREATE UNIQUE INDEX UX_Leads_Email ON Leads(Email) WHERE IsDeleted = 0;
builder.Entity<Lead>()
.HasIndex(l => l.Email)
.IsUnique()
.HasFilter("[IsDeleted] = 0");

Index có lọc là chi tiết quan trọng với soft delete: không có nó, một email đã xoá mềm vẫn chiếm chỗ và không tạo lại được.

Lớp 2 — bắt lỗi và trả thông điệp đọc được:

public async Task<Result> Handle(TaoLeadCommand c, CancellationToken ct)
{
var lead = Lead.Tao(c.Name, c.Email, c.Value);
if (!lead.ThanhCong) return lead;

_db.Leads.Add(lead.Value);

try
{
await _db.SaveChangesAsync(ct);
return Result.ThanhCong();
}
catch (DbUpdateException ex) when (LaViPhamUnique(ex, "UX_Leads_Email"))
{
return Result.Loi("Email đã tồn tại");
}
}

private static bool LaViPhamUnique(DbUpdateException ex, string tenIndex)
=> ex.InnerException is SqlException { Number: 2601 or 2627 } sql
&& sql.Message.Contains(tenIndex, StringComparison.OrdinalIgnoreCase);
20 request đồng thời -> 1 bản ghi, 19 request nhận "Email đã tồn tại"

Kiểm tra tên index trong thông điệp lỗi là chi tiết đáng làm: một entity có thể có nhiều unique index, và bạn muốn trả thông điệp đúng cho từng cái.

Vậy còn validator kiểm tra trùng — có nên giữ không?

RuleFor(x => x.Email)
.MustAsync(async (email, ct) => !await db.Leads.AnyAsync(l => l.Email == email, ct))
.WithMessage("Email đã tồn tại");

Giữ, nhưng hiểu đúng vai trò của nó:

ValidatorUnique index
Bảo đảm không trùngKhôngCó
Thông điệp lỗiĐẹp, cùng lúc với lỗi khácException phải bắt và dịch
Bắt được ở đường nàoChỉ HTTPMọi đường
Chi phíMột truy vấn mỗi requestGần như không
Vai tròTrải nghiệm người dùngĐúng đắn

Validator cho người dùng thấy lỗi cùng lúc với các lỗi khác của form:

{"errors":{
"email":["Email đã tồn tại"],
"value":["Giá trị phải lớn hơn 0"],
"name":["Tên không được vượt quá 200 ký tự"]
}}

Không có validator, người dùng sửa tên, gửi lại, rồi mới biết email trùng — hai vòng thay vì một.

Nhưng nếu phải chọn một, chọn unique index. Trải nghiệm kém hơn một chút; dữ liệu vẫn đúng.

Và một lưu ý về hiệu năng của validator gọi database — xem bài 3: nó chạy kể cả khi các quy tắc khác đã thất bại, trừ khi bạn cấu hình đúng.

Danh sách những gì phải có ràng buộc database, không chỉ validator:

- Trùng lặp (unique)
- Khoá ngoại (dữ liệu tham chiếu phải tồn tại)
- Giá trị nằm trong tập hợp cho phép (check constraint)
- Không âm, trong khoảng (check constraint)
- Bất biến liên quan nhiều cột (check constraint)

Mỗi mục là một thứ mà validator không thể bảo đảm, vì luôn có đường ghi không đi qua nó.

Kiểm chứng bằng test đồng thời thật:

[Fact]
public async Task Hai_muoi_request_cung_email_chi_tao_mot_lead()
{
var email = $"test-{Guid.NewGuid():N}@abc.com";

var ketQua = await Task.WhenAll(Enumerable.Range(0, 20).Select(_ =>
_client.PostAsJsonAsync("/leads", new { name = "ABC", email, value = 1_000_000 })));

ketQua.Count(r => r.IsSuccessStatusCode).Should().Be(1);
(await DemLeadTheoEmailAsync(email)).Should().Be(1);
}

Test này cần database thật — provider in-memory không thực thi unique index theo cách có khoá, nên nó cho test xanh ngay cả với bản có lỗi.


Bài 3 — Chi phí của validator gọi database​

Thêm một MustAsync truy vấn database, gửi request thiếu trường bắt buộc khác, và xác nhận truy vấn vẫn chạy.

Tiêu chí hoàn thành: bạn đo được số truy vấn thừa, và biết ba cách cấu hình để tránh.

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

Gợi ý. FluentValidation mặc định chạy mọi rule để báo mọi lỗi cùng lúc. Điều đó tốt cho trải nghiệm — và tệ cho cái gì?

Lời giải:

public class TaoLeadValidator : AbstractValidator<TaoLeadRequest>
{
public TaoLeadValidator(CrmDbContext db)
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Value).GreaterThan(0);

RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(async (email, ct) =>
!await db.Leads.AnyAsync(l => l.Email == email, ct))
.WithMessage("Email đã tồn tại");
}
}
curl -X POST http://localhost:8080/leads -d '{"name":"","email":"","value":0}'
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (14ms)
SELECT CASE WHEN EXISTS (SELECT 1 FROM [Leads] WHERE [Email] = @__email_0)
THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END

Truy vấn vẫn chạy, mặc dù:

  • Name rỗng — request chắc chắn bị từ chối
  • Email rỗng — truy vấn tìm email rỗng, luôn vô nghĩa
  • Value bằng 0 — cũng sai
Request không hợp lệ:  1 truy vấn database vô ích, 14 ms
10.000 request/giờ, 15% không hợp lệ: 1.500 truy vấn vô ích mỗi giờ

Và nó tệ hơn khi có nhiều rule gọi database:

RuleFor(x => x.Email).MustAsync(KiemTraTrung);
RuleFor(x => x.CustomerId).MustAsync(KiemTraKhachTonTai);
RuleFor(x => x.AssignedTo).MustAsync(KiemTraNguoiDungTonTai);
Một request rỗng hoàn toàn -> 3 truy vấn database

Đây cũng là một bề mặt tấn công: kẻ tấn công gửi request rỗng liên tục và mỗi cái tốn ba truy vấn.

Ba cách cấu hình để tránh:

Cách 1 — CascadeMode.Stop cho từng rule chain:

RuleFor(x => x.Email)
.Cascade(CascadeMode.Stop) // dừng ngay khi một rule thất bại
.NotEmpty()
.EmailAddress()
.MustAsync(async (email, ct) => !await db.Leads.AnyAsync(l => l.Email == email, ct));
Email rỗng -> NotEmpty thất bại -> DỪNG -> không chạy EmailAddress, không chạy MustAsync

Nhưng nó chỉ dừng trong cùng một chain. Name rỗng vẫn không ngăn được chain của Email chạy.

Cách 2 — RuleLevelCascadeMode và ClassLevelCascadeMode:

public TaoLeadValidator(CrmDbContext db)
{
RuleLevelCascadeMode = CascadeMode.Stop; // dừng trong mỗi chain
ClassLevelCascadeMode = CascadeMode.Stop; // dừng ở rule ĐẦU TIÊN thất bại

RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Value).GreaterThan(0);
RuleFor(x => x.Email).NotEmpty().EmailAddress().MustAsync(...);
}
Name rỗng -> DỪNG toàn bộ -> không truy vấn nào chạy

Nhưng ClassLevelCascadeMode = Stop có cái giá: người dùng chỉ thấy một lỗi mỗi lần gửi:

{"errors":{"name":["Tên không được rỗng"]}}

Sửa tên, gửi lại, mới biết Value cũng sai. Với một form 15 trường, đó là trải nghiệm tệ.

Cách 3 — tách hai giai đoạn, và đây là cách tốt nhất:

// Giai đoạn 1 — quy tắc rẻ, KHÔNG chạm database
public class TaoLeadValidator : AbstractValidator<TaoLeadRequest>
{
public TaoLeadValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Email).Cascade(CascadeMode.Stop).NotEmpty().EmailAddress();
RuleFor(x => x.Value).GreaterThan(0);
RuleFor(x => x.CustomerId).NotEmpty();
}
}

// Giai đoạn 2 — quy tắc CẦN database, chỉ chạy khi giai đoạn 1 đã qua
public class TaoLeadDbValidator : AbstractValidator<TaoLeadRequest>
{
public TaoLeadDbValidator(CrmDbContext db)
{
RuleFor(x => x.CustomerId)
.MustAsync(async (id, ct) => await db.Customers.AnyAsync(c => c.Id == id, ct))
.WithMessage("Khách hàng không tồn tại");
}
}
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
// Giai đoạn 1 — mọi validator rẻ, chạy SONG SONG, báo MỌI lỗi
var loiCoBan = (await Task.WhenAll(_validatorCoBan
.Select(v => v.ValidateAsync(request, ct))))
.SelectMany(r => r.Errors).ToList();

if (loiCoBan.Count > 0) throw new ValidationException(loiCoBan);

// Giai đoạn 2 — chỉ chạy khi giai đoạn 1 sạch
var loiDb = (await Task.WhenAll(_validatorDb
.Select(v => v.ValidateAsync(request, ct))))
.SelectMany(r => r.Errors).ToList();

if (loiDb.Count > 0) throw new ValidationException(loiDb);

return await next();
}
}
Request rỗng:       0 truy vấn, và người dùng thấy MỌI lỗi định dạng cùng lúc
Request hợp lệ: truy vấn chạy, như mong đợi

Cách này có cả hai: không truy vấn thừa, và trải nghiệm người dùng tốt.

Nhưng câu hỏi sâu hơn: validator có nên gọi database không?

Ba lý do nói không:

1. Nó không bảo đảm được gì — race condition ở bài 2.

2. Nó trộn hai mối quan tâm. Validator lẽ ra là hàm thuần tuý trên đầu vào; gọi database biến nó thành thứ phụ thuộc trạng thái hệ thống, khó test và khó suy luận.

3. Nó làm test validator cần database:

// Test validator thuần tuý — không cần gì
[Theory]
[InlineData("", false)]
[InlineData("khong-phai-email", false)]
[InlineData("an@abc.com", true)]
public void Kiem_tra_email(string email, bool hopLe)
{
var kq = new TaoLeadValidator().TestValidate(new TaoLeadRequest { Email = email, ... });
if (hopLe) kq.ShouldNotHaveValidationErrorFor(x => x.Email);
else kq.ShouldHaveValidationErrorFor(x => x.Email);
}

Thêm MustAsync gọi database và test này cần dựng DbContext — thứ đã mổ xẻ ở bài 16.1.

Khi nào gọi database trong validator là chấp nhận được:

CHẤP NHẬN:  kiểm tra tồn tại để cho thông điệp lỗi đẹp
(khách hàng, người dùng được gán, sản phẩm)
-> vẫn phải có khoá ngoại ở database làm lớp bảo đảm

KHÔNG: kiểm tra trùng (race condition -> dùng unique index)
quy tắc nghiệp vụ (đường vòng -> đặt trong entity, bài 1)
phân quyền (đặt trong AuthorizationBehavior, chạy TRƯỚC validation)

Đo trên dự án của bạn:

grep -rn "MustAsync\|WhenAsync\|CustomAsync" --include="*Validator.cs" src/

Mỗi kết quả là một truy vấn database chạy trên mọi request tới endpoint đó. Với mỗi cái, hỏi hai câu:

1. Nó có bảo đảm được gì không, hay chỉ cho thông điệp đẹp?
2. Có ràng buộc database tương ứng làm lớp bảo đảm chưa?

Nếu câu 1 là "chỉ cho thông điệp đẹp" và câu 2 là "chưa", bạn đang có một lỗ hổng tính đúng đắn kèm một chi phí hiệu năng — và cả hai cùng biến mất khi thêm ràng buộc database.

Tự kiểm tra​

Frequently asked questions

Làm sao biết một quy tắc thuộc tầng nào?

Hỏi nó cần gì để kiểm tra. Chỉ cần chính request thì thuộc validator; cần truy vấn database thì thuộc Application; cần trạng thái của entity thì thuộc Domain. Câu hỏi thứ hai để phát hiện đặt sai là nếu dữ liệu vào từ job import thì quy tắc còn áp dụng không.

Vì sao bất biến nghiệp vụ không được đặt vào validator?

Vì validator chỉ chạy ở một đường vào là API. Job import, script sửa dữ liệu và một service khác cùng database đều bỏ qua nó, nên quy tắc có thể bị vi phạm mà không ai biết. Bất biến phải nằm trong entity để mọi đường vào đều đi qua.

Vì sao validator không nên gọi database?

Bốn lý do: nó chạy cho mọi request kể cả request sẽ bị từ chối vì trường khác, nó không có transaction nên vẫn có race condition, bạn vẫn cần ràng buộc UNIQUE ở database, và validator trở thành phụ thuộc hạ tầng nên không test được nhanh.

Kiểm tra trùng lặp nên làm ở đâu?

Trong handler để có thông báo lỗi tốt, và vẫn giữ ràng buộc UNIQUE ở database làm hàng rào cuối. Kiểm tra trước cho thông báo đẹp, ràng buộc database cho tính đúng đắn; và nên bắt DbUpdateException để xử lý race condition.

Ba loại lỗi tương ứng ba mã trạng thái nào?

Lỗi hình dạng từ validator là 400, xung đột dữ liệu như email đã tồn tại là 409, và vi phạm bất biến domain là 422. Ba mã khác nhau giúp client biết nên sửa form, báo xung đột, hay báo thao tác không hợp lệ ở trạng thái hiện tại.

Vì sao mỗi command nên có validator riêng?

Validator dùng chung cho nhiều command tạo ghép chặt giống như DTO dùng chung: đổi quy tắc cho một use case sẽ ảnh hưởng use case khác, dù chúng thay đổi vì lý do khác nhau.

Kết luận​

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

  1. Nếu quy tắc vẫn áp dụng khi dữ liệu vào từ job import, nó thuộc về domain.
  2. Validator không gọi database. Kiểm tra trùng thuộc về handler, cộng UNIQUE ở database.
  3. 400, 409, 422 là ba loại lỗi khác nhau — client cần phân biệt được.

Tham khảo​

Điều hướng​