FluentValidation valida objetos .NET con reglas escritas en código, en lugar de con atributos en el modelo. Declaramos una clase que deriva de AbstractValidator<T> y añadimos una cadena de llamadas RuleFor() por propiedad.

La biblioteca incluye validadores para los casos con los que nos topamos constantemente: NotEmpty(), Length(), InclusiveBetween(), EmailAddress(), IsInEnum() y Must() para todo lo que no contempla. En este artículo los vemos uno a uno y, después, creamos nuestro propio validador personalizado.

Este artículo retoma lo que dejamos en nuestra introducción a FluentValidation en ASP.NET Core, pero los conceptos que veremos aquí se aplican a cualquier aplicación .NET, no solo a las de ASP.NET Core. Para demostrarlo, vamos a escribir todo el código en una biblioteca de clases .NET y después la incorporaremos a una API de ASP.NET Core para ver el resultado final.

Para descargar el código fuente de este artículo, puedes visitar el repositorio FluentValidation Validators.

¿Qué es FluentValidation?

FluentValidation es una biblioteca .NET de código abierto para validar objetos con reglas definidas en una clase aparte, en lugar de con atributos en el modelo.

Para crear un validador, derivamos de AbstractValidator<T> y escribimos en el constructor una cadena de llamadas RuleFor() por propiedad. Llamar a Validate() devuelve un ValidationResult que contiene IsValid y una lista de errores; ValidateAsync() hace lo mismo para las reglas que acceden a una base de datos o a una API.

La razón para preferirla a las anotaciones de datos es que las reglas son C# normal y corriente en una clase normal y corriente. Pueden depender de servicios inyectados, ramificarse según el estado del objeto, tratar una propiedad de forma distinta en dos contextos y probarse con pruebas unitarias sin crear una solicitud.

La contrapartida es que nada se valida automáticamente. Algo tiene que llamar al validador: un filtro, un comportamiento del pipeline de MediatR o el propio handler.

Configuración del proyecto

Vamos a empezar creando una nueva biblioteca de clases. En Visual Studio, es la plantilla Class Library, con .NET 8 o una versión posterior como destino, porque FluentValidation 12 ya no es compatible con .NET Standard.

La plantilla también añade <Nullable>enable</Nullable> a ClassLibrary1.csproj. Eliminamos esa línea, como hace el repositorio de ejemplo. Con ella, ASP.NET Core trata como obligatorias todas las propiedades de tipo de referencia no anulable de los modelos y responde a nuestras solicitudes con su propio 400 antes de que se ejecute FluentValidation.

A continuación, instalamos FluentValidation:

dotnet add package FluentValidation

Ahora, añadimos al proyecto una nueva enumeración OrderStatus:

public enum OrderStatus
{
    Accepted,
    Processing,
    Complete
}

Además, vamos a añadir una clase llamada Product:

public class Product
{
    public string Name { get; set; }
}

Después, añadimos una clase llamada Order:

public class Order
{
    public string CustomerName { get; set; }
    public int Price { get; set; }
    public string CustomerEmail { get; set; }
    public OrderStatus OrderStatus { get; set; }
    public Product Product { get; set; }
}

Por último, preparamos nuestro validador:

using FluentValidation;

public class OrderValidator : AbstractValidator<Order>
{
    public OrderValidator()
    {

    }
}

API de ASP.NET Core

Para probarlo todo, vamos a añadir un nuevo proyecto de API de ASP.NET Core que haga referencia a la biblioteca de clases.

Seleccionamos “Add > New Project” en el Solution Explorer y elegimos la plantilla ASP.NET Core Web API.

Aceptamos todos los valores predeterminados y, después, añadimos una referencia al proyecto ClassLibrary1 desde WebApplication1:

dotnet add WebApplication1 reference ClassLibrary1

Hacemos clic con el botón derecho en WebApplication1 y seleccionamos “Set as Startup Project” para que la API sea el proyecto de inicio predeterminado:

Validadores con FluentValidation: configuración de una API como proyecto de inicio

