Fluent API Async Validators in ASP.NET Core Web API

Fluent API Async Validators in ASP.NET Core Web API

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

What Is Fluent API Async Validator?

Fluent API Async Validators are part of the FluentValidation library. They allow us to perform validation checks asynchronously, which is beneficial when the validation logic involves external data sources or long-running operations. Common scenarios include validating against a database (e.g., checking for duplicate entries), querying an external API, or applying business rules that require complex calculations. Asynchronous validation ensures these checks do not block the main request thread, helping our application remain responsive and scalable.

When Should We Use Fluent API Async Validators in ASP.NET Core?

Async validators are best suited for I/O-bound or long-running operations. Use asynchronous validation when:

  • Database Checks: Ensuring an email or username is unique. Verifying referential integrity (checking foreign keys exist).
  • External API Validation: When we need to validate something using an external API.
  • Complex Calculations: Performing resource-intensive calculations without blocking the main thread.
How to Use Fluent API Async Validators

FluentValidation provides the MustAsync() and CustomAsync() methods, which allow us to define async validation rules. You can use them in scenarios where you need to validate input against external resources or apply complex logic that can’t run synchronously.

Real-time Example to Understand Fluent API Async Validators in ASP.NET Core Web API:

Let us understand Fluent API Async Validators with one Real-time Example in ASP.NET Core Web API Application. We will create an e-commerce application to add a new product to the catalog. Before adding the product, the system needs to:

  • Ensure the Product Name is Unique (database check).
  • Ensure the provided Category Exists in the database (foreign key check).
  • Perform Complex Calculation to check if a given discount is valid based on product price (complex business rule based on product price).
  • Other synchronous validations, such as non-empty fields, a price greater than zero, and a discount within a specific range, should be included.

The following is a step-by-step implementation of this e-commerce application, including DTOs, Entities, DbContext, Validators, and an API Controller using ASP.NET Core Web API, Entity Framework Core, and SQL Server Database.

Project Setup:

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
Create Product and Category Models

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

Category Entity:

Create a class file named Category.cs within the Models folder, and then copy and paste the following code. This class represents the Category entity, which is used to categorize products. It has properties such as CategoryId, CategoryName, and Description. The Entity Framework will use this class to map to a database table, and the CategoryId will serve as a foreign key in the Product table.

namespace FluentAPIValidationDemo.Models
{
    // Represents a product category (e.g., Electronics, Books, Home Appliances)
    public class Category
    {
        public int CategoryId { get; set; } // Primary Key
        public string CategoryName { get; set; } // Name of the category
        public string? Description { get; set; } // Optional description of the category

        // Navigation property: A category can have multiple products.
        public List<Product> Products { get; set; }
    }
}
Product Entity:

Create a class file named Product.cs within the Models folder, and then copy and paste the following code. This class represents the Product entity, which contains product information like Name, Price, CategoryId, Discount, Description, etc. This class will be mapped to the Products table in the database.

using System.ComponentModel.DataAnnotations.Schema;

namespace FluentAPIValidationDemo.Models
{
    // Represents a product in the e-commerce system.
    public class Product
    {
        public int ProductId { get; set; } // Primary Key
        public string Name { get; set; } // Unique product name
        public string SKU { get; set; }  // Unique Stock Keeping Unit
        [Column(TypeName = "decimal(10,2)")]
        public decimal Price { get; set; } // Product price
        public int CategoryId { get; set; } // Foreign key to Category
        public int Stock { get; set; } // Inventory quantity
        public string? Description { get; set; } // Optional product description
        [Column(TypeName = "decimal(10,2)")]
        public decimal Discount { get; set; } // Discount percentage
        // Navigation property: The related category for the product.
        public Category Category { get; set; }
    }
}
DiscountRule Entity:

Create a class file named DiscountRule.cs within the Models folder, and then copy and paste the following code. This class represents the DiscountRule entity, which contains the rules for applying discounts based on the product’s price. It has properties MinimumPrice (the minimum price for which the rule applies) and MaximumDiscount (the maximum discount allowed for products that meet the MinimumPrice). This will enforce the business logic for validating the discount applied to products.

using System.ComponentModel.DataAnnotations.Schema;

