Fluent API Custom Validators in ASP.NET Core Web API

Fluent API Custom Validators in ASP.NET Core Web API:

In this article, I will discuss How to Implement Fluent API Custom Validators in ASP.NET Core Web API Applications with Examples. Please read our previous articles discussing How to Implement Fluent API Async Validators in ASP.NET Core Web API Applications.

What are Fluent API Custom Validators?

Fluent API Custom Validators allow developers to create custom validation logic beyond the built-in validation methods like NotEmpty(), Length(), EmailAddress(), etc. These are useful when specific validation rules go beyond the predefined rules provided by FluentValidation. These validators enable developers to handle complex, business-specific, or conditional validation scenarios. They are especially useful for scenarios such as:

  • Complex Validation Rules: Enforcing business rules that involve multiple properties or dependencies on external services.
  • Business-Specific Rules: Defining constraints and conditions unique to your domain and not covered by default validation methods.
  • Conditional Validation: Implementing logic that depends on runtime conditions, relationships between properties, or other dynamic factors.
  • Reusability: Centralizing custom validation logic so it can be consistently applied across multiple models or services.
How to Use Fluent Validation Custom Validators?

FluentValidation supports a range of custom validation methods: Must, MustAsync, Custom, and CustomAsync. Each serves a particular use case, depending on whether validation is synchronous or asynchronous and whether it applies at the property or object level.

Property-Level Synchronous Validation with Must

The Must method applies synchronous validation logic at the property level. For example, you can use it to enforce that a string only contains letters or a numeric value fall within a specific range. The validation condition is defined as a predicate that returns true if valid and false otherwise. The following is the Example Syntax:

Property-Level Synchronous Validation with Must

Note: We need to use the Must method when the validation logic involves only the property being validated and does not require any asynchronous calls or access to external resources. For example, we need to validate that the FirstName contains only letters.

Property-Level Asynchronous Validation with MustAsync

The MustAsync method is similar to Must but supports asynchronous validation logic. This is typically used when making database queries, calling external APIs, or performing other asynchronous operations. The following is the Example Syntax:

Property-Level Asynchronous Validation with MustAsync

Note: We need to use the MustAsync method when we need to validate a property against external data (like checking uniqueness in a database) and need asynchronous processing to prevent blocking. For example, we need to validate that the email is unique to the database.

Object-Level Synchronous Validation with Custom

The Custom method enables synchronous validation logic at the object level. It’s particularly useful when comparing multiple properties or enforcing complex business rules involving more than one field. The following is the Example Syntax:

Object-Level Synchronous Validation with Custom

Note: We need to use the Custom method when validation requires examining multiple properties of the object together or complex business logic that can’t be performed on a property-by-property basis. For example, we need to Validate that Password and ConfirmPassword match.

Object-Level Asynchronous Validation with CustomAsync

The CustomAsync method supports asynchronous validation logic at the object level. It’s often used to validate relationships or dependencies that require database checks or other external validations. The following is the Example Syntax:

Object-Level Asynchronous Validation with CustomAsync

Note: We need to use the CustomAsync method for complex object-level validations that require asynchronous processing. This is useful for validating relationships that involve external resources like databases. For example, validate that the city belongs to the selected Country.

Real-Time Example: User Registration

Let’s understand the above methods using one User Registration application. Then, let’s implement this example using Fluent API with synchronous and asynchronous custom validators in an ASP.NET Core Web API project with Entity Framework core and SQL Server Database.

Setting Up the Project

First, create a new ASP.NET Core Web API Project named FluentAPIValidationDemo and install the following required Packages. You can install the packages using the Package Manager Console by executing the following commands:

  • Install-Package Microsoft.EntityFrameworkCore.SqlServer
  • Install-Package Microsoft.EntityFrameworkCore.Tools
  • Install-Package FluentValidation.AspNetCore
Create Models

Create a folder named Models in the Project root directory where we will create all our Entities:

Gender Entity:

Create a class file named Gender.cs within the Models folder, then copy and paste the following code. This class defines the Gender entity, which represents different gender options. It is used to validate users’ selection of a valid gender (Male, Female, or Unknown).

namespace FluentAPIValidationDemo.Models
{
    public class Gender
    {
        public int GenderId { get; set; } // Primary Key
        public string Name { get; set; }  // e.g., Male, Female, Unknown
    }
}
Country Entity:

Create a class file named Country.cs within the Models folder, and then copy and paste the following code. This will represent the Country entity and Country master data.

