FluentValidation: Stop checking rules on the first failure!

One of the most popular libraries to check model validation is FluentValidation, it is really concise and handy!

In this article, we're going to see the default behavior of FluentValidation about checking rules and making it more efficient based on the requirements.

Imagine we have a request to register a user:

public class RegisterUserRequest
{
    public string? Username { get; set; }

    public string? Email { get; set; }

    public string? Password { get; set; }
}

When the end user wants to register to our website, Frontend has to call an API for registering the user and send the above model, so usually we need to put some initial validations on the Backend side, right?

Thus, here is our validation model:

public class RegisterUserRequestValidator : AbstractValidator<RegisterUserRequest>
{
    public RegisterUserRequestValidator()
    {
        RuleFor(x => x.Username)
            .NotEmpty()
            .WithMessage("Username is required")
            .MinimumLength(5)
            .WithMessage("Username length must be at least 5 chars")
            .MaximumLength(30)
            .WithMessage("Username length must be less than 30 chars");

        RuleFor(x => x.Email)
            .NotEmpty()
            .WithMessage("Email is required")
            .EmailAddress()
            .WithMessage("Email is not valid");

        RuleFor(x => x.Password)
            .NotEmpty()
            .WithMessage("Password is required")
            .MinimumLength(5)
            .WithMessage("Password length must be at least 5 chars");

        // making sure that the username and email are unique
        RuleFor(x => x.Username)
            .MustAsync(async (username, cancellationToken) =>
            {
                var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
                return user == null;
            })
            .WithMessage("Username is already taken");

        RuleFor(x => x.Email)
            .MustAsync(async (email, cancellationToken) =>
            {
                var user = await _userRepository.GetByEmailAsync(email, cancellationToken);
                return user == null;
            })
            .WithMessage("Email is already taken");
    }
}

Ok, a little long but good, basically we checked some constraints about the properties and also checked username and email to be unique so that we need to call the database using MustAsync method which allows us to do async operations like DB queries.

Maybe some folks will complain about not using async operation in the validation and should be in the logic layer, because it may cause 2 queries to get data (one in the validator and one in the logic layer), but as we know it all depends on the projects and requirement both approaches have their own cons and pros.

So, let's check the default behavior of FluentValidation, the first user is going to register with the below request body:

{
  "username": "HiHi",
  "email": "Saeed",
  "password": "1234"
}

How many of those rules will be checked? Let's see the result:

{
  "Email": [
    "Email is already taken"
  ],
  "Password": [
    "Password length must be at least 5 chars"
  ],
  "Username": [
    "Username length must be at least 5 chars"
  ]
}

It's nice, no?

FluentValidation is checking all rules, even though it did 2 database calls and checked both username and email, as a result, email already exists but do we need it?? It's up to you!

Sometimes we need to stop checking other validations if we had one failure, we can config FluentValidation to do so. There is an enum called CascadeMode with below values:

public enum CascadeMode
{
    //
    // Summary:
    //     When a rule/validator fails, execution continues to the next rule/validator.
    //     For more information, see the methods/properties that accept this enum as a parameter.
    Continue,
    //
    // Summary:
    //     For more information, see the methods/properties that accept this enum as a parameter.
    [Obsolete("The behaviour of StopOnFirstFailure has been replaced by use of the separate validator-level properties ClassLevelCascadeMode and RuleLevelCascadeMode, and their global default equivalents. StopOnFirstFailure will be removed in a later release. For more details, see https://docs.fluentvalidation.net/en/latest/cascade.html .")]
    StopOnFirstFailure,
    //
    // Summary:
    //     When a rule/validator fails, validation is stopped for the current rule/validator.
    //     For more information, see the methods/properties that accept this enum as a parameter.
    Stop
}

The description is clear as to when to use them, the default value for CascadeMode is Continue which is the default behavior of FluentValidation and will check all rules, there is one obsolete option called StopOnFirstFailure that in the newer version, we should use Stop. So we can set CascadeMode in ClassLevel and in Rule Chain.

ClassLevel is telling FluentValidation that if one of the rules fails, please don't check the rest and return the result. Also, we can set it on a rule chain as well, for example, we have 3 rules on Username so if the username was empty then no need to check the min and max length, make sense?

So here is the new version of our validator:

public class RegisterUserRequestValidator : AbstractValidator<RegisterUserRequest>
{
    public RegisterUserRequestValidator()
    {
        ClassLevelCascadeMode = CascadeMode.Stop;//<--------

        RuleFor(x => x.Username)
            .Cascade(CascadeMode.Stop)//<--------
            .NotEmpty()
            .WithMessage("Username is required")
            .MinimumLength(5)
            .WithMessage("Username length must be at least 5 chars")
            .MaximumLength(30)
            .WithMessage("Username length must be less than 30 chars");

        RuleFor(x => x.Email)
            .Cascade(CascadeMode.Stop)//<--------
            .NotEmpty()
            .WithMessage("Email is required")
            .EmailAddress()
            .WithMessage("Email is not valid");

        RuleFor(x => x.Password)
            .Cascade(CascadeMode.Stop)//<--------
            .NotEmpty()
            .WithMessage("Password is required")
            .MinimumLength(5)
            .WithMessage("Password length must be at least 5 chars");

        // making sure that the username and email are unique
        RuleFor(x => x.Username)
            .MustAsync(async (username, cancellationToken) =>
            {
                var user = await _userRepository.GetByUsernameAsync(username, cancellationToken);
                return user == null;
            })
            .WithMessage("Username is already taken");

        RuleFor(x => x.Email)
            .MustAsync(async (email, cancellationToken) =>
            {
                var user = await _userRepository.GetByEmailAsync(email, cancellationToken);
                return user == null;
            })
            .WithMessage("Email is already taken");
    }
}

And if we call the API with the previous body, we get this result:

{
  "Username": [
    "Username length must be at least 5 chars"
  ]
}

It stopped on the first failure and didn't do any database query.

Do I need to use it always, because seems more performant? Well you need to care about User Experience (UX) and it depends:)

Code Simple!

1
An error has occurred. This application may no longer respond until reloaded. Reload x