namespace FluentAPIValidationDemo.Models
{
    // Defines discount rules based on the product's price.
    public class DiscountRule
    {
        public int DiscountRuleId { get; set; } // Primary Key

        [Column(TypeName = "decimal(10,2)")]
        public decimal MinimumPrice { get; set; } // Minimum price to apply this rule

        [Column(TypeName = "decimal(10,2)")]
        public decimal MaximumDiscount { get; set; } // Maximum discount allowed for products meeting the minimum price
    }
}
Create DbContext Class

First, create a folder named Data in the project root directory. Inside the Data folder, create a class file named ECommerceDbContext.cs and copy and paste the following code. This class is the Entity Framework Core DbContext that facilitates communication with the database. It contains DbSet properties for Product, Category, and DiscountRule entities. This class also handles model seeding (creating default values for Category and DiscountRule tables), useful during development or testing.

using FluentAPIValidationDemo.Models;
using Microsoft.EntityFrameworkCore;

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

        protected override void OnModelCreating(ModelBuilder modelBuilder)
        {
            // Seed sample data for Categories
            modelBuilder.Entity<Category>().HasData(
                new Category { CategoryId = 1, CategoryName = "Electronics", Description = "Electronic gadgets and devices" },
                new Category { CategoryId = 2, CategoryName = "Books", Description = "Various genres of books" },
                new Category { CategoryId = 3, CategoryName = "Home Appliances", Description = "Appliances for everyday home use" }
            );

            // Seed sample data for DiscountRules
            modelBuilder.Entity<DiscountRule>().HasData(
                new DiscountRule { DiscountRuleId = 1, MinimumPrice = 100, MaximumDiscount = 10 },
                new DiscountRule { DiscountRuleId = 2, MinimumPrice = 500, MaximumDiscount = 20 },
                new DiscountRule { DiscountRuleId = 3, MinimumPrice = 999, MaximumDiscount = 30 }
            );

            // Seed sample data for Products (including SKU and Stock)
            modelBuilder.Entity<Product>().HasData(
                new Product
                {
                    ProductId = 1,
                    Name = "Novel",
                    SKU = "BKN-001",
                    Price = 50,
                    CategoryId = 2,
                    Stock = 100,
                    Discount = 0,
                    Description = "Bestselling novel with an intriguing plot"
                },
                new Product
                {
                    ProductId = 2,
                    Name = "Microwave",
                    SKU = "APPL-001",
                    Price = 150,
                    CategoryId = 3,
                    Stock = 50,
                    Discount = 10,
                    Description = "Compact microwave oven suitable for small kitchens"
                },
                new Product
                {
                    ProductId = 3,
                    Name = "Smartphone",
                    SKU = "ELEC-001",
                    Price = 800,
                    CategoryId = 1,
                    Stock = 30,
                    Discount = 20,
                    Description = "Latest model smartphone with advanced features"
                },
                new Product
                {
                    ProductId = 4,
                    Name = "Laptop",
                    SKU = "ELEC-002",
                    Price = 1200,
                    CategoryId = 1,
                    Stock = 20,
                    Discount = 30,
                    Description = "High-performance laptop for gaming and productivity"
                }
            );
        }

        // DbSet properties for querying and saving instances of each entity.
        public DbSet<Product> Products { get; set; }
        public DbSet<Category> Categories { get; set; }
        public DbSet<DiscountRule> DiscountRules { get; set; }
    }
}
Create Product DTO for Validation

Create a folder named DTOs. Inside the DTOs folder, create a class file named ProductDTO.cs and copy and paste the following code. This Data Transfer Object (DTO) class represents the data sent from the client for creating a new product.

namespace FluentAPIValidationDemo.DTOs
{
    // Data Transfer Object for creating a new Product.
    public class ProductDTO
    {
        public string Name { get; set; } // Product name
        public string SKU { get; set; }  // Unique identifier for the product
        public decimal Price { get; set; } // Product price
        public int CategoryId { get; set; } // Foreign key to Category
        public int Stock { get; set; } // Available inventory quantity
        public string? Description { get; set; } // Optional product description
        public decimal Discount { get; set; } // Discount percentage on the product
    }
}
Create a Validator for ProductDTO:

