Backend Validation in .NET 8 GUIDE: How to Add Custom Validation to the Request Body
Backend validation is a crucial part of developing robust web applications. It ensures the data received from users meets certain criteria before it’s processed or stored. In .NET 8, backend validation is both powerful and straightforward, thanks to built-in support and intuitive validation tools. This guide will explore how to validate incoming data in the request body of your web applications using .NET Core 8.
Why Validate on the Backend?
Frontend validation improves user experience by providing immediate feedback. Still, backend validation remains essential for security and data integrity. Malicious users can easily bypass frontend validation, making backend validation a critical line of defense.
Defining the DTO
A Data Transfer Object (DTO) is a simple, serializable object used solely to transfer data between the client and the server. DTOs ensure that the structure of data sent or received is explicitly defined, separate from your database or domain models. Here is an example of defining a DTO for user registration:
public class UserRegistrationDTO
{
public string Username { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}
DTOs typically reside in a dedicated folder or namespace such as 'DTOs' within your project structure, clearly distinguishing them from domain entities or database models.
Implementing Validation
.NET provides built-in validation attributes in the 'System.ComponentModel.DataAnnotations' namespace. Enhance your DTO with validation attributes:
using System.ComponentModel.DataAnnotations;
public class UserRegistrationDTO
{
[Required]
[StringLength(20, MinimumLength = 5)]
public string Username { get; set; }
[Required]
[EmailAddress]
public string Email { get; set; }
[Required]
[MinLength(8)]
[RegularExpression("^(?=.*[0-9]).+$", ErrorMessage = "Password must contain at least one numeric digit.")]
public string Password { get; set; }
}
Additional common attributes include:
'[Range(min, max)]': Ensures numeric data falls within specified bounds.'[RegularExpression(pattern)]': Enforces a specific pattern using regular expressions.'[Compare("PropertyName")]': Ensures the value matches another property value (useful for password confirmation).
Handling Validation in Controllers
Your API controllers use '[ApiController]' by default, which automatically handles model validation:
using Microsoft.AspNetCore.Mvc;
[Route("api/[controller]")]
[ApiController]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult RegisterUser([FromBody] UserRegistrationDTO user)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
// Proceed with saving the user to the database
return Ok(new { Message = "User registered successfully." });
}
}
Although '[ApiController]' automatically returns a '400 Bad Request' if validation fails, explicitly checking 'ModelState.IsValid' is helpful for scenarios requiring custom response logic.
Custom Validation
Built-in validators may not always suffice. You can create custom validators by inheriting from 'ValidationAttribute':
public class PasswordMustContainNumberAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext context)
{
var password = value as string;
if (string.IsNullOrEmpty(password) || !password.Any(char.IsDigit))
{
return new ValidationResult("Password must contain at least one number.");
}
return ValidationResult.Success;
}
}
Then, use it in your DTO:
[Required]
[MinLength(8)]
[PasswordMustContainNumber]
public string Password { get; set; }
Why Validate Both at Application and Database Levels?
Although your database also performs validations (such as not-null constraints or length checks), applying validation at the application level:
- Provides immediate feedback to users.
- Reduces unnecessary database calls.
- Enhances security by preventing malicious or incorrect data early.
- Clearly documents API requirements for developers.
Validation Messages and Localization
Clear, informative validation messages enhance user experience. Customize validation messages with attributes:
[Required(ErrorMessage = "Username is required.")]
[StringLength(20, MinimumLength = 5, ErrorMessage = "Username must be between 5 and 20 characters.")]
public string Username { get; set; }
Localization can be achieved via resource files, adapting messages to various languages dynamically.
Advanced Validation with FluentValidation
As your validation needs become more complex, you may find that attributes alone aren’t enough.
Libraries can alleviate this concern.
One such popular library is FluentValidation. Install it via NuGet:
dotnet add package FluentValidation.AspNetCore
Then you define a validator class for your DTO:
using FluentValidation;
public class UserRegistrationValidator : AbstractValidator<UserRegistrationDTO>
{
public UserRegistrationValidator()
{
RuleFor(x => x.Username)
.NotEmpty()
.Length(5, 20);
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.Matches("[0-9]").WithMessage("Password must contain at least one number.");
}
}
Next, register the validators in your application in Program.cs:
builder.Services.AddControllers()
.AddFluentValidation(fv => fv.RegisterValidatorsFromAssemblyContaining<UserRegistrationValidator>());
This tells ASP.NET Core to look for any validator classes, such as 'UserRegistrationValidator', and connect them to the controller system automatically.
When it finds a class that inherits from 'AbstractValidator<T>', it wires it into the validation pipeline. This means that whenever a controller receives a DTO like 'UserRegistrationDTO', FluentValidation runs behind the scenes to enforce the rules you've defined. If the data doesn’t meet those rules, the request is rejected automatically with a 400 Bad Request response, and your controller action won’t execute.
[Route("api/[controller]")]
[ApiController]
public class UsersController : ControllerBase
{
[HttpPost]
public IActionResult RegisterUser([FromBody] UserRegistrationDTO user)
{
// At this point, FluentValidation has already validated the incoming DTO.
// If validation fails, ASP.NET Core returns a 400 response automatically.
return Ok(new { Message = "User registered successfully." });
}
}
Even when using FluentValidation, your controller continues to receive the request body as a DTO—like 'UserRegistrationDTO'. The role of the DTO doesn’t change: it remains a simple object designed to represent incoming data from the client. What does change is how validation is applied. Instead of using attributes directly on the DTO properties, you define the validation rules externally using a validator class.
This approach keeps your DTOs clean and focused only on structure, while all validation logic lives in the corresponding 'AbstractValidator<T>' class. This pattern—combining DTOs for data structure and external validators for rules—is widely used in production applications. It separates concerns cleanly: your DTO represents the expected shape of input, and your validator enforces the rules that input must follow.
That said, for basic validation, the built-in '[Required]', '[EmailAddress]', etc., are perfectly adequate. FluentValidation becomes especially helpful as complexity grows or when architectural preferences push for cleaner model classes.
Testing Validation
Testing validation logic is straightforward using unit tests. Example:
using Xunit;
public class PasswordValidationTests
{
[Theory]
[InlineData("Password1", true)]
[InlineData("Password", false)]
public void PasswordMustContainNumber_Validation(string password, bool expectedValid)
{
var attribute = new PasswordMustContainNumberAttribute();
var result = attribute.IsValid(password);
Assert.Equal(expectedValid, result == ValidationResult.Success);
}
}
Backend validation ensures data integrity and security. Embrace these practices in your .NET projects for reliable, secure backend logic.