Justo después, pulsamos el atajo “CTRL-F5” para iniciar la API sin depuración y, al abrir la URL /weatherforecast en el navegador, deberíamos ver el conocido resultado del pronóstico del tiempo:

Respuesta de ejemplo de la API

Configurar el controlador

Ahora que tenemos una API básica de ASP.NET Core configurada y con la referencia a nuestro proyecto, debemos añadir un controlador que acepte un objeto Order.

Añadimos un controlador de API vacío a la carpeta Controllers y lo llamamos OrdersController, para que represente el recurso con el que vamos a interactuar.

En el OrdersController que hemos creado, añadimos una directiva using para hacer referencia a nuestro ClassLibrary:

using ClassLibrary1;

A continuación, añadimos el siguiente método:

[HttpPost]
public ActionResult Post([FromBody] Order order)
{
    return Ok("Success!");
}

Este método es casi idéntico al del artículo anterior, en el que simplemente aceptamos un objeto en el cuerpo de una solicitud POST y devolvemos una respuesta 200 (OK) con el texto “Success!”.

Integrar FluentValidation

Ahora tenemos que hacer que los validadores estén disponibles para la API. Para ello, instalamos el paquete de inyección de dependencias (DI) en el proyecto WebApplication1:

dotnet add package FluentValidation.DependencyInjectionExtensions

Después, abrimos Program.cs y añadimos las directivas using necesarias:

using ClassLibrary1;
using FluentValidation;

Además, tenemos que registrar los validadores en el contenedor de DI:

builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<OrderValidator>();

Esto registra la clase OrderValidator que creamos antes como IValidator<Order>, junto con todos los demás validadores del mismo ensamblado, de modo que podemos inyectar cualquiera de ellos allí donde necesitemos validar.

Hay una vía más antigua que merece una mención, porque la usa mucho código existente. El paquete FluentValidation.AspNetCore se conecta al pipeline de validación de ASP.NET Core y ejecuta automáticamente nuestros validadores antes de que se ejecute la acción, con lo que rellena ModelState igual que lo hace la validación de ModelState con las anotaciones de datos. La propia documentación de FluentValidation para ASP.NET Core ahora es tajante al respecto: “Ya no recomendamos usar este enfoque en proyectos nuevos, pero sigue disponible para implementaciones heredadas”. Además, ese paquete se queda en la versión 11.3.1, y la misma página indica que ya no tiene soporte.

Así que llamamos nosotros mismos al validador. Añadimos using FluentValidation; a OrdersController, inyectamos el validador y validamos el pedido entrante:

private readonly IValidator<Order> _validator;

public OrdersController(IValidator<Order> validator)
{
    _validator = validator;
}

[HttpPost]
public async Task<ActionResult> Post([FromBody] Order order)
{
    var validationResult = await _validator.ValidateAsync(order);

    if (!validationResult.IsValid)
    {
        foreach (var error in validationResult.Errors)
        {
            ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
        }

        return ValidationProblem(ModelState);
    }

    return Ok("Success!");
}

Con tres líneas de código de integración obtenemos un 400 (Bad Request) con un payload estándar de detalles del problema, y la validación ocurre donde podemos verla. El mismo esquema sirve para validar un endpoint de una API mínima, donde ni siquiera hay un controlador al que engancharse.

Abrimos Postman y añadimos la solicitud POST para comprobar que todo funciona:

Solicitud en Postman: prueba de validadores con FluentValidation

Si pulsamos el botón Send, deberíamos recibir un estado 200 (OK) y el mensaje “Success!”:

Respuesta en Postman: prueba de validadores con FluentValidation

Ahora que lo tenemos todo configurado, vamos a añadir validación a los modelos y a probarla en la API sobre la marcha.

Validadores integrados

Los validadores integrados abarcan la presencia, el tamaño, el formato y la pertenencia a un conjunto, y todos se asocian a una propiedad mediante RuleFor().

