8.5 - Model Validation
Tóm tắt: Validation ở tầng API trả lời đúng một câu hỏi: dữ liệu này có đúng hình dạng không? Nó không trả lời "email này đã tồn tại chưa" — câu đó cần database và thuộc về tầng nghiệp vụ. Nhầm lẫn ranh giới này là lý do các
ValidationAttributedần biến thành nơi chứa truy vấn SQL. Về công cụ: DataAnnotations đủ cho quy tắc theo từng thuộc tính,IValidatableObjectcho quy tắc liên quan nhiều thuộc tính, và FluentValidation khi quy tắc phức tạp hoặc cần dependency. Định dạng lỗi thì đã chuẩn hoá sẵn:ProblemDetailstheo RFC 7807, nên đừng tự nghĩ ra định dạng riêng.
Mục tiêu bài học
Sau bài này bạn có thể:
- Đặt đúng ranh giới giữa validation đầu vào và quy tắc nghiệp vụ.
- Viết quy tắc liên quan nhiều thuộc tính bằng
IValidatableObject. - Viết
ValidationAttributetuỳ biến đúng cách. - Tuỳ biến
ProblemDetailsmà vẫn giữ chuẩn. - Chọn giữa DataAnnotations và FluentValidation.
Nội dung bài học
8.5.1 — Ranh giới
| Thuộc về validation đầu vào | Thuộc về nghiệp vụ |
|---|---|
| Trường bắt buộc có mặt | Email đã tồn tại trong hệ thống chưa |
| Email đúng định dạng | Khách hàng còn hạn mức tín dụng không |
| Số trong khoảng cho phép | Đơn hàng có được huỷ ở trạng thái này không |
| Ngày kết thúc sau ngày bắt đầu | Kho còn hàng không |
| Độ dài chuỗi hợp lệ | Người dùng có quyền làm việc này không |
Cột trái chỉ cần chính request đó; cột phải cần truy cập dữ liệu.
Ranh giới này quan trọng vì hai lý do. Thứ nhất, validation đầu vào chạy trước action nên nó không có CancellationToken, không có transaction, và một truy vấn database ở đó là điểm nghẽn không ai nhìn thấy. Thứ hai, quy tắc nghiệp vụ thường cần trả 409 hoặc 422, không phải 400 — và cần thông điệp mà người dùng cuối đọc được.
Đặt sai chỗ dẫn tới ValidationAttribute chứa DbContext — một dạng service locator, và nó chạy cho mọi request kể cả những request sẽ bị từ chối vì lý do khác.
Hai cột của bảng đầu mục là hai chặng khác nhau của một request:
![Sơ đồ ranh giới validation: trước action, request qua model binding (route, body, query) rồi validation ([Required], IValidatableObject). Nếu ModelState không hợp lệ, [ApiController] tự trả 400 Bad Request và action không chạy. Nếu hợp lệ, action gọi service; service truy vấn database (AppDbContext) để kiểm tra quy tắc nghiệp vụ như email trùng hay hạn mức. Vi phạm nghiệp vụ trả 409 hoặc 422 kèm ProblemDetails, không vi phạm thì trả 200, 201 hoặc 204.](/assets/validation-boundary.BMRCgqBL.png)
![Sơ đồ ranh giới validation: trước action, request qua model binding (route, body, query) rồi validation ([Required], IValidatableObject). Nếu ModelState không hợp lệ, [ApiController] tự trả 400 Bad Request và action không chạy. Nếu hợp lệ, action gọi service; service truy vấn database (AppDbContext) để kiểm tra quy tắc nghiệp vụ như email trùng hay hạn mức. Vi phạm nghiệp vụ trả 409 hoặc 422 kèm ProblemDetails, không vi phạm thì trả 200, 201 hoặc 204.](/assets/validation-boundary-dark.CqqwqfzT.png)
8.5.2 — DataAnnotations
public sealed record CreateCustomerRequest
{
[Required(ErrorMessage = "Tên khách hàng là bắt buộc")]
[StringLength(200, MinimumLength = 2)]
public required string Name { get; init; }
[Required]
[EmailAddress(ErrorMessage = "Email không hợp lệ")]
[StringLength(320)]
public required string Email { get; init; }
[RegularExpression(@"^(0|\+84)[0-9]{9}$", ErrorMessage = "Số điện thoại không hợp lệ")]
public string? Phone { get; init; }
[Range(0, 999_999_999)]
public decimal CreditLimit { get; init; }
}Ba lưu ý:
[Required]và nullable reference types. Với NRT bật, thuộc tínhstringkhông nullable đã được coi là bắt buộc.[Required]vẫn nên viết để thông điệp lỗi rõ ràng và để Swagger sinh schema đúng.[StringLength]thay cho[MaxLength]khi bạn cần cả cận dưới.[MaxLength]dành cho EF Core (độ dài cột),[StringLength]dành cho validation.[EmailAddress]rất lỏng — nó chỉ kiểm tra có đúng một dấu@không ở đầu hay cuối.a@bqua được. Muốn chặt hơn thì phải gửi email xác nhận, không phải viết regex phức tạp hơn.
8.5.3 — Quy tắc liên quan nhiều thuộc tính
DataAnnotations nhìn từng thuộc tính một. Với quy tắc liên quan hai thuộc tính, dùng IValidatableObject:
public sealed record CreateCampaignRequest : IValidatableObject
{
public required DateOnly StartDate { get; init; }
public required DateOnly EndDate { get; init; }
public decimal Budget { get; init; }
public bool IsUnlimited { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
{
if (EndDate <= StartDate)
yield return new ValidationResult(
"Ngày kết thúc phải sau ngày bắt đầu", [nameof(EndDate)]);
if (!IsUnlimited && Budget <= 0)
yield return new ValidationResult(
"Chiến dịch có giới hạn phải có ngân sách lớn hơn 0", [nameof(Budget)]);
}
}Validate chạy sau mọi DataAnnotations, và chỉ khi chúng đều qua. Nên bên trong nó bạn không cần kiểm tra null cho các trường đã [Required].
Danh sách tên thuộc tính ở tham số thứ hai quyết định lỗi hiện dưới trường nào trong errors — bỏ trống thì nó thành lỗi cấp toàn bộ model và form phía client không biết tô đỏ ô nào.
8.5.4 — ValidationAttribute tuỳ biến
[AttributeUsage(AttributeTargets.Property)]
public sealed class FutureDateAttribute : ValidationAttribute
{
public int MinimumDaysAhead { get; init; } = 0;
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
if (value is null) return ValidationResult.Success; // để [Required] lo việc này
if (value is not DateTime date)
return new ValidationResult($"{ctx.DisplayName} phải là ngày hợp lệ");
var earliest = DateTime.UtcNow.AddDays(MinimumDaysAhead);
return date > earliest
? ValidationResult.Success
: new ValidationResult(
$"{ctx.DisplayName} phải sau {earliest:dd/MM/yyyy}",
[ctx.MemberName!]);
}
}Ba nguyên tắc:
- Trả
Successchonull. Việc "có bắt buộc không" là của[Required]. Attribute làm cả hai thì không tắt riêng được. - Không truy cập database.
ValidationContextcóGetService()nên về kỹ thuật làm được — nhưng đó chính là service locator, và nó chạy cho mọi request. - Dùng
ctx.MemberNameđể lỗi gắn đúng trường.
8.5.5 — ProblemDetails theo RFC 7807
Với [ApiController], validation thất bại trả sẵn:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"traceId": "00-8fa1c2...-00",
"errors": {
"Email": ["Email không hợp lệ"],
"Name": ["Tên khách hàng là bắt buộc"]
}
}Đừng thay định dạng này bằng định dạng riêng. Nó là chuẩn: thư viện client sinh tự động parse được, traceId nối thẳng với log của bạn, và người tích hợp API không phải đọc tài liệu.
Cần bổ sung thì thêm trường, đừng thay cấu trúc:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = ctx =>
{
ctx.ProblemDetails.Extensions["traceId"] = Activity.Current?.Id ?? ctx.HttpContext.TraceIdentifier;
ctx.ProblemDetails.Extensions["requestPath"] = ctx.HttpContext.Request.Path.Value;
};
});Và dùng đúng ProblemDetails cho lỗi nghiệp vụ:
return Problem(
title: "Không đủ hạn mức tín dụng",
detail: $"Hạn mức còn lại {remaining:N0} VND, đơn hàng cần {required:N0} VND",
statusCode: StatusCodes.Status422UnprocessableEntity);8.5.6 — Khi nào chuyển sang FluentValidation
dotnet add package FluentValidation.AspNetCorepublic sealed class CreateCustomerValidator : AbstractValidator<CreateCustomerRequest>
{
public CreateCustomerValidator(ICountryService countries)
{
RuleFor(x => x.Name).NotEmpty().Length(2, 200);
RuleFor(x => x.Email).NotEmpty().EmailAddress().MaximumLength(320);
RuleFor(x => x.CountryCode)
.Must(countries.IsSupported)
.WithMessage("Quốc gia không được hỗ trợ");
When(x => x.Type == CustomerType.Business, () =>
{
RuleFor(x => x.TaxCode).NotEmpty().Length(10, 13);
});
}
}| DataAnnotations | FluentValidation |
|---|---|
| Có sẵn, không cần thư viện | Thêm một phụ thuộc |
| Quy tắc nằm cạnh thuộc tính | Quy tắc tách khỏi model |
| Khó test riêng | Test được như một lớp bình thường |
| Không tiêm được dependency | Tiêm qua constructor bình thường |
| Quy tắc có điều kiện rất vụng | When / Unless rõ ràng |
Ngưỡng thực dụng: bắt đầu bằng DataAnnotations. Chuyển sang FluentValidation khi bạn thấy mình viết IValidatableObject lần thứ ba, hoặc khi cần dependency trong quy tắc.
Đừng dùng cả hai cho cùng một model — hai nơi định nghĩa quy tắc là hai nơi để quên cập nhật.
8.5.7 — Rà lại code của bạn
Danh sách rà soát validation
- Không có ValidationAttribute nào truy cập database.
- Quy tắc cần dữ liệu đã chuyển xuống tầng nghiệp vụ và trả 409 hoặc 422.
- Custom attribute trả Success cho null, để [Required] lo việc bắt buộc.
- Mọi ValidationResult đều gắn tên thuộc tính để client tô đỏ đúng ô.
- Định dạng lỗi giữ nguyên ProblemDetails, chỉ thêm trường chứ không thay cấu trúc.
- Mọi phản hồi lỗi đều có traceId nối được với log.
- Một model chỉ dùng một hệ validation: DataAnnotations hoặc FluentValidation.
- Thông điệp lỗi viết cho người dùng cuối, không lộ tên cột hay chi tiết nội bộ.
Bài tập áp dụng
Bài 1 — Xác thực đặt sai chỗ
Viết một ValidationAttribute kiểm tra email trùng bằng DbContext lấy từ ValidationContext.GetService(). Gửi request thiếu trường Name và chú ý truy vấn vẫn chạy. Chuyển xuống service và đo lại.
Tiêu chí hoàn thành: bạn nêu được ranh giới giữa xác thực cú pháp và xác thực nghiệp vụ.
Gợi ý và lời giải — Bài 1
Gợi ý. Bộ xác thực của ASP.NET Core chạy tất cả các quy tắc để thu thập đủ danh sách lỗi, chứ không dừng ở lỗi đầu tiên. Vậy quy tắc gọi database cũng sẽ chạy.
Lời giải — bản đặt sai chỗ:
public class EmailChuaTonTaiAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
var db = ctx.GetService<CrmDbContext>()!;
var email = value as string;
return db.Customers.Any(c => c.Email == email) // TRUY VẤN DATABASE
? new ValidationResult("Email đã tồn tại")
: ValidationResult.Success;
}
}
public class CreateCustomerRequest
{
[Required] public string Name { get; init; } = "";
[Required, EmailAddress, EmailChuaTonTai] public string Email { get; init; } = "";
}Gửi request thiếu Name:
curl -X POST localhost:5000/api/customers -H "Content-Type: application/json" \
-d '{"email":"[email protected]"}'Phản hồi 400 đúng như mong đợi. Nhưng log SQL cho thấy:
info: Executed DbCommand (4ms) [...]
SELECT EXISTS (SELECT 1 FROM "Customers" AS c WHERE c."Email" = @p0)Truy vấn vẫn chạy, dù request đã sai ngay từ trường đầu tiên.
Ba vấn đề:
- Lãng phí tài nguyên. Mỗi request sai định dạng vẫn tốn một lần đi tới database. Với một endpoint bị quét tự động, đó là một cách làm quá tải database rất rẻ tiền cho kẻ tấn công.
- Xác thực chạy đồng bộ.
ValidationAttributekhông có bản bất đồng bộ, nên truy vấn chạy đồng bộ và chặn luồng — đúng vấn đề ở bài 6.1. - Có khoảng trống đua. Giữa lúc kiểm tra và lúc ghi, một request khác có thể đã chèn cùng email. Kiểm tra này không bảo đảm được gì.
Ranh giới đúng:
| Loại | Kiểm tra gì | Đặt ở đâu |
|---|---|---|
| Cú pháp | Định dạng, độ dài, khoảng giá trị, bắt buộc | DataAnnotations hoặc FluentValidation, ở biên |
| Nghiệp vụ | Trùng lặp, tồn tại, quyền, quy tắc miền | Tầng nghiệp vụ, bất đồng bộ |
Câu hỏi phân biệt: kiểm tra này có cần truy cập dữ liệu không? Có thì nó là nghiệp vụ.
Bản đúng:
// Ở biên — chỉ cú pháp
public class CreateCustomerRequest
{
[Required, MaxLength(200)] public string Name { get; init; } = "";
[Required, EmailAddress] public string Email { get; init; } = "";
}
// Ở tầng nghiệp vụ — bất đồng bộ, và có lớp bảo vệ thật
public async Task<Result<Customer>> CreateAsync(CreateCustomerRequest req, CancellationToken ct)
{
if (await _repo.EmailExistsAsync(req.Email, ct))
return Result<Customer>.RuleViolation("Email đã tồn tại");
var customer = Customer.Create(req.Name, new Email(req.Email));
try
{
await _repo.AddAsync(customer, ct);
}
catch (DuplicateEmailException) // ràng buộc duy nhất ở database bắt được cuộc đua
{
return Result<Customer>.RuleViolation("Email đã tồn tại");
}
return Result<Customer>.Ok(customer);
}Hai lớp, và cả hai đều cần. Kiểm tra trước cho thông báo đẹp trong trường hợp thường; ràng buộc duy nhất ở database là bảo đảm thật cho trường hợp đua. Chỉ có lớp thứ hai mới thật sự ngăn được dữ liệu trùng, như bài 2.6 đã phân tích.
Dừng sớm khi cú pháp đã sai. Với [ApiController], action không chạy khi model không hợp lệ — nên xác thực nghiệp vụ trong service tự động được bỏ qua. Đó chính là thứ tự đúng: rẻ trước, đắt sau.
Bài 2 — Lỗi không gắn với trường nào
Viết IValidatableObject trả ValidationResult không kèm tên thuộc tính, xem JSON trả về và giải thích vì sao form phía client không tô đỏ được ô nào.
Tiêu chí hoàn thành: bạn so sánh được hai cấu trúc JSON và nêu được hệ quả với giao diện.
Gợi ý và lời giải — Bài 2
Gợi ý. ValidationResult có hai hàm khởi tạo: một chỉ nhận thông báo, một nhận thêm danh sách tên thuộc tính. Client dùng danh sách đó để biết tô đỏ ô nào.
Lời giải — bản thiếu tên trường:
public class CreateOrderRequest : IValidatableObject
{
public DateTime NgayBatDau { get; init; }
public DateTime NgayKetThuc { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
{
if (NgayKetThuc <= NgayBatDau)
yield return new ValidationResult("Ngày kết thúc phải sau ngày bắt đầu");
// thiếu tham số thứ hai
}
}JSON trả về:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"": ["Ngày kết thúc phải sau ngày bắt đầu"]
}
}Khoá là chuỗi rỗng. Client nhận được thông báo nhưng không biết nó thuộc về trường nào, nên không tô đỏ được ô nào — chỉ hiển thị được ở một chỗ chung chung phía trên form.
Bản có tên trường:
public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
{
if (NgayKetThuc <= NgayBatDau)
yield return new ValidationResult(
"Ngày kết thúc phải sau ngày bắt đầu",
[nameof(NgayKetThuc)]); // <- danh sách tên thuộc tính
}{
"errors": {
"NgayKetThuc": ["Ngày kết thúc phải sau ngày bắt đầu"]
}
}Giờ client tô đỏ được đúng ô NgayKetThuc và hiển thị thông báo ngay bên dưới nó.
Gắn lỗi vào nhiều trường khi quy tắc liên quan tới cả hai:
yield return new ValidationResult(
"Khoảng thời gian không hợp lệ",
[nameof(NgayBatDau), nameof(NgayKetThuc)]);Cả hai ô đều được tô đỏ — đúng với thực tế là lỗi nằm ở mối quan hệ giữa chúng.
Dùng nameof chứ không dùng chuỗi. nameof(NgayKetThuc) được trình biên dịch kiểm tra, nên đổi tên thuộc tính sẽ tự động cập nhật. Viết "NgayKetThuc" thì đổi tên xong là lỗi im lặng — client vẫn nhận được khoá cũ và không tô đỏ được gì.
Chú ý về quy ước đặt tên trong JSON. Mặc định ASP.NET Core dùng camelCase cho JSON, nhưng khoá trong errors giữ nguyên tên thuộc tính C#. Nếu client mong đợi camelCase để khớp với các trường khác, hãy cấu hình:
builder.Services.Configure<ApiBehaviorOptions>(o =>
{
o.InvalidModelStateResponseFactory = ctx =>
{
var loi = ctx.ModelState
.Where(e => e.Value?.Errors.Count > 0)
.ToDictionary(
e => JsonNamingPolicy.CamelCase.ConvertName(e.Key),
e => e.Value!.Errors.Select(x => x.ErrorMessage).ToArray());
return new BadRequestObjectResult(new ValidationProblemDetails(loi));
};
});Sự không nhất quán giữa email trong request và Email trong lỗi là chi tiết nhỏ nhưng gây khó chịu thật cho người viết client, và nó chỉ lộ ra khi đã tích hợp.
Với đối tượng lồng nhau, khoá dùng dấu chấm và chỉ số:
{
"errors": {
"Lines[0].SoLuong": ["Số lượng phải lớn hơn 0"],
"DiaChi.PhuongXa": ["Bắt buộc"]
}
}Client cần hiểu được định dạng này để tô đúng ô trong danh sách động — hãy ghi rõ nó trong tài liệu API.
Bài 3 — So sánh IValidatableObject với FluentValidation
Cài cùng một bộ quy tắc có điều kiện — khách doanh nghiệp bắt buộc mã số thuế — bằng cả hai cách. So sánh số dòng và viết unit test cho mỗi bên.
Tiêu chí hoàn thành: bạn nêu được tiêu chí chuyển sang FluentValidation, thay vì kết luận cái nào "tốt hơn".
Gợi ý và lời giải — Bài 3
Gợi ý. Hãy chú ý vào khả năng kiểm thử và vào chỗ quy tắc sống, không chỉ vào số dòng.
Lời giải — IValidatableObject:
public class CreateCustomerRequest : IValidatableObject
{
[Required, MaxLength(200)] public string Name { get; init; } = "";
[Required, EmailAddress] public string Email { get; init; } = "";
public CustomerType Type { get; init; }
public string? TaxCode { get; init; }
public string? LegalRepresentative { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
{
if (Type == CustomerType.Business)
{
if (string.IsNullOrWhiteSpace(TaxCode))
yield return new ValidationResult("Khách doanh nghiệp bắt buộc có mã số thuế",
[nameof(TaxCode)]);
else if (TaxCode.Length is not (10 or 13))
yield return new ValidationResult("Mã số thuế phải có 10 hoặc 13 chữ số",
[nameof(TaxCode)]);
if (string.IsNullOrWhiteSpace(LegalRepresentative))
yield return new ValidationResult("Khách doanh nghiệp bắt buộc có người đại diện",
[nameof(LegalRepresentative)]);
}
}
}FluentValidation:
public class CreateCustomerValidator : AbstractValidator<CreateCustomerRequest>
{
public CreateCustomerValidator()
{
RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
RuleFor(x => x.Email).NotEmpty().EmailAddress();
When(x => x.Type == CustomerType.Business, () =>
{
RuleFor(x => x.TaxCode)
.NotEmpty().WithMessage("Khách doanh nghiệp bắt buộc có mã số thuế")
.Must(m => m!.Length is 10 or 13).WithMessage("Mã số thuế phải có 10 hoặc 13 chữ số");
RuleFor(x => x.LegalRepresentative)
.NotEmpty().WithMessage("Khách doanh nghiệp bắt buộc có người đại diện");
});
}
}So sánh:
IValidatableObject | FluentValidation | |
|---|---|---|
| Số dòng | ~18 | ~16 |
| Quy tắc nằm ở đâu | Trong chính DTO | Lớp riêng |
| Kiểm thử | Phải dựng ValidationContext | validator.TestValidate(req) |
| Quy tắc bất đồng bộ | Không hỗ trợ | MustAsync |
| Tái sử dụng quy tắc | Khó | SetValidator, kế thừa |
| Phụ thuộc thêm | Không | Một gói NuGet |
Kiểm thử — khác biệt lớn nhất:
// IValidatableObject — phải dựng ngữ cảnh
[Fact]
public void BusinessWithoutTaxCode_ReportsError()
{
var req = new CreateCustomerRequest { Type = CustomerType.Business };
var ctx = new ValidationContext(req);
var results = req.Validate(ctx).ToList();
Assert.Contains(results, r => r.MemberNames.Contains("TaxCode"));
}
// FluentValidation — có API kiểm thử riêng
[Fact]
public void BusinessWithoutTaxCode_ReportsError()
{
var result = new CreateCustomerValidator()
.TestValidate(new CreateCustomerRequest { Type = CustomerType.Business });
result.ShouldHaveValidationErrorFor(x => x.TaxCode)
.WithErrorMessage("Khách doanh nghiệp bắt buộc có mã số thuế");
}Tiêu chí chuyển sang FluentValidation — bốn dấu hiệu:
- Quy tắc có điều kiện phức tạp. Khi
Validatebắt đầu cóiflồng nhau,Whencủa FluentValidation đọc dễ hơn nhiều. - Cần quy tắc bất đồng bộ.
IValidatableObjectkhông hỗ trợ, nên bạn buộc phải chặn luồng như bài 1 đã chỉ ra. - Muốn tách quy tắc khỏi DTO. DTO là hợp đồng dữ liệu; quy tắc xác thực có thể khác nhau giữa các ngữ cảnh — tạo mới và cập nhật thường có quy tắc khác nhau trên cùng một DTO.
- Cần tái sử dụng. Một
AddressValidatordùng lại được ở mọi DTO có địa chỉ.
Khi nào DataAnnotations là đủ. Với API nội bộ và quy tắc đơn giản — bắt buộc, độ dài, khoảng giá trị, định dạng — DataAnnotations gọn hơn và không cần thêm phụ thuộc. Đừng cài FluentValidation chỉ vì nó "chuyên nghiệp hơn"; hãy cài khi gặp một trong bốn dấu hiệu trên.
Dùng cả hai cũng được. DataAnnotations cho quy tắc đơn giản ngay trên DTO, FluentValidation cho quy tắc phức tạp. Chúng không loại trừ nhau, và ASP.NET Core chạy cả hai.
Tự kiểm tra
Ranh giới giữa validation đầu vào và quy tắc nghiệp vụ là gì?
Validation đầu vào chỉ cần chính request đó: trường bắt buộc, định dạng email, khoảng giá trị, độ dài. Quy tắc nghiệp vụ cần truy cập dữ liệu: email đã tồn tại chưa, còn hạn mức không, kho còn hàng không. Cái sau thường trả 409 hoặc 422 chứ không phải 400.
Vì sao không nên truy cập database trong ValidationAttribute?
Vì validation chạy trước action nên không có CancellationToken, không có transaction, và truy vấn ở đó là điểm nghẽn không ai nhìn thấy. Nó cũng chạy cho mọi request kể cả những request sẽ bị từ chối vì lý do khác. Lấy DbContext từ ValidationContext.GetService còn là một dạng service locator.
IValidatableObject dùng khi nào?
Khi quy tắc liên quan nhiều thuộc tính, ví dụ ngày kết thúc phải sau ngày bắt đầu, vì DataAnnotations chỉ nhìn từng thuộc tính một. Phương thức Validate chạy sau mọi DataAnnotations và chỉ khi chúng đều qua, nên bên trong không cần kiểm tra null cho trường đã Required.
Custom ValidationAttribute cần theo nguyên tắc nào?
Ba nguyên tắc. Trả Success cho null để việc bắt buộc thuộc về Required, nếu không thì không tắt riêng được. Không truy cập database. Và dùng ctx.MemberName để lỗi gắn đúng trường, nếu không nó thành lỗi cấp model và client không biết tô đỏ ô nào.
Có nên thay ProblemDetails bằng định dạng riêng không?
Không. Nó là chuẩn RFC 7807 nên thư viện client sinh tự động parse được, traceId nối thẳng với log, và người tích hợp API không phải đọc tài liệu. Cần bổ sung thì thêm trường vào Extensions chứ đừng thay cấu trúc.
Khi nào chuyển từ DataAnnotations sang FluentValidation?
Khi bạn viết IValidatableObject lần thứ ba, hoặc khi quy tắc cần dependency vì DataAnnotations không tiêm được. FluentValidation còn tách quy tắc khỏi model nên test được như lớp bình thường, và có When/Unless cho quy tắc có điều kiện. Đừng dùng cả hai cho cùng một model.
Kết luận
Ba điều đáng nhớ nhất:
- Validation đầu vào chỉ nhìn vào request. Cần dữ liệu là nghiệp vụ, và nghiệp vụ trả 409/422.
- Giữ
ProblemDetails— thêm trường, đừng thay cấu trúc. - DataAnnotations trước, FluentValidation khi quy tắc cần dependency — nhưng không dùng cả hai.
Tham khảo
- Model validation in ASP.NET Core MVC
- Handle errors in ASP.NET Core web APIs
- System.ComponentModel.DataAnnotations namespace
- RFC 9457 — Problem Details for HTTP APIs
Điều hướng
- Bài trước: 8.4 — Controllers và Actions
- Bài tiếp theo: 8.6 — Configuration System
- Về module: Trang mục lục