Next, create a folder named Validators in the project root directory, and inside this folder, create a class file named ProductDTOValidator.cs. This validator uses FluentValidation to ensure that the incoming ProductDTO is valid. It includes both synchronous rules (e.g., non-empty fields, price must be greater than zero) and asynchronous rules (e.g., uniqueness checks and discount validation based on external data). The async rules ensure that:

  • Name and SKU are unique.
  • The Category exists.
  • The Discount is valid based on discount rules.

So, once you create the ProductDTOValidator class, please copy and paste the following code. The following code is self-explained, so please read the comment lines for a better understanding.

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

namespace FluentAPIValidationDemo.Validators
{
    // Validator for ProductDTO using FluentValidation.
    public class ProductDTOValidator : AbstractValidator<ProductDTO>
    {
        private readonly ECommerceDbContext _context;

        // Inject the DbContext for performing async validations.
        public ProductDTOValidator(ECommerceDbContext context)
        {
            _context = context;

            // Validate the 'Name' property:
            RuleFor(x => x.Name)
                .NotEmpty().WithMessage("Product name is required.")
                .Length(3, 100).WithMessage("Product name must be between 3 and 100 characters.")
                .MustAsync(BeUniqueNameAsync).WithMessage("Product name must be unique.");

            // Validate the 'SKU' property:
            RuleFor(x => x.SKU)
                .NotEmpty().WithMessage("SKU is required.")
                .Length(3, 20).WithMessage("SKU must be between 3 and 20 characters.")
                .MustAsync(BeUniqueSKUAsync).WithMessage("SKU must be unique.");

            // Validate the 'Price' property:
            RuleFor(x => x.Price)
                .GreaterThan(0).WithMessage("Price must be greater than zero.");

            // Validate the 'Stock' property:
            RuleFor(x => x.Stock)
                .GreaterThanOrEqualTo(0).WithMessage("Stock cannot be negative.");

            // Validate the 'CategoryId' property:
            RuleFor(x => x.CategoryId)
                .MustAsync(CategoryExistsAsync).WithMessage("Category does not exist.");

            // Validate the 'Discount' property:
            RuleFor(x => x.Discount)
                .InclusiveBetween(0, 50).WithMessage("Discount must be between 0 and 50%.")
                .MustAsync(IsValidDiscountBasedOnRuleAsync).WithMessage("Discount is not valid for the given product price.");
        }

        // Checks asynchronously that the product name is unique in the database.
        private async Task<bool> BeUniqueNameAsync(string productName, CancellationToken cancellationToken)
        {
            return !await _context.Products.AsNoTracking()
                .AnyAsync(p => p.Name == productName, cancellationToken);
        }

        // Checks asynchronously that the SKU is unique in the database.
        private async Task<bool> BeUniqueSKUAsync(string sku, CancellationToken cancellationToken)
        {
            return !await _context.Products.AsNoTracking()
                .AnyAsync(p => p.SKU == sku, cancellationToken);
        }

        // Checks asynchronously that the provided CategoryId exists.
        private async Task<bool> CategoryExistsAsync(int categoryId, CancellationToken cancellationToken)
        {
            return await _context.Categories.AsNoTracking()
                .AnyAsync(c => c.CategoryId == categoryId, cancellationToken);
        }

        // Asynchronously verifies that the discount is valid based on applicable discount rules.
        private async Task<bool> IsValidDiscountBasedOnRuleAsync(ProductDTO product, decimal discount, CancellationToken cancellationToken)
        {
            // Retrieve the most appropriate discount rule for the given product price.
            var discountRule = await _context.DiscountRules
                .AsNoTracking()
                .Where(rule => product.Price >= rule.MinimumPrice)
                .OrderByDescending(rule => rule.MinimumPrice)
                .FirstOrDefaultAsync(cancellationToken);

            if (discountRule == null)
            {
                // No discount rule applies if the product price is below all defined thresholds.
                return false;
            }

            // The discount is valid if it does not exceed the maximum discount allowed by the rule.
            return discount <= discountRule.MaximumDiscount;
        }
    }
}
Fluent API Asynchronous Validation with MustAsync():
  • BeUniqueNameAsync ensures that the product name is unique in the database.
  • BeUniqueSKUAsync ensures that the product SKU is unique in the database.
  • CategoryExistsAsync checks if the provided CategoryId exists, preventing foreign key violations.
  • IsValidDiscountBasedOnRuleAsync checks if the discount is acceptable based on product price, preventing an unrealistic discount rate.