namespace FluentAPIValidationDemo.Models
{
    public class Country
    {
        public int CountryId { get; set; } // Primary Key
        public string Name { get; set; }   // Country name
        public ICollection<City> Cities { get; set; } // List of cities in this country
    }
}
City Entity:

Create a class file named City.cs within the Models folder, and then copy and paste the following code. This will represent the City entity and City master data.

namespace FluentAPIValidationDemo.Models
{
    public class City
    {
        public int CityId { get; set; }    // Primary Key
        public string Name { get; set; }   // City name
        public int CountryId { get; set; } // Foreign Key to Country

        // Navigation Property to link with Country entity
        public Country Country { get; set; }
    }
}
User Entity:

Create a class file named User.cs within the Models folder, and then copy and paste the following code. This class defines the User entity, which contains all necessary fields for user registration, such as First Name, Last Name, Email, Password, Address, etc.

namespace FluentAPIValidationDemo.Models
{
    public class User
    {
        public int UserId { get; set; }          // Primary Key
        public string FirstName { get; set; }      // User's first name
        public string LastName { get; set; }       // User's last name
        public string Email { get; set; }          // User's email address and must be Unique
        public string Password { get; set; }       // User's password (should be hashed in production)
        public DateTime DateOfBirth { get; set; }    // User's birth date
        public string PhoneNumber { get; set; }    // Contact phone number
        public string Address { get; set; }        // Optional: User’s address
        public int? GenderId { get; set; }         // Foreign Key to Gender
        public int? CountryId { get; set; }        // Foreign Key to Country
        public int? CityId { get; set; }           // Foreign Key to City (must belong to selected Country)
        // Navigation Properties
        public Gender Gender { get; set; }
        public Country Country { get; set; }
        public City City { get; set; }
    }
}
Create User DbContext Class

First, create a folder named Data in the project root directory, and then inside the Data folder, create a class file named UserDbContext.cs and copy and paste the following code. This class configures the EF Core DbContex,t including seeding master data.

using FluentAPIValidationDemo.Models;
using Microsoft.EntityFrameworkCore;

namespace FluentAPIValidationDemo.Data
{
    public class UserDbContext : DbContext
    {
        public UserDbContext(DbContextOptions<UserDbContext> options) : base(options) { }

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Seed Gender master data
            modelBuilder.Entity<Gender>().HasData(
                new Gender { GenderId = 1, Name = "Male" },
                new Gender { GenderId = 2, Name = "Female" },
                new Gender { GenderId = 3, Name = "Unknown" }
            );

            // Seed Country master data
            modelBuilder.Entity<Country>().HasData(
                new Country { CountryId = 1, Name = "USA" },
                new Country { CountryId = 2, Name = "India" }
            );

            // Seed City master data
            modelBuilder.Entity<City>().HasData(
                new City { CityId = 1, Name = "New York", CountryId = 1 },
                new City { CityId = 2, Name = "Los Angeles", CountryId = 1 },
                new City { CityId = 3, Name = "Mumbai", CountryId = 2 },
                new City { CityId = 4, Name = "Delhi", CountryId = 2 }
            );

            // Seed initial User data (including extra properties)
            modelBuilder.Entity<User>().HasData(
                new User
                {
                    UserId = 1,
                    FirstName = "Pranaya",
                    LastName = "Rout",
                    Email = "pranaya.rout@example.com",
                    Password = "Secure@123",  // In production, store hashed passwords
                    DateOfBirth = new DateTime(1990, 5, 20),
                    PhoneNumber = "9876543210",
                    Address = "123, Main Street",
                    GenderId = 1,
                    CountryId = 2,
                    CityId = 3
                },
                new User
                {
                    UserId = 2,
                    FirstName = "Hina",
                    LastName = "Sharma",
                    Email = "hina.sharma@example.com",
                    Password = "StrongPass@123",
                    DateOfBirth = new DateTime(1985, 8, 15),
                    PhoneNumber = "1234567890",
                    Address = "456, Park Avenue",
                    GenderId = 2,
                    CountryId = 2,
                    CityId = 4
                }
            );
        }

        public DbSet<User> Users { get; set; }
        public DbSet<Gender> Genders { get; set; }
        public DbSet<Country> Countries { get; set; }
        public DbSet<City> Cities { get; set; }
    }
}
Configure the Database Connection String in the appsettings.json file

To connect our DbContext to the SQL Server database, we need to add the connection string in the appsettings.json file. So, please modify the appsettings.json file as follows. This contains the connection string (UsersDBConnection) required for connecting to the SQL Server database (UsersDB).

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "UsersDBConnection": "Server=LAPTOP-6P5NK25R\\SQLSERVER2022DEV;Database=UsersDB;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}
DbConext Configuration

