Sử Dụng FluentValidation Để Kiểm Soát Đầu Vào Hiệu Quả trên Chặng Đường Lộ Trình .NET

Nhập Môn: Tại Sao Validation Lại Quan Trọng?

Chào mừng các bạn quay trở lại với series Lộ Trình ASP.NET Core! Trên con đường xây dựng các ứng dụng web mạnh mẽ và tin cậy với .NET, việc xử lý và validate dữ liệu đầu vào là một trong những khía cạnh cơ bản nhưng lại cực kỳ quan trọng. Dữ liệu đến từ người dùng, từ các hệ thống khác thông qua RESTful API, hay từ bất kỳ nguồn bên ngoài nào khác đều tiềm ẩn rủi ro. Nếu không được kiểm tra cẩn thận, dữ liệu sai lệch, thiếu sót, hoặc thậm chí độc hại có thể dẫn đến lỗi ứng dụng, hỏng dữ liệu trong cơ sở dữ liệu, hoặc các lỗ hổng bảo mật nghiêm trọng.

Validation (kiểm tra tính hợp lệ) đảm bảo rằng dữ liệu bạn nhận được đáp ứng các yêu cầu nghiệp vụ và kỹ thuật trước khi nó được xử lý tiếp bởi ứng dụng. Điều này không chỉ giúp ngăn chặn lỗi mà còn cải thiện trải nghiệm người dùng bằng cách cung cấp phản hồi rõ ràng khi họ nhập sai thông tin.

Trong thế giới .NET, đặc biệt là với ASP.NET Core, có nhiều cách để thực hiện validation. Mặc định, .NET cung cấp DataAnnotations, một cách tiếp cận dựa trên attribute khá đơn giản và tiện lợi cho các trường hợp cơ bản. Tuy nhiên, khi ứng dụng của bạn phát triển với logic validation phức tạp hơn, cấu trúc dữ liệu lồng nhau, hoặc các yêu cầu validation có điều kiện, DataAnnotations bắt đầu bộc lộ những hạn chế.

Đây là lúc các thư viện validation mạnh mẽ hơn xuất hiện, và FluentValidation là một trong những cái tên nổi bật nhất trong cộng đồng .NET. Bài viết này sẽ đi sâu vào cách sử dụng FluentValidation để nâng tầm việc kiểm soát dữ liệu đầu vào trong các ứng dụng .NET của bạn.

DataAnnotations Có Đủ Mạnh Mẽ? Giới Thiệu FluentValidation

Như đã đề cập, DataAnnotations là cách phổ biến để validate trong ASP.NET Core bằng cách sử dụng các attribute như [Required], [StringLength], [Range], [EmailAddress], v.v., gắn trực tiếp lên các thuộc tính của model (DTO – Data Transfer Object, hoặc ViewModel).


public class CreateProductRequest
{
    [Required(ErrorMessage = "Tên sản phẩm không được để trống.")]
    [StringLength(100, MinimumLength = 3, ErrorMessage = "Tên sản phẩm phải dài từ 3 đến 100 ký tự.")]
    public string Name { get; set; }

    [Range(0.01, double.MaxValue, ErrorMessage = "Giá sản phẩm phải là số dương.")]
    public decimal Price { get; set; }

    [EmailAddress(ErrorMessage = "Định dạng email không hợp lệ.")]
    public string ManufacturerEmail { get; set; }
}

Với những trường hợp đơn giản, cách này hoạt động tốt và tích hợp mượt mà với cơ chế Model Binding của ASP.NET Core. Tuy nhiên, bạn sẽ gặp khó khăn khi:

  • Logic validation phức tạp hơn (ví dụ: kiểm tra sự phụ thuộc giữa các trường, validation dựa trên giá trị của trường khác).
  • Cần validate cùng một model theo các rule khác nhau tùy ngữ cảnh (ví dụ: rule khi tạo mới khác với rule khi cập nhật).
  • Muốn tách biệt hoàn toàn logic validation ra khỏi định nghĩa model để giữ cho model “sạch” hơn và tuân thủ các nguyên tắc thiết kế như Kiến trúc Sạch (Clean Architecture) hoặc Thiết Kế Hướng Miền (DDD).
  • Viết unit test cho logic validation trở nên khó khăn vì nó gắn liền với model.

