Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Model Validation

Overview Questions

  • Làm thế nào để đảm bảo dữ liệu nhận từ client là hợp lệ?
  • Data Annotations là gì và sử dụng như thế nào?
  • FluentValidation khác Data Annotations ra sao?
  • Làm sao để tạo custom validation attribute?
  • Validation response format chuẩn là gì?
  • [ApiController] tự động validation như thế nào?

Data Annotations Validation

Built-in Validation Attributes

public class Product
{
    public int Id { get; set; }
    
    [Required(ErrorMessage = "Name is required")]
    [StringLength(100, MinimumLength = 2, ErrorMessage = "Name must be 2-100 characters")]
    public string Name { get; set; } = string.Empty;
    
    [Range(0.01, 9999.99, ErrorMessage = "Price must be between 0.01 and 9999.99")]
    public decimal Price { get; set; }
    
    [EmailAddress(ErrorMessage = "Invalid email format")]
    public string? Email { get; set; }
    
    [Url(ErrorMessage = "Invalid URL format")]
    public string? Website { get; set; }
    
    [Phone(ErrorMessage = "Invalid phone number")]
    public string? Phone { get; set; }
    
    [RegularExpression(@"^[A-Z]{3}$", ErrorMessage = "Code must be 3 uppercase letters")]
    public string? Code { get; set; }
    
    [Compare("Password", ErrorMessage = "Passwords do not match")]
    public string? ConfirmPassword { get; set; }
}

Validation trong Controller

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(Product product)
    {
        // [ApiController] tự động check ModelState.IsValid
        // Nếu invalid, tự động return 400 Bad Request
        
        // Xử lý khi valid
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    }
    
    [HttpGet("{id}")]
    public IActionResult GetById(int id)
    {
        return Ok(new { id });
    }
}

Validation Error Response

{
  "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": ["Name is required"],
    "Price": ["Price must be between 0.01 and 9999.99"]
  }
}

Custom Validation Attribute

Simple Custom Attribute

public class MinimumAgeAttribute : ValidationAttribute
{
    private readonly int _minimumAge;

    public MinimumAgeAttribute(int minimumAge)
    {
        _minimumAge = minimumAge;
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        if (value is DateTime birthDate)
        {
            var age = DateTime.Today.Year - birthDate.Year;
            if (birthDate > DateTime.Today.AddYears(-age)) age--;
            
            if (age < _minimumAge)
            {
                return new ValidationResult($"Must be at least {_minimumAge} years old");
            }
        }
        
        return ValidationResult.Success;
    }
}

// Sử dụng
public class UserRegistration
{
    [Required]
    public string Name { get; set; } = string.Empty;
    
    [MinimumAge(18, ErrorMessage = "You must be at least 18 years old")]
    public DateTime DateOfBirth { get; set; }
}

Property Comparison Validation

public class DateRangeAttribute : ValidationAttribute
{
    private readonly string _startDateProperty;
    private readonly string _endDateProperty;

    public DateRangeAttribute(string startDateProperty, string endDateProperty)
    {
        _startDateProperty = startDateProperty;
        _endDateProperty = endDateProperty;
    }

    protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
    {
        var startDateProperty = validationContext.ObjectType.GetProperty(_startDateProperty);
        var endDateProperty = validationContext.ObjectType.GetProperty(_endDateProperty);

        if (startDateProperty?.GetValue(validationContext.ObjectInstance) is DateTime start &&
            endDateProperty?.GetValue(validationContext.ObjectInstance) is DateTime end)
        {
            if (start >= end)
            {
                return new ValidationResult("Start date must be before end date");
            }
        }

        return ValidationResult.Success;
    }
}

// Sử dụng
[DateRange("StartDate", "EndDate", ErrorMessage = "Invalid date range")]
public class Event
{
    public DateTime StartDate { get; set; }
    public DateTime EndDate { get; set; }
}

IValidatableObject

Self-Validating Model

public class Order : IValidatableObject
{
    public int Id { get; set; }
    public DateTime OrderDate { get; set; }
    public DateTime? ShipDate { get; set; }
    public List<OrderItem> Items { get; set; } = new();
    public string? CouponCode { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // Cross-property validation
        if (ShipDate.HasValue && ShipDate <= OrderDate)
        {
            yield return new ValidationResult(
                "Ship date must be after order date",
                new[] { nameof(ShipDate) });
        }

        // Collection validation
        if (!Items.Any())
        {
            yield return new ValidationResult(
                "Order must have at least one item",
                new[] { nameof(Items) });
        }

        // Business rule validation
        if (Items.Count > 10 && string.IsNullOrEmpty(CouponCode))
        {
            yield return new ValidationResult(
                "Orders with more than 10 items require a coupon code",
                new[] { nameof(CouponCode) });
        }
    }
}

public class OrderItem
{
    public int ProductId { get; set; }
    public int Quantity { get; set; }
    public decimal Price { get; set; }
}

FluentValidation

Cài đặt

dotnet add package FluentValidation.AspNetCore

Cấu hình

// Program.cs
builder.Services.AddControllers()
    .AddFluentValidation(fv =>
    {
        fv.RegisterValidatorsFromAssemblyContaining<Program>();
        fv.DisableDataAnnotationsValidation = true; // Optional: disable data annotations
    });

