Validation
🎯 What You'll Learn
- Why validation matters
- Data Annotations attributes
- Automatic validation with [ApiController]
- Custom validation attributes
- FluentValidation library
- Returning validation errors
Why Validation?
Validation ensures data integrity by checking that input meets business rules before processing.
- Security: Prevent malicious input
- Data integrity: Ensure valid data in database
- User experience: Provide clear error messages
- Business rules: Enforce domain constraints
Data Annotations
Data Annotations are attributes that define validation rules on model properties.
Common Validation Attributes
| Attribute | Description | Example |
|---|---|---|
[Required] |
Property must have a value | [Required] |
[StringLength] |
String length constraints | [StringLength(100)] |
[MinLength] |
Minimum length | [MinLength(3)] |
[MaxLength] |
Maximum length | [MaxLength(50)] |
[Range] |
Numeric range | [Range(1, 100)] |
[EmailAddress] |
Valid email format | [EmailAddress] |
[Phone] |
Valid phone number | [Phone] |
[Url] |
Valid URL | [Url] |
[RegularExpression] |
Regex pattern | [RegularExpression(@"^\d{5}$")] |
[Compare] |
Compare with another property | [Compare("Password")] |
Basic Validation Example
public class CreateProductDto
{
[Required(ErrorMessage = "Product name is required")]
[StringLength(100, MinimumLength = 3,
ErrorMessage = "Name must be between 3 and 100 characters")]
public string Name { get; set; } = string.Empty;
[Required]
[Range(0.01, 1000000, ErrorMessage = "Price must be between $0.01 and $1,000,000")]
public decimal Price { get; set; }
[StringLength(500, ErrorMessage = "Description cannot exceed 500 characters")]
public string? Description { get; set; }
[Required]
[Range(0, int.MaxValue, ErrorMessage = "Quantity must be non-negative")]
public int Quantity { get; set; }
[EmailAddress(ErrorMessage = "Invalid email address")]
public string? SupplierEmail { get; set; }
}
Automatic Validation with [ApiController]
The [ApiController] attribute automatically validates models and returns
400 Bad Request with validation errors.
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpPost]
public ActionResult CreateProduct(CreateProductDto dto)
{
// No need to check ModelState.IsValid!
// [ApiController] does it automatically
// If validation fails, returns 400 automatically
return Ok("Product created");
}
}
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": [
"Product name is required"
],
"Price": [
"Price must be between $0.01 and $1,000,000"
]
}
}
Manual Validation (Without [ApiController])
[HttpPost]
public ActionResult CreateProduct(CreateProductDto dto)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Process valid data
return Ok("Product created");
}
Custom Validation Attributes
public class FutureDateAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(
object? value,
ValidationContext validationContext)
{
if (value is DateTime date)
{
if (date <= DateTime.Now)
{
return new ValidationResult("Date must be in the future");
}
}
return ValidationResult.Success;
}
}
// Usage
public class CreateOrderDto
{
[FutureDate]
public DateTime DeliveryDate { get; set; }
}
Class-Level Validation
public class CreateProductDto : IValidatableObject
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public decimal DiscountPrice { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (DiscountPrice >= Price)
{
yield return new ValidationResult(
"Discount price must be less than regular price",
new[] { nameof(DiscountPrice) });
}
if (Name.Contains("test", StringComparison.OrdinalIgnoreCase))
{
yield return new ValidationResult(
"Product name cannot contain 'test'",
new[] { nameof(Name) });
}
}
}
FluentValidation
FluentValidation is a popular library for building strongly-typed validation rules.
Installation
dotnet add package FluentValidation.AspNetCore
Creating a Validator
public class CreateProductDtoValidator : AbstractValidator<CreateProductDto>
{
public CreateProductDtoValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Product name is required")
.Length(3, 100).WithMessage("Name must be between 3 and 100 characters")
.Must(name => !name.Contains("test", StringComparison.OrdinalIgnoreCase))
.WithMessage("Product name cannot contain 'test'");
RuleFor(x => x.Price)
.GreaterThan(0).WithMessage("Price must be greater than 0")
.LessThanOrEqualTo(1000000).WithMessage("Price cannot exceed $1,000,000");
RuleFor(x => x.Description)
.MaximumLength(500).WithMessage("Description cannot exceed 500 characters")
.When(x => x.Description != null);
RuleFor(x => x.Quantity)
.GreaterThanOrEqualTo(0).WithMessage("Quantity must be non-negative");
RuleFor(x => x.SupplierEmail)
.EmailAddress().WithMessage("Invalid email address")
.When(x => !string.IsNullOrEmpty(x.SupplierEmail));
}
}
Register FluentValidation
using FluentValidation;
using FluentValidation.AspNetCore;
builder.Services.AddControllers();
// Register FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductDtoValidator>();
Complete InvenTrack Example
public class CreateProductDto
{
[Required(ErrorMessage = "Product name is required")]
[StringLength(100, MinimumLength = 3)]
public string Name { get; set; } = string.Empty;
[Required]
[Range(0.01, 1000000)]
public decimal Price { get; set; }
[StringLength(500)]
public string? Description { get; set; }
[Required]
[Range(0, int.MaxValue)]
public int Quantity { get; set; }
[Required]
[StringLength(50)]
public string Category { get; set; } = string.Empty;
[Url]
public string? ImageUrl { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
[HttpPost]
public ActionResult<Product> CreateProduct(CreateProductDto dto)
{
// Validation happens automatically with [ApiController]
// If invalid, returns 400 Bad Request with errors
var product = new Product
{
Id = 123,
Name = dto.Name,
Price = dto.Price,
Description = dto.Description,
Quantity = dto.Quantity
};
return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}
[HttpGet("{id}")]
public ActionResult<Product> GetProduct(int id)
{
return Ok(new Product { Id = id });
}
}
Best Practices
- Validate early: Validate at API boundary
- Use DTOs: Don't validate domain models directly
- Clear messages: Provide helpful error messages
- FluentValidation: Consider for complex validation
- Custom attributes: For reusable validation logic
- IValidatableObject: For cross-property validation
- Client-side validation: Validate on client too (UX)
- Security: Never trust client input
Key Takeaways
- Data Annotations: Attribute-based validation
- [ApiController]: Automatic validation
- ModelState: Contains validation errors
- Custom attributes: Extend ValidationAttribute
- IValidatableObject: Class-level validation
- FluentValidation: Strongly-typed validation rules
- 400 Bad Request: Standard validation error response
- Error messages: Customize for better UX
You now understand validation! In the next section, we'll explore Minimal APIs—a lightweight alternative to controllers for building simple, fast APIs with less ceremony.