La presencia tiene dos niveles de exigencia. NotNull() solo rechaza null, mientras que NotEmpty() rechaza además una cadena vacía, los espacios en blanco y el valor predeterminado del tipo. Por eso, con las cadenas, el validador al que recurrimos es NotEmpty().

El tamaño trata por separado las cadenas y los números: MinimumLength(), MaximumLength() y Length() para el texto; InclusiveBetween() y los validadores de comparación para los números.

Es en el formato donde más trabajo nos ahorra la biblioteca. EmailAddress() y Matches() se encargan de los patrones que, de otro modo, escribiríamos una y otra vez. La propia documentación de FluentValidation describe la regla predeterminada de correo electrónico como “una comprobación intencionadamente ingenua para reproducir el comportamiento del EmailAddressAttribute de ASP.NET Core”.

De la pertenencia a un conjunto se encarga IsInEnum(). Importa más de lo que parece: una propiedad de tipo enumeración enlazada desde JSON acepta cualquier entero, incluidos valores que la enumeración nunca declaró, y IsInEnum() es lo que los rechaza.

Todo lo que la biblioteca no contempla va a Must(), que recibe un predicado.

Validación de valores null y vacíos

La validación más habitual y sencilla consiste en asegurarse de que hay un valor y, en el caso de las cadenas o las secuencias, de que no está vacío.

Añadimos estas dos reglas a OrderValidator:

RuleFor(model => model.CustomerName).NotNull();
RuleFor(model => model.CustomerEmail).NotEmpty();

Estas dos reglas garantizan que CustomerName tiene un valor y que CustomerEmail tiene un valor que no está formado solo por espacios en blanco. En la mayoría de los casos usaríamos directamente el validador NotEmpty(), salvo que quisiéramos admitir los espacios en blanco como valor válido; aun así, vale la pena mostrar las dos opciones.

Las dos reglas son incondicionales. Cuando una propiedad solo es obligatoria en algunos casos, los métodos When() y Unless() de FluentValidation hacen el mismo trabajo que los atributos de obligatoriedad condicional, sin ningún atributo personalizado a la vista.

Validación de longitud

Una vez que nos hemos asegurado de que las cadenas contienen un valor, la siguiente validación que solemos necesitar tiene que ver con la longitud.

Modificamos la regla que acabamos de crear para CustomerName:

RuleFor(model => model.CustomerName).MinimumLength(10);

Ahora nos aseguramos de que el nombre tiene al menos 10 caracteres (con este cambio, un nombre ausente pasa la validación, porque los validadores de longitud consideran válido null). Si lo preferimos, podemos usar estos otros validadores de longitud parecidos:

  • MaximumLength (útil si queremos asegurarnos de que una cadena tiene, como máximo, un número determinado de caracteres)
  • Length (cuando queremos que la cadena tenga exactamente un número determinado de caracteres o que esté dentro de un rango)

Validación de rangos

Por lo general, siempre que tenemos enteros en el modelo, debemos aplicar algún tipo de límites razonables a su valor. Aquí es donde entran en juego los validadores de rango.

Volvemos a la clase OrderValidator y añadimos un validador para la propiedad Price:

RuleFor(model => model.Price).InclusiveBetween(1, 1000);

Aquí simplemente nos hemos asegurado de que el precio está entre 1 y 1000, ambos incluidos. También podemos usar los siguientes validadores parecidos, cuyo uso, por suerte, resulta evidente por su nombre:

  • ExclusiveBetween
  • LessThan / LessThanOrEqualTo
  • GreaterThan / GreaterThanOrEqualTo

Validación de correo electrónico

Otra validación que se necesita a menudo es la de las direcciones de correo electrónico. Para habilitarla, basta con añadir la siguiente regla a la propiedad CustomerEmail, en sustitución de la regla NotEmpty() que teníamos antes:

RuleFor(model => model.CustomerEmail).EmailAddress();