FluentValidation giải quyết những vấn đề này bằng cách áp dụng nguyên tắc “validation out of model”. Thay vì dùng attribute, bạn định nghĩa các rule validation trong các lớp riêng biệt (validator classes), sử dụng một API fluent (chuỗi các phương thức gọi liên tiếp) rất dễ đọc và viết.

Hãy xem bảng so sánh ngắn gọn:

So Sánh: DataAnnotations và FluentValidation

Tính năng DataAnnotations FluentValidation
Vị trí Logic Validation Gắn trực tiếp lên model (DTO/Entity) Lớp validator riêng biệt, tách khỏi model
Cách Định Nghĩa Rule Sử dụng Attribute Sử dụng Fluent API (phương thức)
Độ linh hoạt cho rule phức tạp/điều kiện Hạn chế, cần Custom Validation Attribute Rất linh hoạt, hỗ trợ rule dựa trên điều kiện (When, Unless), Rule Sets
Khả năng Kiểm Thử (Testability) Khó kiểm thử độc lập logic nghiệp vụ Validator là POCO (Plain Old CLR Object), dễ dàng kiểm thử độc lập
Đọc hiểu Tốt cho rule đơn giản Tốt cho cả rule đơn giản và phức tạp, dễ theo dõi luồng logic
Áp dụng cho các kịch bản khác nhau (ví dụ: Create vs Update) Khó khăn, phải tạo nhiều model hoặc dùng Grouping/ValidationContext (ít phổ biến) Sử dụng Rule Sets hiệu quả và rõ ràng
Hỗ trợ Dependency Injection Hạn chế trong validator attribute Tích hợp rất tốt với DI, cho phép inject service vào validator (ví dụ: kiểm tra email đã tồn tại trong DB)

Rõ ràng, FluentValidation mang lại sự tách biệt, linh hoạt và khả năng kiểm thử vượt trội, biến nó thành lựa chọn hàng đầu cho các ứng dụng .NET có quy mô và độ phức tạp cao.

Bắt Đầu Với FluentValidation

Để sử dụng FluentValidation trong dự án ASP.NET Core của bạn, bạn cần cài đặt package NuGet:


dotnet add package FluentValidation.AspNetCore

Package FluentValidation.AspNetCore bao gồm cả thư viện FluentValidation lõi và các tích hợp cần thiết để nó hoạt động mượt mà với ASP.NET Core MVC/API. Nó tự động thay thế hệ thống validation dựa trên DataAnnotations mặc định.

Tiếp theo, bạn cần đăng ký FluentValidation vào hệ thống Dependency Injection (DI) của ứng dụng. Thường làm trong file Program.cs (đối với .NET 6+) hoặc Startup.cs (đối với các phiên bản cũ hơn).


// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// Đăng ký FluentValidation
builder.Services.AddFluentValidationAutoValidation() // Bật validation tự động khi binding model
                .AddFluentValidationClientsideAdapters(); // Tùy chọn: hỗ trợ validation client-side nếu dùng Views/Razor Pages

// Đăng ký các validators. Phương thức này tự động quét các assembly để tìm các class kế thừa AbstractValidator<T>
// Thay "AssemblyContaining<Program>" bằng một Type bất kỳ trong assembly chứa các validators của bạn
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

var app = builder.Build();

// ... Các cấu hình middleware khác

app.MapControllers(); // Đảm bảo MapControllers được gọi SAU khi đăng ký DI và cấu hình middleware

app.Run();

AddFluentValidationAutoValidation() là phương thức quan trọng nhất. Nó sẽ thêm các service cần thiết và cấu hình ASP.NET Core để tự động chạy validator tương ứng khi một model được bind từ HTTP request.

AddValidatorsFromAssemblyContaining<T>() là cách tiện lợi để đăng ký tất cả các validator trong một assembly vào DI mà không cần đăng ký thủ công từng cái một. Bạn chỉ cần chọn một class bất kỳ nằm trong assembly chứa các validator của mình (ví dụ: Program class nếu validator cùng assembly, hoặc một class khởi tạo trong lớp Application nếu theo Kiến trúc Sạch).

Định Nghĩa Validator Đầu Tiên Của Bạn

Một validator trong FluentValidation là một class kế thừa từ AbstractValidator<T>, trong đó T là kiểu dữ liệu (model, DTO, command…) mà bạn muốn validate.