Please modify the Program.cs class file as follows. This class configures the services used in the application, such as the UserDbContext and controller services. It sets up dependency injection for the database context and integrates Swagger for API documentation.

using FluentAPIValidationDemo.Data;
using Microsoft.EntityFrameworkCore;

namespace FluentAPIValidationDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.

            // Add services to the container.
            builder.Services.AddControllers()
                .AddJsonOptions(options =>
                {
                    // Keep original property names during serialization/deserialization.
                    options.JsonSerializerOptions.PropertyNamingPolicy = null;
                });

            // Register the UserDbContext with dependency injection
            builder.Services.AddDbContext<UserDbContext>(options =>
                options.UseSqlServer(builder.Configuration.GetConnectionString("UsersDBConnection")));

            // Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            // To enable Automatic Fluent API Validation, Please uncomment the following two lines of Code
            // builder.Services.AddFluentValidationAutoValidation();
            // builder.Services.AddValidatorsFromAssemblyContaining<Program>();

            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();

            app.UseAuthorization();

            app.MapControllers();

            app.Run();
        }
    }
}
Generate and Apply Database Migration:

Now, open the Package Manager Console and execute the Add-Migration command to create a new Migration file. Then, execute the Update-Database command to apply the migration and update and sync the database with our models, as shown in the image below.

How to Implement Fluent API Custom Validators in ASP.NET Core Web API Applications with Examples

Once you execute the above commands, it should have created the UsersDB database with the Required tables as shown in the below image:

How to Implement Fluent API Custom Validators in ASP.NET Core Web API Applications

Create the UserDTO for Validation

Create a folder named DTOs. Inside the DTOs folder, create a class file named UserDTO.cs and copy and paste the following code. This class represents the UserDTO model, containing only the fields necessary for user registration. It provides a layer of abstraction between the User entity and incoming data.

namespace FluentAPIValidationDemo.DTOs
{
    public class UserDTO
    {
        // Personal details
        public string FirstName { get; set; }
        public string LastName { get; set; }

        // Contact and security details
        public string Email { get; set; }
        public string Password { get; set; }
        public string ConfirmPassword { get; set; }

        // Demographics
        public DateTime DateOfBirth { get; set; }
        public string PhoneNumber { get; set; }
        public int GenderId { get; set; }

        // Address details (optional)
        public string Address { get; set; }

        // Location details
        public int CountryId { get; set; }
        public int CityId { get; set; } // CityId must belong to the specified Country
    }
}
Create a Validator for UserDTO:

So, the following Property-Level and Object-Level Validations will be implemented in our application.

Property-Level Validations
  • FirstName: The FirstName should not be empty and should contain only letters. Since we are dealing with a single property and validating the value without any database or external API interaction, we need to use the Must method.
  • LastName: LastName should not be empty and should contain only letters. It is the same as the First name, so we need to use the Must method.
  • Email: The email should not be empty and should be in a valid format. It must also be unique in the database. Here, we need to asynchronously check if the Email already exists in the database. Since we are dealing with the Email property only and it involves the database interaction, we need to use the MustAsync method.
  • DateOfBirth: The DateOfBirth property should not be empty and cannot be a future date. We also need to validate that it is at least 18 years before the current date. Again, we are also dealing with a single DateOfBirth property and validating the value without any database or external API interaction, so we need to use the Must method.
  • Gender: The Gender must exist in the database. Asynchronously, we need to check if the Provided gender exists in the Genders table. Since we are dealing with the Gender property and it involves database interaction, we need to use the MustAsync method.
Object-Level Validations
  • Password and ConfirmPassword: We need to make sure Password and ConfirmPassword match. As it involves two properties and does not require any IO Operations to validate data, we need to use the Custom method.
  • Country and City Relationship: We need to ensure the provided Country exists in the database. Here, we need to check asynchronously if the Country exists in the Countries table. Once the Country is valid, we need to check whether the provided City belongs to the specified Country. Again, we need to check asynchronously if the city exists in the list of cities for the given CountryId. Here, we use multiple properties and involve database interaction, so we need to use the CustomAsync method.

First, create a folder named Validators in the project root directory, and inside this folder, create a class file named UserDTOValidator.cs and then copy and paste the following code. This class implements FluentValidation to validate the UserDTO model.

using FluentAPIValidationDemo.Data;
using FluentAPIValidationDemo.DTOs;
using FluentValidation;
using Microsoft.EntityFrameworkCore;