Vale la pena precisar lo que hace este validador, porque el nombre promete más de lo que la regla ofrece. La comprobación predeterminada de EmailAddress() es ingenua a propósito: acepta cualquier valor que contenga un único carácter @ que no esté ni al principio ni al final. Así, a@b pasa la validación, y también has [email protected], mientras que not-an-email y a@b@c se rechazan. Es intencionado, y se trata de la misma comprobación del carácter @ que realiza el propio EmailAddressAttribute de ASP.NET Core.

Si necesitamos algo más estricto, el validador Matches() recibe una expresión regular y es el lugar adecuado para poner nuestra propia regla.

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

Validación de enumeraciones

La mayoría de las aplicaciones .NET usan enumeraciones para indicar el estado de un objeto. Al validarlas, a menudo tenemos que pasar por el engorro de hacer conversiones, boxing y unboxing, y de comprobar uno por uno los distintos miembros de la enumeración para saber si la entrada es válida.

Con FluentValidation es facilísimo. Para demostrarlo, añadimos una validación de enumeración a la propiedad OrderStatus:

RuleFor(model => model.OrderStatus).IsInEnum();

El método IsInEnum() garantiza que el valor es uno de los miembros incluidos en la enumeración.

Aunque aquí pueda parecer sencillo, piensa en una API como la del artículo anterior. Ahí, si tuviéramos una enumeración en el modelo, recibiríamos el valor en forma de int desde la aplicación que hace la llamada, y el enlace de modelos (model binding) lo asignaría después a nuestra propiedad de tipo enumeración. Como no tenemos control sobre esos valores de entrada, tenemos que aplicar a la validación de la enumeración un enfoque de “lista blanca” para garantizar que el valor enlazado es uno de los que permitimos. Eso es exactamente lo que hace por nosotros el método IsInEnum().

Enviamos en Postman la misma solicitud que antes y vemos el resultado:

Respuesta en Postman: validadores de enumeración con FluentValidation

Ahora vemos que recibimos errores de validación por las reglas que hemos creado, lo que significa que todo funciona bien.

Cambiamos el cuerpo de la solicitud para que todo vuelva a salir bien:

Respuesta en Postman: validación de enumeración correcta con FluentValidation

Ya hemos visto la mayoría de los validadores integrados que solemos usar. En la siguiente sección veremos cómo encadenar validadores.

Encadenar validadores

A menudo necesitamos aplicar más de una regla de validación a una propiedad concreta. Ahí es donde entra el encadenamiento.

Vamos a demostrarlo encadenando un validador al validador EmailAddress() que ya tenemos:

RuleFor(model => model.CustomerEmail)
    .EmailAddress()
    .MinimumLength(20);

Aquí nos aseguramos de dos cosas:

  1. Que el valor de CustomerEmail es una dirección de correo electrónico válida
  2. Que el valor de CustomerEmail tiene al menos 20 caracteres

Es un ejemplo bastante forzado, pero muestra una técnica que podemos aplicar cuando queremos usar un validador integrado que no termina de ajustarse a lo que necesitamos y queremos ser más específicos.

Cambiamos el valor de customerEmail en la solicitud de Postman a “AAAAA” y pulsamos Send:

Respuesta en Postman: validadores encadenados con FluentValidation

Como vemos, ahora recibimos 2 errores de validación, uno por cada regla encadenada.

Aquí también vale la pena mencionar cómo funciona el modo de “cascada” de FluentValidation. En el ejemplo anterior, se aplica el validador EmailAddress() y, tanto si la validación tiene éxito como si falla, después se aplica también la validación MinimumLength().

Esto tiene el efecto secundario de aplicar validación adicional (y, por lo tanto, más procesamiento) cuando la llamada va a fallar de todos modos. A veces no es lo que queremos.

Para evitarlo, cambiamos la regla de validación del correo electrónico:

RuleFor(model => model.CustomerEmail)
    .Cascade(CascadeMode.Stop)
    .EmailAddress()
    .MinimumLength(20);

Ahora, si la validación EmailAddress() falla, el validador MinimumLength() no se ejecuta. El modo de cascada también se puede establecer para todo el validador en el constructor, o de forma global para todos los validadores.