Hãy lấy lại ví dụ CreateProductRequest và định nghĩa validator cho nó:


// File CreateProductRequest.cs (hoặc CreateProductRequestValidator.cs)
public class CreateProductRequest
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string ManufacturerEmail { get; set; }
}

// File CreateProductRequestValidator.cs
using FluentValidation;

public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Tên sản phẩm không được để trống.")
            .Length(3, 100).WithMessage("Tên sản phẩm phải dài từ 3 đến 100 ký tự.");

        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Giá sản phẩm phải là số dương.");

        RuleFor(x => x.ManufacturerEmail)
            .NotEmpty().WithMessage("Email nhà sản xuất không được để trống.")
            .EmailAddress().WithMessage("Địa chỉ email nhà sản xuất không hợp lệ.");
    }
}

Trong constructor của CreateProductRequestValidator, chúng ta sử dụng phương thức RuleFor(x => x.PropertyName) để bắt đầu định nghĩa rule cho từng thuộc tính. Sau đó, chuỗi các phương thức như NotEmpty(), Length(), GreaterThan() được gọi liên tiếp (fluent API) để thêm các rule cụ thể. Phương thức WithMessage() cho phép tùy chỉnh thông báo lỗi.

Khi ASP.NET Core nhận một request với body chứa dữ liệu kiểu CreateProductRequest, cơ chế Model Binding sẽ tạo đối tượng này. Sau đó, nhờ FluentValidation AutoValidation, nó sẽ tìm trong DI service IValidator<CreateProductRequest> (chính là instance của CreateProductRequestValidator mà chúng ta đã đăng ký) và chạy validation. Nếu có lỗi, chúng sẽ được thêm vào ModelState.

Các Rule Validation Phổ Biến

FluentValidation cung cấp một bộ sưu tập phong phú các rule built-in, giúp bạn xử lý hầu hết các kịch bản validation phổ biến. Dưới đây là một số rule thường dùng:

  • NotEmpty(): Áp dụng cho string, collection, kiểm tra không rỗng hoặc chỉ chứa khoảng trắng (cho string).
  • NotNull(): Áp dụng cho mọi kiểu reference type hoặc nullable value type, kiểm tra không null.
  • Null(): Kiểm tra giá trị là null.
  • Empty(): Áp dụng cho string, collection, kiểm tra rỗng (không có ký tự nào cho string, không có phần tử nào cho collection).
  • Length(min, max): Áp dụng cho string, kiểm tra độ dài nằm trong khoảng [min, max].
  • MinimumLength(min), MaximumLength(max): Kiểm tra độ dài tối thiểu/tối đa.
  • EmailAddress(): Kiểm tra định dạng email cơ bản.
  • InclusiveBetween(min, max): Kiểm tra giá trị nằm trong khoảng đóng [min, max].
  • ExclusiveBetween(min, max): Kiểm tra giá trị nằm trong khoảng mở (min, max).
  • GreaterThan(value), LessThan(value): Kiểm tra giá trị lớn hơn/nhỏ hơn một giá trị cụ thể.
  • GreaterThanOrEqualTo(value), LessThanOrEqualTo(value): Kiểm tra giá trị lớn hơn hoặc bằng/nhỏ hơn hoặc bằng một giá trị cụ thể.
  • Matches(regex): Kiểm tra chuỗi theo biểu thức chính quy.
  • IsInEnum(): Kiểm tra xem giá trị số có tồn tại trong Enum không.
  • Must(predicate): Định nghĩa rule tùy chỉnh bằng một hàm predicate (trả về boolean).
  • MustAsync(predicate): Định nghĩa rule tùy chỉnh bằng một hàm predicate bất đồng bộ.

Các rule có thể được kết hợp lại với nhau:


RuleFor(x => x.PostalCode)
    .NotEmpty().WithMessage("Mã bưu điện không được để trống.")
    .Matches(@"^\d{5}$").WithMessage("Mã bưu điện phải gồm 5 chữ số.");

RuleFor(x => x.DiscountRate)
    .InclusiveBetween(0, 0.5m).WithMessage("Tỷ lệ giảm giá phải nằm trong khoảng từ 0 đến 0.5.");

Thứ tự của các rule trong chuỗi quan trọng. FluentValidation sẽ dừng kiểm tra một thuộc tính ngay khi gặp rule đầu tiên bị vi phạm, trừ khi bạn cấu hình khác đi (ví dụ: sử dụng Cascade(CascadeMode.Continue)).


