This project uses a hybrid validation approach:
DataAnnotationsValidator<T>for simple per-field rules declared directly on DTOsFluentValidationfor cross-field, conditional, composed, and reusable rulesFluentValidationActionFilterto execute registered validators automatically before controller actions
The validation pipeline is:
HTTP Request body/query
→ Model binding
→ FluentValidationActionFilter
→ resolves IValidator<T> from DI
→ runs DataAnnotationsValidator<T> and any extra FluentValidation rules
→ If invalid: 400 Bad Request with ValidationProblemDetails
→ If valid: controller action runs
Registration is done in DI and MVC setup:
services.AddControllers(options =>
{
options.Filters.Add<FluentValidationActionFilter>();
});
services.AddValidatorsFromAssemblyContaining<CreateProductRequestValidator>();There is no per-validator registration and no AddFluentValidationAutoValidation() middleware in the current implementation.
Use:
- Data Annotations for required fields, length, ranges, email format, and other single-field rules
DataAnnotationsValidator<T>as the validator base when a request is mostly attribute-driven- FluentValidation rules only for logic that cannot be expressed cleanly with attributes
This keeps DTO contracts explicit while still allowing richer validation where needed.
For simple rules, put validation directly on the request DTO.
using System.ComponentModel.DataAnnotations;
using APITemplate.Application.Common.Validation;
public sealed record CreateProductRequest(
[NotEmpty(ErrorMessage = "Product name is required.")]
[MaxLength(200, ErrorMessage = "Product name must not exceed 200 characters.")]
string Name,
string? Description,
[Range(0.01, double.MaxValue, ErrorMessage = "Price must be greater than zero.")]
decimal Price,
Guid? CategoryId = null) : IProductRequest;The custom NotEmptyAttribute is available in Application/Common/Validation/NotEmptyAttribute.cs for strings and collections where plain [Required] is not enough.
Create a validator that inherits from DataAnnotationsValidator<T>.
using APITemplate.Application.Common.Validation;
namespace APITemplate.Application.Features.ProductReview.Validation;
public sealed class CreateProductReviewRequestValidator
: DataAnnotationsValidator<CreateProductReviewRequest>;This base class:
- runs
Validator.TryValidateObject(...) - validates constructor parameter attributes for record DTOs
- converts attribute failures into FluentValidation failures
Use this pattern when the request only needs attribute-based validation.
When validation depends on multiple fields, extend DataAnnotationsValidator<T> and add FluentValidation rules.
using APITemplate.Application.Common.Validation;
using FluentValidation;
public abstract class ProductRequestValidatorBase<T> : DataAnnotationsValidator<T>
where T : class, IProductRequest
{
protected ProductRequestValidatorBase()
{
RuleFor(x => x.Description)
.NotEmpty().WithMessage("Description is required for products priced above 1000.")
.When(x => x.Price > 1000);
}
}Then reuse it:
public sealed class CreateProductRequestValidator
: ProductRequestValidatorBase<CreateProductRequest>;
public sealed class UpdateProductRequestValidator
: ProductRequestValidatorBase<UpdateProductRequest>;For filters and reusable request parts, compose validators with Include(...).
using FluentValidation;
public sealed class ProductFilterValidator : AbstractValidator<ProductFilter>
{
public ProductFilterValidator()
{
Include(new PaginationFilterValidator());
Include(new DateRangeFilterValidator<ProductFilter>());
Include(new SortableFilterValidator<ProductFilter>(ProductSortFields.Map.AllowedNames));
RuleFor(x => x.MinPrice)
.GreaterThanOrEqualTo(0)
.When(x => x.MinPrice.HasValue);
RuleFor(x => x.MaxPrice)
.GreaterThanOrEqualTo(x => x.MinPrice!.Value)
.When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue);
}
}Existing reusable validators live under Application/Common/Validation/.
When a rule needs I/O, inject a dependency and use MustAsync.
using FluentValidation;
public sealed class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator(ICustomerRepository customerRepository)
{
RuleFor(x => x.CustomerId)
.MustAsync(async (id, ct) => await customerRepository.ExistsAsync(id, ct))
.WithMessage("Customer does not exist.");
}
}Use this sparingly. Business invariants that depend on broader domain state often belong in the service layer instead.
For business rule violations discovered after request validation, throw the domain ValidationException.
public async Task<ProductResponse> CreateAsync(CreateProductRequest request, CancellationToken ct)
{
var existing = await _repository.GetByNameAsync(request.Name, ct);
if (existing is not null)
throw new ValidationException("A product with the same name already exists.");
// continue
}ApiExceptionHandler converts this into HTTP 400.
Validation failures return HTTP 400 Bad Request as ValidationProblemDetails.
{
"errors": {
"Price": [
"Price must be greater than zero."
],
"Description": [
"Description is required for products priced above 1000."
]
},
"title": "One or more validation errors occurred.",
"status": 400
}Use two patterns depending on where the rule lives:
- Attribute-driven validators: instantiate the validator and call
Validate(...) - Pure FluentValidation rules: use
FluentValidation.TestHelper
The existing tests under tests/APITemplate.Tests/Unit/Validators/ show both styles.
- Add Data Annotation attributes to the DTO for simple field rules
- Create
<RequestType>Validator.csinApplication/Features/<Feature>/Validation/ - Inherit from
DataAnnotationsValidator<T>when attributes should be enforced - Add FluentValidation rules only for cross-field, conditional, or composed logic
- Reuse
PaginationFilterValidator,DateRangeFilterValidator<T>, andSortableFilterValidator<T>where applicable - Rely on assembly scanning; no per-validator DI registration is needed
| File | Purpose |
|---|---|
Application/Common/Validation/DataAnnotationsValidator.cs |
Bridges Data Annotations into FluentValidation |
Application/Common/Validation/NotEmptyAttribute.cs |
Custom attribute for non-empty string/collection checks |
Application/Common/Validation/PaginationFilterValidator.cs |
Shared pagination validation |
Application/Common/Validation/DateRangeFilterValidator.cs |
Shared date-range validation |
Application/Common/Validation/SortableFilterValidator.cs |
Shared sort parameter validation |
Api/Filters/FluentValidationActionFilter.cs |
Runs validators for controller action arguments |
Application/Features/Product/Validation/ProductRequestValidatorBase.cs |
Hybrid validator example |
Application/Features/ProductReview/Validation/CreateProductReviewRequestValidator.cs |
Attribute-only validator example |
Domain/Exceptions/ValidationException.cs |
Service-layer validation error |
Api/ExceptionHandling/ApiExceptionHandler.cs |
Converts validation exceptions to HTTP 400 |