Hoy en día, la enumeración CascadeMode declara exactamente dos miembros: Continue y Stop. El código y los artículos más antiguos usan CascadeMode.StopOnFirstFailure, que FluentValidation marcó como obsoleto en la versión 11.0 y eliminó en la 12.0 (guía de actualización a FluentValidation 12.0, consultada el 13 de septiembre de 2026). En FluentValidation 12, esa línea es un error de compilación, no una advertencia.

Ejecutamos la aplicación y volvemos a enviar la misma solicitud en Postman:

Respuesta en Postman: opciones de cascada de los validadores con FluentValidation

Ahora solo se muestra la primera regla de validación. Cambiamos el valor de customerEmail a “[email protected]” para que la entrada vuelva a ser válida.

En la siguiente sección veremos cómo usar validadores anidados cuando tenemos varias clases.

Validadores anidados

En el proyecto actual tenemos dos clases, Order y Product. Hasta ahora solo hemos aplicado validación a la clase Order. ¿Y si quisiéramos validar también la clase Product? ¿Cómo nos aseguraríamos entonces de que esa validación se ejecuta cuando validamos el Order? Es un caso habitual, así que veamos cómo implementarlo.

Primero, añadimos un validador sencillo para la clase Product:

using FluentValidation;

public class ProductValidator : AbstractValidator<Product>
{
    public ProductValidator()
    {
        RuleFor(model => model.Name).NotEmpty();
    }
}

Aquí lo mantenemos sencillo porque queremos centrarnos en cómo llamar a este validador desde la clase Order.

Dicho esto, añadimos una nueva regla al validador Order:

RuleFor(model => model.Product)
    .NotNull()
    .SetValidator(new ProductValidator());

Sencillamente, nos aseguramos de que se envía un valor para Product y delegamos la validación en el ProductValidator. Es un gran ejemplo de separación de responsabilidades: si en algún momento la clase Product evoluciona (y, con ella, sus reglas de validación), eso no es asunto del OrderValidator.

Ejecutamos la API y enviamos en Postman la solicitud que ya teníamos:

Respuesta en Postman: validadores anidados con errores en FluentValidation

Ahora recibimos un error para la propiedad product que indica que debe existir. Es nuestra regla de validación NotNull() la que se activa.

Probamos a cambiar el valor del producto por un objeto vacío:

Respuesta en Postman: error del validador anidado con FluentValidation

Seguimos recibiendo un error, pero, como vemos, ahora es la regla de validación anidada la que se activa para la propiedad Product.Name.

Para corregir la entrada, la volvemos a cambiar:

Respuesta en Postman: validación anidada correcta con FluentValidation

Vemos que ahora la solicitud tiene éxito.

A continuación, veamos cómo validar colecciones.

Colecciones

En el proyecto actual, cada Order tiene un solo Product. Pero ¿y si un pedido tiene muchos productos (un caso más realista)?

Actualizamos la clase Order para reflejarlo:

public Product[] Products { get; set; }

Si pasamos a OrderValidator, vemos que ahora tenemos un error de compilación en la siguiente línea:

RuleFor(model => model.Product)
    .NotNull()
    .SetValidator(new ProductValidator());

Lo que queremos ahora es seguir usando nuestro ProductValidator, pero invocarlo para cada producto.

Para ello, vamos a modificar la línea que da error:

RuleForEach(model => model.Products).SetValidator(new ProductValidator());

Ahora nos aseguramos de que cada producto se valida con las reglas de ProductValidator.

Ejecutamos la aplicación y modificamos la solicitud de Postman:

Respuesta en Postman: validadores de colecciones con FluentValidation

The Web API Production Checklist, ebook gratuito en inglés

Ebook gratis

¿Tu Web API está lista para producción?

33 puntos que comprobar antes de desplegarla, con la solución de cada uno. Un PDF gratuito de 76 páginas para .NET 10.

El ebook está en inglés.