// Sẽ kiểm tra tất cả các rule cho Name dù rule đầu tiên fail
RuleFor(x => x.Name)
    .Cascade(CascadeMode.Continue)
    .NotEmpty().WithMessage("Tên không được để trống.")
    .MinimumLength(3).WithMessage("Tên phải dài ít nhất 3 ký tự.")
    .Matches(@"^[A-Za-z\s]+$").WithMessage("Tên chỉ chứa chữ cái và khoảng trắng.");

Các Khái Niệm Nâng Cao Hơn

FluentValidation không chỉ dừng lại ở các rule cơ bản. Thư viện này cung cấp nhiều tính năng mạnh mẽ để xử lý các kịch bản phức tạp hơn.

Rule Sets (Tập Rule)

Đôi khi, bạn cần validate cùng một kiểu dữ liệu nhưng theo các rule khác nhau tùy thuộc vào ngữ cảnh. Ví dụ: khi tạo người dùng mới, mật khẩu là bắt buộc và cần đủ phức tạp; khi cập nhật hồ sơ người dùng, mật khẩu có thể là tùy chọn. Rule Sets giúp bạn nhóm các rule lại.


public class UserValidator : AbstractValidator<User>
{
    public UserValidator()
    {
        // Rule mặc định (áp dụng cho cả tạo và cập nhật nếu không chỉ định Rule Set)
        RuleFor(user => user.Email)
            .NotEmpty()
            .EmailAddress();

        // Rule Set cho kịch bản tạo mới
        RuleSet("Create", () =>
        {
            RuleFor(user => user.Password)
                .NotEmpty()
                .MinimumLength(8)
                .WithMessage("Mật khẩu phải có ít nhất 8 ký tự.");
            // ... các rule khác chỉ cho tạo mới
        });

        // Rule Set cho kịch bản cập nhật
        RuleSet("Update", () =>
        {
            RuleFor(user => user.Bio)
                .MaximumLength(500);
            // ... các rule khác chỉ cho cập nhật
        });
    }
}

Khi sử dụng Rule Sets, bạn cần validate thủ công và chỉ định Rule Set nào sẽ chạy. Trong ASP.NET Core API/MVC, điều này yêu cầu một chút code custom nếu bạn muốn sử dụng Rule Sets với auto validation.


// Trong Controller hoặc Handler (nếu dùng CQRS như MediatR)
[HttpPost("create-user")]
public async Task<IActionResult> CreateUser([FromBody] CreateUserCommand command,
                                            [FromServices] IValidator<CreateUserCommand> validator)
{
    // Validate chỉ với Rule Set "Create"
    var validationResult = await validator.ValidateAsync(command, options => options.IncludeRuleSets("Create"));

    if (!validationResult.IsValid)
    {
        // Xử lý lỗi, ví dụ: trả về BadRequest
        return BadRequest(validationResult.ToDictionary());
    }

    // ... Xử lý command khi hợp lệ
    return Ok();
}

Conditional Rules (Rule Có Điều Kiện)

Sử dụng When hoặc Unless để chỉ áp dụng một rule nếu một điều kiện nào đó đúng hoặc sai. Điều kiện này có thể dựa trên giá trị của chính object đang được validate.


public class OrderRequestValidator : AbstractValidator<OrderRequest>
{
    public OrderRequestValidator()
    {
        // ... các rules khác

        // Chỉ yêu cầu ShipDate nếu DeliveryMethod là Shipping
        RuleFor(x => x.ShipDate)
            .NotEmpty().WithMessage("Ngày giao hàng không được để trống nếu chọn hình thức Ship.")
            .When(x => x.DeliveryMethod == DeliveryMethod.Shipping);

        // Chỉ yêu cầu PickupTime nếu DeliveryMethod là Pickup
        RuleFor(x => x.PickupTime)
            .NotEmpty().WithMessage("Giờ tự nhận không được để trống nếu chọn hình thức Tự nhận.")
            .When(x => x.DeliveryMethod == DeliveryMethod.Pickup);

        // Chỉ yêu cầu CorporateId nếu CustomerType là Corporate
         RuleFor(x => x.CorporateId)
             .NotEmpty().WithMessage("Mã công ty không được để trống cho khách hàng Corporate.")
             .When(x => x.CustomerType == CustomerType.Corporate);
    }
}

