Validation
Foundatio.Repositories provides a validation system for ensuring document integrity before persistence. This guide covers implementing validation, handling exceptions, and validation patterns.
Document Validation
ValidateAndThrowAsync
Override ValidateAndThrowAsync in your repository to implement custom validation:
public class EmployeeRepository : ElasticRepositoryBase<Employee>
{
public EmployeeRepository(EmployeeIndex index) : base(index) { }
protected override Task ValidateAndThrowAsync(Employee document)
{
if (string.IsNullOrEmpty(document.Name))
throw new DocumentValidationException("Name is required");
if (string.IsNullOrEmpty(document.Email))
throw new DocumentValidationException("Email is required");
if (!IsValidEmail(document.Email))
throw new DocumentValidationException("Email format is invalid");
if (document.Age < 0 || document.Age > 150)
throw new DocumentValidationException("Age must be between 0 and 150");
return Task.CompletedTask;
}
private bool IsValidEmail(string email)
{
return email.Contains('@') && email.Contains('.');
}
}When Validation Runs
Validation is called automatically during:
AddAsync- Before adding new documentsSaveAsync- Before saving/updating documents
Validation is not called during:
PatchAsync/PatchAllAsync- Partial updates bypass validationRemoveAsync/RemoveAllAsync- Deletions don't require validation
Async Validation
For validation that requires async operations (e.g., checking uniqueness):
protected override async Task ValidateAndThrowAsync(Employee document)
{
// Basic validation
if (string.IsNullOrEmpty(document.Email))
throw new DocumentValidationException("Email is required");
// Check for duplicate email
var existing = await FindOneAsync(q => q
.FieldEquals(e => e.Email, document.Email)
.ExcludedId(document.Id));
if (existing != null)
throw new DuplicateDocumentException($"Email {document.Email} is already in use");
}Exception Hierarchy
Foundatio.Repositories provides a hierarchy of exceptions for different error scenarios:
DocumentValidationException
Thrown when document validation fails:
public class DocumentValidationException : DocumentException
{
public DocumentValidationException() { }
public DocumentValidationException(string message) : base(message) { }
}Usage:
throw new DocumentValidationException("Name is required");
throw new DocumentValidationException($"Age {age} is out of valid range");DocumentNotFoundException
Thrown when a document is not found:
public class DocumentNotFoundException : DocumentException
{
public string Id { get; }
public DocumentNotFoundException(string id) : base($"Document \"{id}\" could not be found")
{
Id = id;
}
}When thrown:
PatchAsyncwhen document doesn't existSaveAsyncwhen updating a non-existent document (in some cases)RemoveAsyncwhen deleting a non-existent document
DuplicateDocumentException
Thrown when attempting to create a duplicate document:
public class DuplicateDocumentException : DocumentException
{
public DuplicateDocumentException(string message) : base(message) { }
}Usage:
throw new DuplicateDocumentException($"Document with email {email} already exists");VersionConflictDocumentException
Thrown when optimistic concurrency check fails:
public class VersionConflictDocumentException : DocumentException
{
public VersionConflictDocumentException() { }
public VersionConflictDocumentException(string message) : base(message) { }
public VersionConflictDocumentException(string message, Exception inner) : base(message, inner) { }
}When thrown:
SaveAsyncwhen the document version doesn't match (ifIVersioned)
Skipping Validation
Per-Operation
Skip validation for specific operations:
// Skip validation for trusted data
await repository.AddAsync(trustedEntity, o => o.SkipValidation());
// Explicitly control validation
await repository.SaveAsync(entity, o => o.Validation(false));Use Cases for Skipping Validation
- Bulk imports - Trusted data from verified sources
- System operations - Internal updates that bypass business rules
- Migrations - Data transformations during schema changes
WARNING
Only skip validation when you're certain the data is valid. Invalid data can cause issues downstream.
Validation Patterns
Fluent Validation Integration
Integrate with FluentValidation for complex validation rules:
public class EmployeeValidator : AbstractValidator<Employee>
{
public EmployeeValidator()
{
RuleFor(e => e.Name)
.NotEmpty().WithMessage("Name is required")
.MaximumLength(100).WithMessage("Name cannot exceed 100 characters");
RuleFor(e => e.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("Invalid email format");
RuleFor(e => e.Age)
.InclusiveBetween(18, 100).WithMessage("Age must be between 18 and 100");
}
}
public class EmployeeRepository : ElasticRepositoryBase<Employee>
{
private readonly IValidator<Employee> _validator;
public EmployeeRepository(EmployeeIndex index, IValidator<Employee> validator)
: base(index)
{
_validator = validator;
}
protected override async Task ValidateAndThrowAsync(Employee document)
{
var result = await _validator.ValidateAsync(document);
if (!result.IsValid)
{
var errors = string.Join("; ", result.Errors.Select(e => e.ErrorMessage));
throw new DocumentValidationException(errors);
}
}
}Validation in Event Handlers
Validate in event handlers for cross-cutting concerns:
public class EmployeeRepository : ElasticRepositoryBase<Employee>
{
public EmployeeRepository(EmployeeIndex index) : base(index)
{
DocumentsAdding.AddHandler(ValidateNewEmployee);
DocumentsSaving.AddHandler(ValidateEmployeeUpdate);
}
private Task ValidateNewEmployee(object sender, DocumentsEventArgs<Employee> args)
{
foreach (var employee in args.Documents)
{
// Validate new employee specific rules
if (employee.StartDate < DateTime.UtcNow.Date)
throw new DocumentValidationException("Start date cannot be in the past");
}
return Task.CompletedTask;
}
private Task ValidateEmployeeUpdate(object sender, ModifiedDocumentsEventArgs<Employee> args)
{
foreach (var modified in args.Documents)
{
var original = modified.Original;
var current = modified.Value;
// Prevent certain changes
if (original != null && original.EmployeeId != current.EmployeeId)
throw new DocumentValidationException("Employee ID cannot be changed");
}
return Task.CompletedTask;
}
}Conditional Validation
Apply different validation rules based on context:
protected override Task ValidateAndThrowAsync(Employee document)
{
// Always validate required fields
if (string.IsNullOrEmpty(document.Name))
throw new DocumentValidationException("Name is required");
// Additional validation for active employees
if (document.Status == EmployeeStatus.Active)
{
if (string.IsNullOrEmpty(document.Department))
throw new DocumentValidationException("Active employees must have a department");
if (document.ManagerId == null)
throw new DocumentValidationException("Active employees must have a manager");
}
return Task.CompletedTask;
}Error Handling
Catching Validation Errors
try
{
await repository.AddAsync(employee);
}
catch (DocumentValidationException ex)
{
_logger.LogWarning("Validation failed: {Message}", ex.Message);
return BadRequest(ex.Message);
}
catch (DuplicateDocumentException ex)
{
_logger.LogWarning("Duplicate document: {Message}", ex.Message);
return Conflict(ex.Message);
}Comprehensive Error Handling
try
{
await repository.SaveAsync(employee);
}
catch (DocumentValidationException ex)
{
// Validation failed
return BadRequest(new { error = "Validation failed", message = ex.Message });
}
catch (DocumentNotFoundException ex)
{
// Document doesn't exist
return NotFound(new { error = "Not found", id = ex.Id });
}
catch (VersionConflictDocumentException ex)
{
// Concurrent modification
return Conflict(new { error = "Version conflict", message = ex.Message });
}
catch (DuplicateDocumentException ex)
{
// Duplicate document
return Conflict(new { error = "Duplicate", message = ex.Message });
}
catch (RepositoryException ex)
{
// Other repository errors
_logger.LogError(ex, "Repository error");
return StatusCode(500, new { error = "Internal error" });
}Validation Result Pattern
For APIs that need to return validation errors without exceptions:
public class ValidationResult
{
public bool IsValid { get; set; }
public List<string> Errors { get; set; } = new();
}
public class EmployeeService
{
private readonly IEmployeeRepository _repository;
public async Task<(Employee Employee, ValidationResult Validation)> CreateEmployeeAsync(
Employee employee)
{
var validation = ValidateEmployee(employee);
if (!validation.IsValid)
return (null, validation);
try
{
var result = await _repository.AddAsync(employee);
return (result, validation);
}
catch (DocumentValidationException ex)
{
validation.IsValid = false;
validation.Errors.Add(ex.Message);
return (null, validation);
}
}
private ValidationResult ValidateEmployee(Employee employee)
{
var result = new ValidationResult { IsValid = true };
if (string.IsNullOrEmpty(employee.Name))
result.Errors.Add("Name is required");
if (string.IsNullOrEmpty(employee.Email))
result.Errors.Add("Email is required");
result.IsValid = result.Errors.Count == 0;
return result;
}
}Next Steps
- Configuration - Validation configuration options
- Repository Pattern - Event handlers for validation
- CRUD Operations - Error handling in operations