Descarga la checklist gratis

PDF gratuito. Un solo correo para enviártelo. Puedes darte de baja cuando quieras.

Como vemos, la entrada tiene dos productos, pero la validación falla para el segundo producto (índice 1) porque la propiedad “name” está vacía. Esto demuestra que la validación se ejecuta para cada elemento del array, y el error aparece asociado a Products[1].Name, así que sabemos exactamente qué elemento era incorrecto.

Corregimos la solicitud de Postman:

Respuesta en Postman: validación de colecciones correcta con FluentValidation

Ahora todo vuelve a funcionar.

En la siguiente sección veremos cómo lanzar excepciones de validación.

Lanzar excepciones

La forma más habitual de invocar los validadores es llamar al método Validate() y, después, examinar el ValidationResult.

Sin embargo, ¿qué pasa si queremos lanzar una excepción automáticamente y cortocircuitar el código?

Podemos hacerlo con el método ValidateAndThrow():

orderValidator.ValidateAndThrow(order);

Fíjate en que esta vez no guardamos el resultado de la validación. Esto se debe a que ValidateAndThrow devuelve void. O la validación tiene éxito y el código continúa, o se lanza una ValidationException.

El motivo más habitual para hacerlo es el manejo global de errores. En lugar de comprobar constantemente los resultados de validación por todo el código y actuar en consecuencia (lo que violaría el principio “DRY“), podemos lanzar una ValidationException y capturar esa excepción más arriba en el código.

Comportamiento predeterminado

En nuestra API llamamos al validador de forma explícita y examinamos el resultado, que es el comportamiento que configuramos antes. Veamos qué nos cuesta eso en un ejemplo más pequeño antes de dejar que la excepción salga volando.

Para demostrar este comportamiento, modificamos el método de la API que ya tenemos:

[HttpPost]
public ActionResult Post([FromBody] Order order)
{
    var product = new Product
    {
        Name = null // will fail validation
    };

    var validationResult = new ProductValidator().Validate(product);

    return validationResult.IsValid
        ? (ActionResult) Ok("Success!")
        : BadRequest("Validation failed");
}

Vamos a explicar el código:

  1. Primero, creamos un nuevo Product con valores que sabemos que no pasarán la validación
  2. Después, creamos un ProductValidator() y validamos el producto
  3. Por último, devolvemos Ok o BadRequest según el resultado de la validación

Si ejecutamos la API y enviamos en Postman la solicitud que ya teníamos:

Respuesta en Postman: comportamiento de validación predeterminado con FluentValidation

Vemos la respuesta “Validation failed”.

Usar ValidateAndThrow()

Ahora vamos a cambiar el código para que, en su lugar, lance una excepción. Primero, necesitamos la referencia a FluentValidation, que añadimos antes:

using FluentValidation;

A continuación, modificamos el método:

[HttpPost]
public ActionResult Post([FromBody] Order order)
{
    var product = new Product
    {
        Name = null // will fail validation
    };

    new ProductValidator().ValidateAndThrow(product);

    return Ok("Success!");
}

Ahora, si ejecutamos la API y enviamos la solicitud en Postman, el código de respuesta es 500 (Internal Server Error) y el cuerpo indica que se ha lanzado una ValidationException.

No es un buen payload para una solicitud incorrecta, y por eso ValidateAndThrow() solo compensa cuando algo más arriba espera para capturar la excepción y convertirla en una respuesta. Un middleware de manejo global de errores es uno de esos lugares; ejecutar los validadores dentro de un pipeline de MediatR es otro, en el que un comportamiento valida la solicitud y el pipeline nunca llega al handler.

Devolvemos el método a la versión que configuramos antes:

[HttpPost]
public async Task<ActionResult> Post([FromBody] Order order)
{
    var validationResult = await _validator.ValidateAsync(order);

    if (!validationResult.IsValid)
    {
        foreach (var error in validationResult.Errors)
        {
            ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
        }

        return ValidationProblem(ModelState);
    }

    return Ok("Success!");
}

