Skip to content

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 ValidationAttribute dầ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, IValidatableObject cho 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: ProblemDetails theo 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 ValidationAttribute tuỳ biến đúng cách.
  • Tuỳ biến ProblemDetails mà 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àoThuộc về nghiệp vụ
Trường bắt buộc có mặtEmail đã tồn tại trong hệ thống chưa
Email đúng định dạngKhá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 đầuKho 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.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.

File gốc: nền sáng · nền tối

8.5.2 — DataAnnotations ​

csharp
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ính string khô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@b qua đượ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:

csharp
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 ​

csharp
[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:

  1. Trả Success cho null. 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.
  2. Không truy cập database. ValidationContext có 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.
  3. 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:

json
{
  "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:

csharp
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ụ:

csharp
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 ​

bash
dotnet add package FluentValidation.AspNetCore
csharp
public 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);
        });
    }
}
DataAnnotationsFluentValidation
Có sẵn, không cần thư việnThêm một phụ thuộc
Quy tắc nằm cạnh thuộc tínhQuy tắc tách khỏi model
Khó test riêngTest được như một lớp bình thường
Không tiêm được dependencyTiêm qua constructor bình thường
Quy tắc có điều kiện rất vụngWhen / 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ỗ:

csharp
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:

bash
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:

text
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 đề:

  1. 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.
  2. Xác thực chạy đồng bộ. ValidationAttribute khô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.
  3. 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ạiKiểm tra gìĐặt ở đâu
Cú phápĐịnh dạng, độ dài, khoảng giá trị, bắt buộcDataAnnotations hoặc FluentValidation, ở biên
Nghiệp vụTrùng lặp, tồn tại, quyền, quy tắc miềnTầ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:

csharp
// Ở 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:

csharp
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ề:

json
{
  "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:

csharp
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
}
json
{
  "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:

csharp
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:

csharp
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ố:

json
{
  "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:

csharp
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:

csharp
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:

IValidatableObjectFluentValidation
Số dòng~18~16
Quy tắc nằm ở đâuTrong chính DTOLớp riêng
Kiểm thửPhải dựng ValidationContextvalidator.TestValidate(req)
Quy tắc bất đồng bộKhông hỗ trợMustAsync
Tái sử dụng quy tắcKhóSetValidator, kế thừa
Phụ thuộc thêmKhôngMột gói NuGet

Kiểm thử — khác biệt lớn nhất:

csharp
// 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:

  1. Quy tắc có điều kiện phức tạp. Khi Validate bắt đầu có if lồng nhau, When của FluentValidation đọc dễ hơn nhiều.
  2. Cần quy tắc bất đồng bộ. IValidatableObject không hỗ trợ, nên bạn buộc phải chặn luồng như bài 1 đã chỉ ra.
  3. 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.
  4. Cần tái sử dụng. Một AddressValidator dù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:

  1. 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.
  2. Giữ ProblemDetails — thêm trường, đừng thay cấu trúc.
  3. DataAnnotations trước, FluentValidation khi quy tắc cần dependency — nhưng không dùng cả hai.

Tham khảo ​

Điều hướng ​

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