public enum DeliveryMethod { Shipping, Pickup }
public enum CustomerType { Individual, Corporate }
public class OrderRequest
{
    public DeliveryMethod DeliveryMethod { get; set; }
    public DateTime? ShipDate { get; set; }
    public TimeSpan? PickupTime { get; set; }
    public CustomerType CustomerType { get; set; }
    public string CorporateId { get; set; }
    // ...
}

Bạn cũng có thể sử dụng Unless để định nghĩa rule áp dụng khi điều kiện SAI.

Custom Validators với MustMustAsync

Khi các rule built-in không đủ, bạn có thể dùng Must để viết logic validation tùy chỉnh. Đối số của Must là một hàm predicate nhận vào object đang validate (hoặc giá trị của thuộc tính đang validate) và trả về bool.


RuleFor(x => x.Password)
    .Must(BeAValidPassword).WithMessage("Mật khẩu phải chứa ít nhất 8 ký tự, bao gồm chữ hoa, chữ thường và số.");

private bool BeAValidPassword(string password)
{
    if (string.IsNullOrWhiteSpace(password)) return false;

    var hasMinimumLength = password.Length >= 8;
    var hasUpper = password.Any(char.IsUpper);
    var hasLower = password.Any(char.IsLower);
    var hasDigit = password.Any(char.IsDigit);

    return hasMinimumLength && hasUpper && hasLower && hasDigit;
}

Đối với các rule cần gọi bất đồng bộ (ví dụ: kiểm tra xem username hoặc email đã tồn tại trong cơ sở dữ liệu – liên quan đến Entity Framework Core hoặc Dapper), bạn sử dụng MustAsync. Điều này yêu cầu validator của bạn phải inject các service cần thiết thông qua constructor (vì validator được quản lý bởi DI).


public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
    private readonly IUserRepository _userRepository; // Inject repository hoặc service

    public CreateUserRequestValidator(IUserRepository userRepository)
    {
        _userRepository = userRepository;

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress()
            .MustAsync(async (email, cancellation) =>
            {
                // Sử dụng service đã inject để kiểm tra trong DB
                var exists = await _userRepository.EmailExistsAsync(email, cancellation);
                return !exists; // Trả về true nếu email chưa tồn tại
            }).WithMessage("Địa chỉ email đã tồn tại.");
    }
}

public class CreateUserRequest
{
    public string Email { get; set; }
    // ...
}

Việc có thể inject dependencies vào validator là một lợi thế lớn của FluentValidation so với DataAnnotations.

Validation Đối Tượng Lồng Nhau và Collection

Trong các ứng dụng thực tế, request DTO hoặc command object thường chứa các đối tượng con hoặc collection các đối tượng con. FluentValidation hỗ trợ validation đệ quy cho các cấu trúc này.

Đối Tượng Lồng Nhau (Nested Objects)

Nếu model của bạn có thuộc tính là một object khác và bạn muốn validate object con đó, bạn sử dụng SetValidator.


// Models
public class CreateOrderRequest
{
    public AddressDto ShippingAddress { get; set; }
    // ... other properties
}

public class AddressDto
{
    public string Street { get; set; }
    public string City { get; set; }
    public string PostalCode { get; set; }
}

// Validators
public class AddressDtoValidator : AbstractValidator<AddressDto>
{
    public AddressDtoValidator()
    {
        RuleFor(x => x.Street).NotEmpty().WithMessage("Địa chỉ đường không được để trống.");
        RuleFor(x => x.City).NotEmpty().WithMessage("Thành phố không được để trống.");
        RuleFor(x => x.PostalCode).NotEmpty().Matches(@"^\d{5}$").WithMessage("Mã bưu điện không hợp lệ.");
    }
}

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    // Inject validator con nếu sử dụng AddValidatorsFromAssemblyContaining
    public CreateOrderRequestValidator(IValidator<AddressDto> addressValidator)
    {
        // ... other order rules

        // Validate thuộc tính ShippingAddress sử dụng AddressDtoValidator
        RuleFor(x => x.ShippingAddress)
            .NotNull().WithMessage("Địa chỉ giao hàng không được để trống.") // Kiểm tra object ShippingAddress không null trước
            .SetValidator(addressValidator); // Áp dụng AddressDtoValidator
    }
}