En la próxima sección veremos cómo llevar FluentValidation al siguiente nivel mediante validadores personalizados.

¿Cómo escribir un validador personalizado en FluentValidation?

Hay tres formas, y cuál conviene elegir depende de si la regla necesita un nombre.

Para una regla puntual, Must() recibe un predicado y se resuelve en una línea. Recibe el valor de la propiedad, devuelve bool y se combina con WithMessage() para indicar qué ha fallado.

Para una regla que se usa en más de un validador, escribimos un método de extensión para IRuleBuilder<T, TProperty> que devuelve IRuleBuilderOptions<T, TProperty>. Dentro de él combinamos validadores existentes y nuestra propia llamada a Must(), y después lo llamamos como cualquier regla integrada.

Escribir una regla personalizada como método de extensión es lo que la hace reutilizable, porque la regla y su mensaje van juntos y todos los validadores obtienen el mismo comportamiento.

Para reglas que necesitan servicios inyectados o trabajo asíncrono, la tercera forma es una clase que implementa IPropertyValidator o un AbstractValidator que se pasa a SetValidator().

Si hacemos clic con el botón derecho en cualquiera de los validadores integrados que hemos usado hasta ahora y seleccionamos “Go to definition”, veremos que se encuentra en una clase llamada DefaultValidatorExtensions. Esos validadores extienden la interfaz IRuleBuilder, y el nuestro también lo hará.

Echemos un vistazo a la validación que ya tenemos para la propiedad CustomerName en la clase Order:

RuleFor(model => model.CustomerName).MinimumLength(10);

¿Y si queremos hacer algo más que comprobar que tiene al menos 10 caracteres? ¿Y si queremos asegurarnos de que es de verdad un nombre real, con nombre y apellido, y un espacio en medio? Vamos a crear un validador personalizado que encapsule esa lógica.

Añadir un validador personalizado FullName

Primero, añadimos una clase FluentValidationExtensions con un nuevo método de extensión:

using FluentValidation;

public static class FluentValidationExtensions
{
    public static IRuleBuilderOptions<T, string> FullName<T>(this IRuleBuilder<T, string> ruleBuilder)
    {
        return ruleBuilder
                   .MinimumLength(10)
                   .Must(val => val.Split(" ").Length >= 2);
    }
}

Vamos a explicar el código:

  1. En primer lugar, vemos que IRuleBuilder, en la firma del método, recibe 2 argumentos de tipo, T y string. Básicamente, esto significa que el validador personalizado se puede aplicar a cualquier validador de una clase, siempre que la propiedad que se valida sea una cadena. Si quisiéramos limitarlo a ciertas clases, podríamos usar restricciones genéricas de C#, pero lo mantenemos sencillo con fines demostrativos.
  2. En la implementación, usamos el validador MinimumLength que ya existe y añadimos el código de validación:
.Must(val => val.Split(" ").Length >= 2);

Usamos el “encadenamiento de validadores” (que ya vimos antes) y también el validador Must, que acepta un predicado cuyo tipo de valor devuelto debe ser bool. Básicamente, aquí es donde podemos poner cualquier lógica personalizada. En este caso, dividimos el valor por los espacios en un array y comprobamos que tiene al menos dos elementos (el nombre de pila y, después, quizá un segundo nombre y los apellidos). Por supuesto, es una implementación muy ingenua, pero, de nuevo, la mantenemos sencilla para centrarnos en los distintos validadores de FluentValidation y no en una lógica complicada.

Usar nuestro validador personalizado

Modificamos la regla de validación para que use el nuevo validador:

RuleFor(model => model.CustomerName).FullName();

Ahora volvemos a ejecutar la API y enviamos la solicitud de Postman:

Respuesta en Postman: validadores personalizados con FluentValidation

La solicitud sigue teniendo éxito.

Veamos qué ocurre si cambiamos el valor de customerName a “JoeBloggs” (sin espacio) y volvemos a enviar la solicitud:

