Validation is an important part of building reliable ASP.NET Core applications. Before saving a customer, creating an account, or processing an order, applications need to verify that the incoming data meets the required business rules.
Some validation rules are simple. Checking whether an email address is empty or whether an age falls within an acceptable range does not require a database call. However, other rules need to communicate with external systems or query a database.
For example, when registering a new user, an application may need to check whether the email address already exists. Similarly, creating an order may require checking product availability or verifying whether a customer has an active subscription.
These checks can become expensive when they are performed repeatedly or without proper asynchronous execution. Even when developers use async and await, validation can still introduce unnecessary database queries and increase API response times.
In this article, we will explore how asynchronous validation works in ASP.NET Core, why database calls can make validation slow, and how to improve validation performance using Entity Framework Core and FluentValidation.
Why Database Calls During Validation Can Become Slow
Consider a user registration API that checks whether an email address already exists before creating a new account.
A simple implementation might look like this:
public async Task<bool> IsEmailExistsAsync(string email)
{
return await _context.Users
.AnyAsync(x => x.Email == email);
}This method uses AnyAsync, which checks whether a matching record exists without loading the complete user entity.
Although this is better than retrieving an entire record, database validation can still become a performance problem in certain situations.
Common reasons include:
- Multiple validation rules execute separate database queries.
- The same database condition is checked more than once.
- Database columns used for validation do not have appropriate indexes.
- Validation queries retrieve more information than necessary.
- Database calls are executed sequentially when independent checks could run concurrently.
- The application performs synchronous database operations inside an asynchronous request pipeline.
For example, a registration request might check email availability, phone number availability, and username availability. If each check makes a separate database call, the total validation time can increase.
The solution is not simply to add async and await everywhere. Developers also need to examine how many queries are executed and whether each query is necessary.
Understanding Asynchronous Validation in ASP.NET Core
Asynchronous validation allows an application to perform operations that involve waiting for external resources without unnecessarily blocking a thread.
Consider the difference between synchronous and asynchronous database calls.
A synchronous query:
bool exists = _context.Users
.Any(x => x.Email == email);An asynchronous query:
bool exists = await _context.Users
.AnyAsync(x => x.Email == email);The asynchronous version allows the request thread to return to the thread pool while the database operation is waiting for a response.
When the database operation completes, the application continues processing the request.
This is particularly useful in ASP.NET Core applications that handle multiple concurrent requests.
However, asynchronous execution does not make the database query itself faster. It improves how application threads are used while waiting for I/O operations.
Microsoft recommends using asynchronous APIs for database and other I/O operations in ASP.NET Core applications.
Implementing Async Validation With FluentValidation
FluentValidation is a popular library for defining validation rules separately from application models.
It supports asynchronous validation through methods such as MustAsync and CustomAsync.
Let's implement a practical registration example.
Step 1. Create the Registration Model
First, define a model containing the information required to register a user.
public sealed class RegisterUserRequest
{
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string PhoneNumber { get; set; } = string.Empty;
}Step 2. Create a User Repository
Instead of writing database queries directly inside the validator, keep database access in a separate service.
public interface IUserRepository
{
Task<bool> EmailExistsAsync(
string email,
CancellationToken cancellationToken);
Task<bool> PhoneExistsAsync(
string phoneNumber,
CancellationToken cancellationToken);
}Now implement the repository using Entity Framework Core.
public sealed class UserRepository : IUserRepository
{
private readonly AppDbContext _context;
public UserRepository(AppDbContext context)
{
_context = context;
}
public Task<bool> EmailExistsAsync(
string email,
CancellationToken cancellationToken)
{
return _context.Users
.AnyAsync(
x => x.Email == email,
cancellationToken);
}
public Task<bool> PhoneExistsAsync(
string phoneNumber,
CancellationToken cancellationToken)
{
return _context.Users
.AnyAsync(
x => x.PhoneNumber == phoneNumber,
cancellationToken);
}
}Using AnyAsync avoids loading complete user records when the application only needs to know whether a matching record exists.
The cancellation token also allows the database operation to be cancelled when the request is no longer needed.
Step 3. Create an Asynchronous Validator
Now define the registration validator.
public sealed class RegisterUserValidator
: AbstractValidator<RegisterUserRequest>
{
public RegisterUserValidator(IUserRepository repository)
{
RuleFor(x => x.Name)
.NotEmpty()
.MaximumLength(100);
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(async (email, cancellationToken) =>
{
return !await repository.EmailExistsAsync(
email,
cancellationToken);
})
.WithMessage("Email address is already registered.");
RuleFor(x => x.PhoneNumber)
.NotEmpty()
.MustAsync(async (phone, cancellationToken) =>
{
return !await repository.PhoneExistsAsync(
phone,
cancellationToken);
})
.WithMessage("Phone number is already registered.");
}
}The validator separates business validation rules from the controller.
The Name rule performs local validation, while the Email and PhoneNumber rules execute asynchronous database checks.
Passing the cancellation token is important because it allows the underlying database operation to respond to cancellation requests.
Step 4. Register Dependencies
Register the repository and validator in the application's dependency injection container.
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<
IValidator<RegisterUserRequest>,
RegisterUserValidator>();The scoped lifetime is appropriate because the repository uses an Entity Framework Core DbContext, which is normally registered with a scoped lifetime.
Step 5. Execute Validation Asynchronously in the Controller
One important detail is how the validator is executed.
Join the conversation! Your thoughts help the community grow.