Nhờ cơ chế DI của FluentValidation khi bạn dùng AddValidatorsFromAssemblyContaining, bạn chỉ cần inject IValidator<AddressDto> vào constructor của CreateOrderRequestValidator, và FluentValidation sẽ tự động cung cấp instance của AddressDtoValidator.

Collections

Sử dụng RuleForEach để áp dụng validator cho từng phần tử trong một collection (ví dụ: List<T>, IEnumerable<T>).


// Models
public class CreateOrderRequest
{
    public List<OrderItemDto> Items { get; set; }
    // ...
}

public class OrderItemDto
{
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

// Validators
public class OrderItemDtoValidator : AbstractValidator<OrderItemDto>
{
    public OrderItemDtoValidator()
    {
        RuleFor(x => x.ProductName).NotEmpty().WithMessage("Tên sản phẩm trong mục đơn hàng không được để trống.");
        RuleFor(x => x.Quantity).GreaterThan(0).WithMessage("Số lượng sản phẩm phải lớn hơn 0.");
        RuleFor(x => x.Price).GreaterThan(0).WithMessage("Giá sản phẩm phải lớn hơn 0.");
    }
}

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    // Inject validator con nếu sử dụng AddValidatorsFromAssemblyContaining
    public CreateOrderRequestValidator(IValidator<OrderItemDto> itemValidator)
    {
        // ... other order rules

        // Kiểm tra collection không rỗng trước
        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Đơn hàng phải có ít nhất một sản phẩm.");

        // Áp dụng OrderItemDtoValidator cho từng phần tử trong collection Items
        RuleForEach(x => x.Items).SetValidator(itemValidator);
    }
}

Tương tự với đối tượng lồng nhau, bạn chỉ cần inject validator cho kiểu phần tử trong collection (IValidator<OrderItemDto>) vào validator của collection chứa nó.

Validate Thủ Công

Trong hầu hết các kịch bản ASP.NET Core API với AddFluentValidationAutoValidation, validation diễn ra tự động trước khi action method của controller được gọi. Tuy nhiên, có những trường hợp bạn cần validate một đối tượng thủ công:

  • Validate một object trong service layer hoặc business logic, không phải object được bind trực tiếp từ request.
  • Validate trong các loại ứng dụng khác không phải web (console app, background service).
  • Khi sử dụng các kiến trúc như CQRS/MediatR, validation thường được đặt trong request pipeline (ví dụ: sử dụng Behavior Preprocessor) hoặc handler.
  • Khi cần validate với Rule Sets cụ thể.

Để validate thủ công, bạn chỉ cần inject IValidator<T> vào class của bạn và gọi phương thức Validate hoặc ValidateAsync.


public class ProductService
{
    private readonly IValidator<CreateProductRequest> _validator;
    // ... other dependencies

    public ProductService(IValidator<CreateProductRequest> validator /*, ...*/)
    {
        _validator = validator;
        // ...
    }

    public async Task<bool> CreateProductAsync(CreateProductRequest request, CancellationToken cancellationToken)
    {
        // Validate thủ công
        var validationResult = await _validator.ValidateAsync(request, cancellationToken);

        if (!validationResult.IsValid)
        {
            // Xử lý lỗi validation
            // Có thể log lỗi, hoặc trả về một custom result object chứa lỗi
            // hoặc throw một exception custom để middleware xử lý
            foreach (var error in validationResult.Errors)
            {
                // Ví dụ: log lỗi
                Console.WriteLine($"- Property: {error.PropertyName}, Error: {error.ErrorMessage}");
            }
            // Trả về false hoặc throw exception tùy kiến trúc ứng dụng
             throw new ValidationException(validationResult.Errors); // Ví dụ dùng exception
        }

        // Logic tạo sản phẩm khi dữ liệu hợp lệ
        // ... Lưu vào DB, gửi event, ...

        return true;
    }
}

Phương thức ValidateAsync trả về một đối tượng ValidationResult chứa thông tin chi tiết về các lỗi (nếu có) trong thuộc tính Errors. Mỗi lỗi là một đối tượng ValidationFailure bao gồm tên thuộc tính bị lỗi (PropertyName) và thông báo lỗi (ErrorMessage).

Lời Khuyên và Best Practice

Nơi Đặt Validator