namespace FluentAPIValidationDemo.Validators
{
    public class UserDTOValidator : AbstractValidator<UserDTO>
    {
        private readonly UserDbContext _dbContext;

        public UserDTOValidator(UserDbContext dbContext)
        {
            _dbContext = dbContext;

            // ----------------------------
            // Property-Level Validations
            // ----------------------------

            // Validate FirstName: must not be empty and contain only letters.
            RuleFor(user => user.FirstName)
                .NotEmpty().WithMessage("First Name is required.")
                .Must(name => name.All(char.IsLetter))
                .WithMessage("First Name must contain only letters.");

            // Validate LastName: must not be empty and contain only letters.
            RuleFor(user => user.LastName)
                .NotEmpty().WithMessage("Last Name is required.")
                .Must(name => name.All(char.IsLetter))
                .WithMessage("Last Name must contain only letters.");

            // Validate Email: must not be empty, follow valid email format, and be unique.
            RuleFor(user => user.Email)
                .NotEmpty().WithMessage("Email is required.")
                .EmailAddress().WithMessage("Email must be in a valid format.")
                .MustAsync(async (email, cancellationToken) =>
                {
                    // Check database to ensure email uniqueness.
                    return !await _dbContext.Users.AnyAsync(u => u.Email == email, cancellationToken);
                })
                .WithMessage("Email must be unique.");

            // Validate Password: must not be empty.
            RuleFor(user => user.Password)
                .NotEmpty().WithMessage("Password is required.");

            // Validate PhoneNumber: basic check to ensure it's provided.
            RuleFor(user => user.PhoneNumber)
                .NotEmpty().WithMessage("Phone Number is required.");

            // Validate Address: optional field, but limit the maximum length if provided.
            RuleFor(user => user.Address)
                .MaximumLength(200).WithMessage("Address cannot exceed 200 characters.");

            // Validate DateOfBirth: must not be empty, cannot be a future date, and user must be at least 18 years old.
            RuleFor(user => user.DateOfBirth)
                .NotEmpty().WithMessage("Date of Birth is required.")
                .LessThanOrEqualTo(DateTime.Now).WithMessage("Date of Birth cannot be a future date.")
                .Must(BeAtLeast18YearsOld)
                .WithMessage("User must be at least 18 years old.");

            // Validate GenderId: asynchronously check that the provided Gender exists.
            RuleFor(user => user.GenderId)
                .MustAsync(IsValidGender)
                .WithMessage("The specified Gender does not exist.");

            // ----------------------------
            // Object-Level Validations
            // ----------------------------

            // Validate that Password and ConfirmPassword match.
            RuleFor(user => user)
                .Custom((dto, context) =>
                {
                    if (dto.Password != dto.ConfirmPassword)
                    {
                        // Associate the error with ConfirmPassword property.
                        context.AddFailure("ConfirmPassword", "Password and ConfirmPassword must match.");
                    }
                });

            // Validate Country and City relationship:
            // 1. Ensure the Country exists.
            // 2. Check that the specified City belongs to that Country.
            RuleFor(user => user)
                .CustomAsync(async (dto, context, cancellationToken) =>
                {
                    // Retrieve the country including its list of cities.
                    var country = await _dbContext.Countries
                        .Include(c => c.Cities)
                        .AsNoTracking()
                        .FirstOrDefaultAsync(c => c.CountryId == dto.CountryId, cancellationToken);

                    if (country == null)
                    {
                        context.AddFailure("CountryId", "The selected country does not exist.");
                    }
                    else if (!country.Cities.Any(city => city.CityId == dto.CityId))
                    {
                        context.AddFailure("CityId", $"The selected city does not belong to the country '{country.Name}'.");
                    }
                });
        }

        // Helper method to check if the user is at least 18 years old.
        private bool BeAtLeast18YearsOld(DateTime dob)
        {
            return dob <= DateTime.Now.AddYears(-18);
        }

        // Asynchronous method to check if the provided GenderId exists in the database.
        private async Task<bool> IsValidGender(int genderId, CancellationToken cancellationToken)
        {
            return await _dbContext.Genders.AnyAsync(g => g.GenderId == genderId, cancellationToken);
        }
    }
}
Create the Web API Controller:

Create an Empty API controller named UsersController within the Controllers folder and then copy and paste the following code. This API controller contains an endpoint to register users by validating the input data using UserDTOValidator. Maps the validated DTO to the User entity and saves it to the database.

