.NETFluentValidationValidationASP.NET CoreArchitecture

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.

Yva Hajatiana
11 mars 2026
7 min de lecture
Partager :X / TwitterLinkedIn
Composition éditoriale sur la précision et la fiabilité logicielles

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ègleProtection attendueEmplacement principal
e-mail obligatoire et correctement formécontrat d'entréevalidateur FluentValidation
nom limité à 80 caractèrescontrat applicatifvalidateur FluentValidation
client majeur pour un produit réglementéinvariant métierdomaine
e-mail uniquecohérence concurrentecontrainte unique en base
fournisseur externe disponiblecondition d'exécutioncas 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.

Mots-clés :.NETFluentValidationValidationASP.NET CoreArchitecture
Y

Yva Hajatiana

Lead développeur .NET à Paris et architecte logiciel, 10 ans d'expérience sur des SI critiques bancaires (.NET, Angular, systèmes distribués, IAM, DevOps).