Intégrer FluentValidation dans une API ASP.NET Core sans confondre validation des entrées, invariants métier et contraintes de persistance fiables et durables.

FluentValidation en ASP.NET Core : règles utiles
FluentValidation rend les règles d'entrée lisibles et testables dans une application .NET. Le problème commence lorsqu'un validateur devient l'endroit où l'on place indistinctement formats HTTP, accès à la base et décisions métier.
Une validation saine répond d'abord à cette question : quelle frontière protège cette règle ? Une adresse e-mail vide concerne le contrat d'entrée. L'interdiction d'annuler une commande déjà expédiée appartient au domaine. L'unicité d'une valeur doit rester garantie par la base, même si une vérification préalable améliore l'expérience utilisateur.
Trois catégories de règles à ne pas confondre
Prenons une commande de création de compte :
public sealed record RegisterCustomerCommand(
string Email,
string DisplayName,
DateOnly BirthDate);
Les règles ne vivent pas toutes au même endroit.
| Règle | Protection attendue | Emplacement principal |
|---|---|---|
| e-mail obligatoire et correctement formé | contrat d'entrée | validateur FluentValidation |
| nom limité à 80 caractères | contrat applicatif | validateur FluentValidation |
| client majeur pour un produit réglementé | invariant métier | domaine |
| e-mail unique | cohérence concurrente | contrainte unique en base |
| fournisseur externe disponible | condition d'exécution | cas d'usage et gestion d'erreur |
Cette séparation évite de présenter une règle comme fiable alors qu'elle ne l'est pas. Par exemple, un appel MustAsync qui cherche un e-mail existant peut réduire les erreurs retournées à l'utilisateur, mais deux requêtes concurrentes peuvent passer le contrôle en même temps. Seule une contrainte unique protège réellement les données ; sa déclaration avec EF Core est présentée dans Entity Framework Core : contrôler ses données.
Écrire un validateur ciblé
Le validateur porte les règles déterministes qui peuvent être évaluées à partir de la commande :
using FluentValidation;
public sealed class RegisterCustomerValidator
: AbstractValidator<RegisterCustomerCommand>
{
public RegisterCustomerValidator()
{
RuleFor(x => x.Email)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.EmailAddress()
.MaximumLength(254);
RuleFor(x => x.DisplayName)
.NotEmpty()
.MaximumLength(80);
RuleFor(x => x.BirthDate)
.LessThanOrEqualTo(DateOnly.FromDateTime(DateTime.UtcNow));
}
}
CascadeMode.Stop évite d'exécuter les règles suivantes d'une même chaîne lorsque la première a déjà échoué. Ce choix ne doit pas être global par réflexe : retourner plusieurs erreurs indépendantes en une réponse reste souvent plus utile au client.
Les messages affichés à l'utilisateur peuvent être localisés à la frontière HTTP. Pour les traitements applicatifs, préférez aussi des codes d'erreur stables aux chaînes de caractères utilisées comme identifiants.
Enregistrer les validateurs dans l'injection de dépendances
Le package FluentValidation.DependencyInjectionExtensions permet de découvrir les validateurs d'un assembly :
builder.Services.AddValidatorsFromAssemblyContaining<
RegisterCustomerValidator>();
L'enregistrement explicite reste adapté aux petits projets :
builder.Services.AddTransient<
IValidator<RegisterCustomerCommand>,
RegisterCustomerValidator>();
La documentation recommande le cycle de vie transient comme choix simple et sûr. Si un validateur reçoit des dépendances, son cycle de vie ne doit jamais être plus long que celui de ces dépendances.
Préférer une validation manuelle dans une API moderne
Le package historique FluentValidation.AspNetCore n'est plus maintenu. Son pipeline automatique MVC ne prend pas en charge les règles asynchrones et ne couvre pas les Minimal APIs. Pour un nouveau projet, l'appel manuel rend le comportement explicite :
app.MapPost("/customers", async (
RegisterCustomerCommand command,
IValidator<RegisterCustomerCommand> validator,
RegisterCustomerHandler handler,
CancellationToken cancellationToken) =>
{
var validation = await validator.ValidateAsync(
command,
cancellationToken);
if (!validation.IsValid)
{
return Results.ValidationProblem(
validation.Errors
.GroupBy(error => error.PropertyName)
.ToDictionary(
group => group.Key,
group => group
.Select(error => error.ErrorMessage)
.ToArray()));
}
var customerId = await handler.HandleAsync(
command,
cancellationToken);
return Results.Created($"/customers/{customerId}");
});
ValidateAsync exécute les règles synchrones et asynchrones. Il est donc possible de faire évoluer le validateur sans créer plus tard une exécution synchrone invalide.
Dans une application avec beaucoup d'endpoints, cette logique peut être centralisée dans un filtre d'endpoint générique :
public sealed class ValidationFilter<T>(IValidator<T> validator) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
T? argument = context.Arguments.OfType<T>().FirstOrDefault();
if (argument is null)
{
return Results.BadRequest();
}
var validation = await validator.ValidateAsync(
argument,
context.HttpContext.RequestAborted);
return validation.IsValid
? await next(context)
: Results.ValidationProblem(validation.ToDictionary());
}
}
app.MapPost("/customers", CreateCustomerAsync)
.AddEndpointFilter<ValidationFilter<RegisterCustomerCommand>>();
ToDictionary() regroupe les erreurs par propriété dans le format attendu par ValidationProblem. L'objectif reste le même : rendre l'ordre d'exécution visible et conserver une réponse HTTP cohérente.
Depuis .NET 10, ASP.NET Core propose aussi une validation intégrée pour les Minimal APIs (builder.Services.AddValidation()), fondée sur les attributs DataAnnotations. Elle suffit pour des contraintes simples posées sur les types ; FluentValidation reste plus expressif dès que les règles deviennent conditionnelles, dépendent de plusieurs propriétés ou doivent être testées isolément.
Les règles asynchrones demandent une limite claire
FluentValidation propose MustAsync et CustomAsync, mais leur disponibilité ne signifie pas que tous les appels externes doivent entrer dans un validateur.
Une règle asynchrone est raisonnable lorsqu'elle complète rapidement la validation d'une entrée. Elle devient problématique si elle :
- déclenche plusieurs requêtes réseau ;
- modifie des données ;
- dépend d'un workflow métier complexe ;
- décide si une opération est autorisée ;
- remplace une contrainte transactionnelle.
Dans ces cas, le handler du cas d'usage est un meilleur emplacement. Il peut gérer les indisponibilités, les délais, la concurrence et la traduction d'une erreur métier sans transformer le validateur en orchestrateur caché.
Le domaine doit encore refuser les états invalides
Même avec une validation d'entrée complète, une entité doit protéger ses invariants :
public sealed class Customer
{
public DateOnly BirthDate { get; }
private Customer(DateOnly birthDate)
{
var today = DateOnly.FromDateTime(DateTime.UtcNow);
var minimumBirthDate = today.AddYears(-18);
if (birthDate > minimumBirthDate)
{
throw new CustomerMustBeAdultException();
}
BirthDate = birthDate;
}
}
Ce découpage entre validateur, cas d'usage et domaine est celui décrit dans la Clean Architecture en .NET sans dogmatisme. Pourquoi répéter une partie du contrôle ? Parce que le domaine peut être appelé depuis une API, un consommateur de messages, un traitement batch ou un test. Son intégrité ne doit pas dépendre du fait qu'un validateur HTTP ait été exécuté auparavant.
Tester les règles comme une boîte noire
Les extensions de test de FluentValidation permettent de fournir une entrée et d'observer les erreurs sans tester l'implémentation interne :
using FluentValidation.TestHelper;
public sealed class RegisterCustomerValidatorTests
{
private readonly RegisterCustomerValidator _validator = new();
[Fact]
public void Empty_email_is_rejected()
{
var command = new RegisterCustomerCommand(
"",
"Yva",
new DateOnly(1990, 1, 1));
var result = _validator.TestValidate(command);
result.ShouldHaveValidationErrorFor(x => x.Email);
}
[Fact]
public void Valid_input_has_no_validation_error()
{
var command = new RegisterCustomerCommand(
"[email protected]",
"Yva",
new DateOnly(1990, 1, 1));
var result = _validator.TestValidate(command);
result.ShouldNotHaveAnyValidationErrors();
}
}
Il est préférable d'utiliser le vrai validateur plutôt que de le simuler. Les tests restent ainsi centrés sur son contrat observable et résistent mieux aux changements internes.
Checklist avant d'ajouter une règle
- La règle protège-t-elle la forme de l'entrée ou un invariant métier ?
- Peut-elle être évaluée sans effet de bord ?
- Une contrainte de base de données doit-elle garantir le résultat final ?
- L'appel est-il asynchrone et correctement annulable ?
- L'erreur produite sera-t-elle stable et compréhensible par le client ?
- Le même invariant est-il protégé si le cas d'usage est appelé hors HTTP ?
Conclusion
FluentValidation est efficace lorsqu'il reste une frontière explicite de validation, pas lorsqu'il devient une couche métier parallèle.
Utilisez-le pour rejeter tôt les entrées mal formées, appelez les validateurs explicitement dans les nouvelles API ASP.NET Core, laissez les entités protéger leurs invariants et confiez la cohérence concurrente à la persistance. Cette répartition produit un système plus prévisible qu'un validateur capable, en apparence, de tout décider.
Pour l'organisation des validateurs dans une solution, voir structurer un projet ASP.NET Core maintenable ; pour les bases de la validation côté langage, l'épisode saisie utilisateur et TryParse en C#.
Pour approfondir l'API actuelle, consultez la documentation ASP.NET Core de FluentValidation, les règles asynchrones et les extensions de test.