Tạo Validator

public class ProductValidator : AbstractValidator<Product>
{
    public ProductValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Name is required")
            .MaximumLength(100).WithMessage("Name cannot exceed 100 characters");
            
        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Price must be greater than 0")
            .LessThan(10000).WithMessage("Price cannot exceed 10,000");
            
        RuleFor(x => x.Email)
            .EmailAddress().WithMessage("Invalid email format")
            .When(x => !string.IsNullOrEmpty(x.Email));
            
        RuleFor(x => x.Category)
            .Must(BeAValidCategory).WithMessage("Invalid category")
            .When(x => !string.IsNullOrEmpty(x.Category));
            
        // Conditional validation
        RuleFor(x => x.DiscountCode)
            .NotEmpty().WithMessage("Discount code is required for orders over $100")
            .When(x => x.Price > 100);
    }
    
    private bool BeAValidCategory(string category)
    {
        var validCategories = new[] { "Electronics", "Books", "Clothing", "Food" };
        return validCategories.Contains(category);
    }
}

Custom Validators với FluentValidation

public static class CustomValidators
{
    public static IRuleBuilderOptions<T, string> MustBeValidPhoneNumber<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder.Must(phone => 
            phone != null && phone.All(char.IsDigit) && phone.Length >= 10 && phone.Length <= 15)
            .WithMessage("Phone number must be 10-15 digits");
    }
    
    public static IRuleBuilderOptions<T, string> MustBeStrongPassword<T>(
        this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
            .MinimumLength(8).WithMessage("Password must be at least 8 characters")
            .Matches("[A-Z]").WithMessage("Password must contain uppercase letter")
            .Matches("[a-z]").WithMessage("Password must contain lowercase letter")
            .Matches("[0-9]").WithMessage("Password must contain digit")
            .Matches("[^a-zA-Z0-9]").WithMessage("Password must contain special character");
    }
}

// Sử dụng
public class UserRegistrationValidator : AbstractValidator<UserRegistration>
{
    public UserRegistrationValidator()
    {
        RuleFor(x => x.Phone).MustBeValidPhoneNumber();
        RuleFor(x => x.Password).MustBeStrongPassword();
    }
}

Validation Pipeline

Custom Validation Filter

public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            var errors = context.ModelState
                .Where(e => e.Value?.Errors.Count > 0)
                .ToDictionary(
                    kvp => kvp.Key,
                    kvp => kvp.Value?.Errors.Select(e => e.ErrorMessage).ToArray()
                );

            var response = new
            {
                type = "https://tools.ietf.org/html/rfc7231#section-6.5.1",
                title = "Validation Error",
                status = 400,
                errors = errors
            };

            context.Result = new BadRequestObjectResult(response);
        }
    }
}

// Đăng ký global
builder.Services.AddControllers(options =>
{
    options.Filters.Add<ValidateModelAttribute>();
});

Validation trong Minimal APIs

app.MapPost("/api/products", async (Product product, AppDbContext db) =>
{
    var validationContext = new ValidationContext<Product>(product);
    var validator = new ProductValidator();
    var validationResult = await validator.ValidateAsync(validationContext);
    
    if (!validationResult.IsValid)
    {
        var errors = validationResult.Errors
            .GroupBy(e => e.PropertyName)
            .ToDictionary(
                g => g.Key,
                g => g.Select(e => e.ErrorMessage).ToArray()
            );
        
        return Results.ValidationProblem(errors);
    }
    
    db.Products.Add(product);
    await db.SaveChangesAsync();
    return Results.Created($"/api/products/{product.Id}", product);
});

Best Practices

1. Kết hợp Data Annotations và FluentValidation

// Data Annotations cho simple validation
public class Product
{
    [Required]
    public string Name { get; set; } = string.Empty;
    
    [Range(0.01, 9999.99)]
    public decimal Price { get; set; }
}

// FluentValidation cho complex validation
public class ProductValidator : AbstractValidator<Product>
{
    public ProductValidator()
    {
        RuleFor(x => x.Name)
            .MustAsync(BeUniqueName).WithMessage("Product name must be unique");
            
        RuleFor(x => x.Price)
            .MustAsync(NotExceedBudget).WithMessage("Price exceeds budget limit");
    }
}

2. Validation ở nhiều layers

┌─────────────────────────────────────────────────────────────────┐
│                    VALIDATION LAYERS                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  Layer 1: API Level (Data Annotations / FluentValidation)       │
│  - Input format validation                                      │
│  - Required fields                                              │
│  - Range/length constraints                                     │
│                                                                 │
│  Layer 2: Service Level (Business Rules)                        │
│  - Business logic validation                                    │
│  - Cross-entity validation                                      │
│  - Database-dependent validation                                │
│                                                                 │
│  Layer 3: Domain Level (Invariants)                             │
│  - Domain invariants                                            │
│  - Entity consistency                                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

3. Fail Fast

// ✅ Validate sớm
public async Task<IActionResult> Create(Product product)
{
    // Validation xảy ra trước khi xử lý business logic
    if (!ModelState.IsValid)
        return BadRequest(ModelState);
    
    // Chỉ chạy khi valid
    await _service.ProcessAsync(product);
    return Ok();
}