Configure the Database Connection String in the appsettings.json file

To connect our DbContext to a database, we need to add a connection string in our appsettings.json file. So, please modify the appsettings.json file as follows:

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "ECommerceDBConnection": "Server=LAPTOP-6P5NK25R\\SQLSERVER2022DEV;Database=ECommerceDB;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}
Service and Middleware Configuration:

Next, we need to register services in Program.cs to enable dependency injection. Please modify the Program.cs class file as follows. It configures services (such as adding the DbContext, setting up controllers, and enabling Swagger for API documentation) and the request pipeline. It also registers the ProductDTOValidator into the dependency injection container.

using FluentAPIValidationDemo.Data;
using FluentAPIValidationDemo.DTOs;
using FluentAPIValidationDemo.Validators;
using FluentValidation;
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 ECommerceDbContext with dependency injection
            builder.Services.AddDbContext<ECommerceDbContext>(options =>
                options.UseSqlServer(builder.Configuration.GetConnectionString("ECommerceDBConnection")));

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

            // Register Each Validator Manually
            builder.Services.AddScoped<IValidator<ProductDTO>, ProductDTOValidator>();

            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.

Fluent API Async Validators in ASP.NET Core Web API

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

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

Create the ProductsController:

Create an Empty API controller named ProductsController within the Controllers folder and then copy and paste the following code. This controller is responsible for handling API requests related to products. It includes a CreateProduct action that accepts a ProductDTO and validates it using the validator injected via the constructor. A new product will be created in the database if the validation passes. It returns a 400 Bad Request with validation error details if validation fails.

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

namespace FluentAPIValidationDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        private readonly ECommerceDbContext _context;
        private readonly IValidator<ProductDTO> _validator;

        // Inject the ECommerceDbContext and Validator via constructor injection.
        public ProductsController(ECommerceDbContext context, IValidator<ProductDTO> validator)
        {
            _context = context;
            _validator = validator;
        }

        // POST: api/products
        // Creates a new product after validating the input data.
        [HttpPost]
        public async Task<IActionResult> CreateProduct([FromBody] ProductDTO productDTO)
        {
            // Validate the request using the injected validator
            var validationResult = await _validator.ValidateAsync(productDTO);

            // If validation fails, return a 400 Bad Request with error details.
            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 a Product entity.
            var product = new Product
            {
                Name = productDTO.Name,
                SKU = productDTO.SKU,
                Price = productDTO.Price,
                CategoryId = productDTO.CategoryId,
                Stock = productDTO.Stock,
                Discount = productDTO.Discount,
                Description = productDTO.Description,
            };

            // Add the new product to the database context.
            _context.Products.Add(product);

            // Save changes asynchronously.
            await _context.SaveChangesAsync();

            // Return a 200 OK response along with the newly created product.
            return Ok(product);
        }
    }
}
Testing the Application:

I will provide two scenarios for testing the AddProduct endpoint to ensure the correct behavior of both successful and failed validation cases. These scenarios will cover situations such as validating unique product names and SKUs, checking the existence of a category, and ensuring the discount is acceptable based on discount rules.

Scenario 1: Successful Product Creation

In this scenario, all the fields are valid:

  • Unique product name: The name is different from the seeded product names.
  • Valid category: The category exists in the seeded data.
  • Acceptable discount: The discount is within the allowable limit for the given product price.
Test Request:

HTTP Method: POST
Endpoint: /api/products
Request Body:

{
  "Name": "Tablet",
  "SKU": "ELEC-003",
  "Price": 600,
  "CategoryId": 1,
  "Stock": 25,
  "Discount": 15,
  "Description": "A high-resolution tablet suitable for both work and entertainment."
}

Expected Response: The product is created successfully and returned in the response.

What Is Fluent API Async Validator?

Scenario 2: Failed Product Creation