Một câu hỏi thường gặp là nên đặt các validator ở đâu? Cách phổ biến và được khuyến nghị là đặt chúng cùng với các DTO, Command, hoặc Query mà chúng validate. Ví dụ, nếu bạn theo Kiến trúc Sạch (Clean Architecture), các validator cho Command/Query có thể nằm trong lớp Application, cùng thư mục với các Command/Query tương ứng. Điều này giúp dễ dàng tìm thấy validator cho một class cụ thể và giữ code liên quan gần nhau.

Kiểm Thử Validator

Một trong những ưu điểm lớn nhất của FluentValidation là khả năng kiểm thử. Validator là các class POCO đơn giản, rất dễ viết unit test cho chúng. Bạn chỉ cần tạo một instance của validator, tạo một instance của model cần test, và gọi phương thức Validate hoặc ValidateAsync.


using FluentValidation.TestHelper;
using Xunit; // Hoặc NUnit, MSTest

public class CreateProductRequestValidatorTests
{
    private readonly CreateProductRequestValidator _validator;

    public CreateProductRequestValidatorTests()
    {
        _validator = new CreateProductRequestValidator();
    }

    [Fact]
    public void ShouldHaveValidationErrorForName_WhenNameIsNullOrEmpty()
    {
        var request = new CreateProductRequest { Name = null, Price = 10 };
        var result = _validator.TestValidate(request);
        result.ShouldHaveValidationErrorFor(x => x.Name);

        request.Name = "";
        result = _validator.TestValidate(request);
        result.ShouldHaveValidationErrorFor(x => x.Name);

         request.Name = "   "; // Chỉ khoảng trắng
        result = _validator.TestValidate(request);
        result.ShouldHaveValidationErrorFor(x => x.Name);
    }

    [Fact]
    public void ShouldNotHaveValidationErrorForName_WhenNameIsValid()
    {
        var request = new CreateProductRequest { Name = "Test Product", Price = 10 };
        var result = _validator.TestValidate(request);
        result.ShouldNotHaveValidationErrorFor(x => x.Name);
    }

    [Fact]
    public void ShouldHaveValidationErrorForPrice_WhenPriceIsNotPositive()
    {
        var request = new CreateProductRequest { Name = "Test Product", Price = 0 };
        var result = _validator.TestValidate(request);
        result.ShouldHaveValidationErrorFor(x => x.Price);

        request.Price = -5;
        result = _validator.TestValidate(request);
        result.ShouldHaveValidationErrorFor(x => x.Price);
    }
}

Thư viện FluentValidation.TestHelper cung cấp các extension method tiện lợi (như TestValidate, ShouldHaveValidationErrorFor) để viết unit test cho validator một cách dễ dàng và rõ ràng hơn.

Khả năng kiểm thử dễ dàng giúp bạn tự tin hơn về tính đúng đắn của logic validation và là một phần quan trọng của quá trình phát triển phần mềm chất lượng cao.

Kết Luận

Validation đầu vào là một phần không thể thiếu của bất kỳ ứng dụng nào, đặc biệt là các ứng dụng web và API. Thay vì dựa vào các phương pháp mặc định có phần hạn chế như DataAnnotations khi đối mặt với logic phức tạp, FluentValidation cung cấp một cách tiếp cận mạnh mẽ, linh hoạt và dễ bảo trì hơn rất nhiều.

Bằng cách tách biệt logic validation ra khỏi model, sử dụng Fluent API dễ đọc, hỗ trợ DI, Rule Sets, Conditional Rules và validation bất đồng bộ, FluentValidation giúp code của bạn sạch sẽ, dễ đọc và dễ kiểm thử hơn. Việc áp dụng FluentValidation chắc chắn sẽ là một bước tiến quan trọng trên Lộ Trình .NET của bạn, đặc biệt khi bạn xây dựng các ứng dụng lớn và phức tạp hơn.

Hãy thử nghiệm FluentValidation trong dự án tiếp theo của bạn và trải nghiệm sự khác biệt trong việc kiểm soát dữ liệu đầu vào nhé! Nó không chỉ giúp bạn viết code tốt hơn mà còn giúp ứng dụng của bạn trở nên mạnh mẽ và đáng tin cậy hơn.

Trong bài viết tiếp theo của series, chúng ta sẽ cùng khám phá một khía cạnh quan trọng khác để xây dựng ứng dụng .NET hiện đại và hiệu quả.

Chỉ mục