Respuesta en Postman: validadores personalizados que fallan con FluentValidation

Fíjate en que han fallado nuestras dos reglas de validación. Además, vemos que la segunda regla de validación no es muy fácil de entender para el usuario, así que vamos a modificar el validador personalizado para que exija exactamente un espacio, y a usar el método WithMessage():

return ruleBuilder
           .MinimumLength(10)
           .Must(val => val.Split(" ").Length == 2)
           .WithMessage("Name must contain a single space and be at least 10 characters long");

Esto debería hacer que el error sea algo más fácil de entender. Ejecutamos la API y volvemos a enviar la solicitud:

Respuesta en Postman: mensajes de error personalizados en validadores con FluentValidation

Ahora el cliente debería poder entender fácilmente por qué ha fallado su solicitud y hacer los ajustes necesarios. El método WithMessage() resulta útil para esto cuando queremos ofrecer mejor información de error que la que FluentValidation da de forma predeterminada, algo especialmente importante cuando creamos una API pública.

¿Qué validador de FluentValidation usar?

Ya hemos usado al menos una vez la mayoría de los validadores que trata este artículo. En lugar de volver a recorrer el tutorial para encontrar el que necesitamos, aquí están todos en un solo lugar, junto con lo que comprueba en realidad cada uno:

Qué queremos comprobarValidadorNota
Que hay un valorNotNull()Rechaza null; admite cadenas vacías o formadas solo por espacios en blanco
Que hay un valor y tiene contenidoNotEmpty()Rechaza null, la cadena vacía, los espacios en blanco y los valores predeterminados
La longitud de una cadenaMinimumLength(), MaximumLength(), Length()Length(min, max) acepta un rango
Los límites de un númeroInclusiveBetween(), ExclusiveBetween()También GreaterThan(), LessThanOrEqualTo() y sus variantes
Una dirección de correo electrónicoEmailAddress()Solo comprueba que el valor contiene un único carácter @ que no está ni al principio ni al final, no que esté bien formado: a@b pasa la validación, y también has [email protected]
Que un valor coincide con un patrónMatches()Acepta una expresión regular o una instancia de Regex
Que un valor de enumeración es un miembro declaradoIsInEnum()Detecta enteros fuera de rango enlazados desde una solicitud
Que un valor pertenece a un conjuntoIsInEnum(), o Must() con una búsquedaNo existe un validador específico de "uno de"
Cualquier otra cosaMust()Acepta un predicado que devuelve bool
Un objeto anidadoSetValidator()Pasa la propiedad a otro AbstractValidator<T>
Cada elemento de una colecciónRuleForEach().SetValidator()Los errores se notifican con el índice del elemento
Detenerse tras el primer error.Cascade(CascadeMode.Stop)El nombre antiguo StopOnFirstFailure no está obsoleto, sino eliminado: en la 12.1.1 provoca directamente un CS0117
Reemplazar el mensaje.WithMessage()También .WithErrorCode() y .WithSeverity()

Tres filas de esa tabla merecen una segunda lectura. NotNull() y NotEmpty() no son intercambiables, y EmailAddress() es mucho más permisivo de lo que sugiere su nombre.

Conclusión

En este artículo nos hemos sumergido a fondo en el tema y hemos visto muchos de los casos con los que solemos tener que lidiar al validar objetos en una aplicación .NET: los validadores integrados de presencia, longitud, rango, formato y enumeraciones; cómo encadenarlos con un modo de cascada; cómo anidar validadores; cómo validar colecciones; cómo lanzar una excepción cuando falla la validación y, por último, cómo escribir nuestro propio validador personalizado.

Para la mayoría de las empresas, los datos son su activo más valioso, así que es importante proteger lo que entra en el sistema. Por eso es fundamental contar con una biblioteca tan buena como FluentValidation y saber qué reglas aplicar en cada caso.

¡Feliz validación! Y recuerda: “¡Nunca confíes en la entrada del usuario!”.

Probado con .NET 10.0.10 y FluentValidation 12.1.1.