using FluentAPIValidationDemo.Data;
using FluentAPIValidationDemo.DTOs;
using FluentAPIValidationDemo.Models;
using FluentAPIValidationDemo.Validators;
using Microsoft.AspNetCore.Mvc;

namespace FluentAPIValidationDemo.Controllers
{
    [ApiController]
    [Route("api/[controller]")]
    public class UsersController : ControllerBase
    {
        private readonly UserDbContext _dbContext;

        public UsersController(UserDbContext dbContext)
        {
            _dbContext = dbContext;
        }

        // Registers a new user after validating the provided details.
        [HttpPost]
        public async Task<ActionResult<User>> RegisterUser([FromBody] UserDTO createUserDTO)
        {
            // Initialize the validator with the current DbContext.
            var validator = new UserDTOValidator(_dbContext);
            var validationResult = await validator.ValidateAsync(createUserDTO);

            // If validation fails To Return complete error response
            //if (!validationResult.IsValid)
            //{
            //    return BadRequest(validationResult.Errors);
            //}

            // If validation fails, map errors to a simplified response and return a 400 Bad Request.
            if (!validationResult.IsValid)
            {
                var errorResponse = validationResult.Errors.Select(e => new
                {
                    Field = e.PropertyName,
                    Error = e.ErrorMessage
                });

                return BadRequest(new { Errors = errorResponse });
            }

            // Map the validated DTO to the User entity.
            var user = new User
            {
                FirstName = createUserDTO.FirstName,
                LastName = createUserDTO.LastName,
                Email = createUserDTO.Email,
                Password = createUserDTO.Password, 
                DateOfBirth = createUserDTO.DateOfBirth,
                PhoneNumber = createUserDTO.PhoneNumber,
                Address = createUserDTO.Address,
                GenderId = createUserDTO.GenderId,
                CountryId = createUserDTO.CountryId,
                CityId = createUserDTO.CityId,
            };

            // Add the new user to the database.
            await _dbContext.Users.AddAsync(user);
            await _dbContext.SaveChangesAsync();

            // Return the created user as a response.
            return Ok(user);
        }
    }
}
Testing API Endpoint:

To thoroughly test the API endpoint (POST /api/users), we will provide valid and invalid request examples and the expected responses.

Valid Scenario:

The following is a valid request body.

{
  "FirstName": "Michael",
  "LastName": "Smith",
  "Email": "michael.smith@example.com",
  "Password": "SecurePass1!",
  "ConfirmPassword": "SecurePass1!",
  "DateOfBirth": "1990-05-20",
  "PhoneNumber": "1234567890",
  "Address": "789, Sunset Blvd",
  "GenderId": 1,
  "CountryId": 1,
  "CityId": 2
}

Response in Swagger: A successful 200 OK response with the created user object.

Must, MustAsync, Custom, and CustomAsync Fluent API Methods

Invalid Scenario:

The following is an invalid request body, demonstrating different validation failures.

{
  "FirstName": "Ana",
  "LastName": "Doe@123",
  "Email": "pranaya.rout@example.com",  
  "Password": "password",
  "ConfirmPassword": "SecurePass1!",
  "DateOfBirth": "2010-01-01",
  "PhoneNumber": "12345",
  "Address": "This address",
  "GenderId": 11,
  "CountryId": 1,
  "CityId": 3
}

Response in Swagger: A 400 Bad Request with a list of errors detailing issues such as invalid last name, mismatched passwords, non-unique email/username, underage date of birth, invalid gender, and an incorrect city-country relationship.

Fluent API Custom Validators in ASP.NET Core Web API

When to Use Which Methods?
  • Must / MustAsync: Use these methods for property-level validations. Use Must for simple, synchronous validations and MustAsync for validations requiring external calls (e.g., checking uniqueness in the database).
  • Custom / CustomAsync: Use these for object-level validations where multiple properties need to be compared (e.g., ensuring Password and ConfirmPassword match or validating that a city belongs to a specified country). Use CustomAsync if the logic involves asynchronous operations.

This example demonstrates implementing robust, industry-standard validation logic in an ASP.NET Core Web API using FluentValidation with synchronous and asynchronous custom validators.

In the next article, I will discuss How to Implement Fluent API Conditional Validations in ASP.NET Core Web API with Examples. In this article, I explain How to Implement Fluent API Custom Validators in ASP.NET Core Web API Applications with Examples. I hope you enjoy this article, How to Implement Fluent API Custom Validators in ASP.NET Core Web API.

Leave a Reply

Your email address will not be published. Required fields are marked *