In this scenario, multiple validation errors occur:

  • The product name and SKU are not unique.
  • The category ID does not exist.
  • The discount is too high for the given product price.
Test Request:

HTTP Method: POST
Endpoint: /api/products
Request Body:

{
  "Name": "Laptop",  
  "SKU": "ELEC-002", 
  "Price": 100,
  "CategoryId": 5,   
  "Stock": 10,
  "Discount": 50,
  "Description": "A budget laptop"
}

Expected Response: The API returns a 400 Bad Request with validation errors for duplicate Name/SKU, invalid CategoryId, and discount exceeding the allowed limit for the given price.

How to Use Fluent API Async Validators in ASP.NET Core Web API

How Discount Rules Work in Real-time Application?

Discount Rules serve as dynamic business logic that decides how discounts are applied based on various factors, most commonly, the product’s price.

Discount rules allow businesses to define conditions (like a minimum product price) and corresponding limits (maximum discount percentages). For example, a rule might specify that the maximum discount allowed for any product priced above $500 is 20%. This approach lets businesses control promotions without hardcoding values into your application logic.

Example in a Real-Time Application:

Imagine an online store where products have varying prices. When a seller attempts to add a product:

  • The system checks if the product price qualifies for any discount rules.
  • If the product price is $800, the system retrieves the rule that applies (e.g., maximum discount of 20%).
  • If the seller inputs a 25% discount, the system immediately flags it as invalid, prompting the seller to adjust the discount.
  • This check is performed asynchronously, ensuring the user interface remains responsive even as the system queries current discount rules from the database.
Manually Handling the Validation

Now, let us see how we can create the validator manually and validate the ProductDTO. So, please modify the ProductsController as follows. Now, we are not injecting the Validator instance through the constructor; instead, we are creating an instance of ProductDTOValidator, and using that instance, we validate the ProductDTO Object.

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

namespace FluentAPIValidationDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class ProductsController : ControllerBase
    {
        private readonly ECommerceDbContext _context;

        // Inject the ECommerceDbContext via constructor injection.
        public ProductsController(ECommerceDbContext context)
        {
            _context = context;
        }

        // POST: api/products
        // Creates a new product after validating the input data.
        [HttpPost]
        public async Task<IActionResult> CreateProduct([FromBody] ProductDTO productDTO)
        {
            // Instantiate the validator with the DbContext for performing async validations.
            var validator = new ProductDTOValidator(_context);

            // Validate the incoming ProductDTO asynchronously.
            var validationResult = await validator.ValidateAsync(productDTO);

            // If validation fails, return a 400 Bad Request with error details.
            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 a Product entity.
            var product = new Product
            {
                Name = productDTO.Name,
                SKU = productDTO.SKU,
                Price = productDTO.Price,
                CategoryId = productDTO.CategoryId,
                Stock = productDTO.Stock,
                Discount = productDTO.Discount,
                Description = productDTO.Description,
            };

            // Add the new product to the database context.
            _context.Products.Add(product);

            // Save changes asynchronously.
            await _context.SaveChangesAsync();

            // Return a 200 OK response along with the newly created product.
            return Ok(product);
        }
    }
}

Now, you can comment on the following statement in the Program class, which registers the ProductDTO and the corresponding ProductDTOValidator into the dependency injection container and tests the endpoint. It should work as expected.

builder.Services.AddScoped<IValidator<ProductDTO>, ProductDTOValidator>();

Benefits of Using Fluent API Async Validators in ASP.NET Core Web API

Fluent API Async Validators offer several benefits, especially when validation logic involves external dependencies like databases or external services. Some of the key benefits are:

  • Non-blocking Operations: Async validators ensure that lengthy database or external API calls do not block the main thread.
  • Enhanced Scalability: Async validations prevent thread blocking, allowing the server to handle more concurrent requests.
  • Complex Business Rules: Easily incorporate complex validations (like unique constraints and discount logic) that depend on external data.
  • Improved User Experience: Quick, non-blocking validations result in a smoother client experience.

With asynchronous validations for uniqueness, foreign key existence, and complex business rules alongside synchronous validations, the application ensures robust data integrity and effectively enforces business constraints.

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

Leave a Reply

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