Mastering Model Validation in ASP.NET Core: Best Practices & Tips
Learn how to implement robust model validation in ASP.NET Core with data annotations, custom validators, and production debugging techniques.
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
- ✓Basic knowledge of C# and ASP.NET Core
- ✓Familiarity with creating models and controllers
- ✓Understanding of HTTP and REST APIs
- Use data annotations like [Required], [StringLength], and [Range] for basic validation.
- Create custom validation attributes for complex rules.
- Leverage IValidatableObject for cross-property validation.
- Use FluentValidation for cleaner separation of concerns.
- Always validate on both client and server sides for security.
Think of model validation like a bouncer at a club. The bouncer checks IDs (data types), ensures you're on the list (required fields), and verifies you're not too young (range checks). In ASP.NET Core, the framework acts as a bouncer for your data, rejecting invalid entries before they cause trouble.
Imagine you're building an e-commerce API. A user submits an order with a negative quantity, a missing email, and a price of 'free'. Without validation, your database could end up with corrupted data, your payment gateway might fail, and customers could get frustrated. Model validation is your first line of defense against such chaos.
In ASP.NET Core, model validation is built into the framework, making it easy to enforce rules on incoming data. Whether you're using data annotations, custom attributes, or FluentValidation, the goal is the same: ensure that only clean, valid data enters your system.
This tutorial will guide you through everything from basic annotations to advanced custom validators, production debugging, and common pitfalls. By the end, you'll be able to implement robust validation that protects your application and delights your users.
1. Setting Up Model Validation with Data Annotations
Data annotations are attributes you apply to model properties to define validation rules. ASP.NET Core automatically enforces these rules when binding incoming request data to your models.
Start by adding the System.ComponentModel.DataAnnotations namespace. Common attributes include [Required], [StringLength], [Range], [EmailAddress], [RegularExpression], and [Compare].
Example: A user registration model with validation.
```csharp public class RegisterModel { [Required(ErrorMessage = "Username is required")] [StringLength(50, MinimumLength = 3)] public string Username { get; set; }
[Required] [EmailAddress] public string Email { get; set; }
[Required] [StringLength(100, MinimumLength = 6)] [DataType(DataType.Password)] public string Password { get; set; }
[Compare("Password", ErrorMessage = "Passwords do not match")] [DataType(DataType.Password)] public string ConfirmPassword { get; set; } } ```
In your controller, check ModelState.IsValid before processing the request. If invalid, return a BadRequest with the errors.
``csharp [ApiController] [Route("api/[controller]")] public class AccountController : ControllerBase { [HttpPost("register")] public IActionResult Register(RegisterModel model) { if (!ModelState.IsValid) return BadRequest(ModelState); // Process registration return ``Ok(); } }
With [ApiController] attribute, ASP.NET Core automatically returns a 400 Bad Request with ModelState errors if validation fails, so you can omit the manual check.
2. Creating Custom Validation Attributes
When built-in attributes don't cover your business rules, create custom validation attributes by inheriting from ValidationAttribute and overriding the IsValid method.
Example: A custom attribute to validate that a string is a valid URL.
```csharp public class ValidUrlAttribute : ValidationAttribute { protected override ValidationResult IsValid(object value, ValidationContext validationContext) { if (value == null || string.IsNullOrWhiteSpace(value.ToString())) return ValidationResult.Success; // Let [Required] handle null
if (Uri.TryCreate(value.ToString(), UriKind.Absolute, out var uriResult) && (uriResult.Scheme == Uri.UriSchemeHttp || uriResult.Scheme == Uri.UriSchemeHttps)) { return ValidationResult.Success; }
return new ValidationResult("The field must be a valid URL."); } } ```
Apply it to a property:
``csharp public class ProfileModel { [ValidUrl] public string Website { get; set; } } ``
You can also access other properties via ValidationContext.ObjectInstance for cross-property validation.
3. Implementing IValidatableObject for Model-Level Validation
When validation rules involve multiple properties, implement IValidatableObject on your model class. This interface requires a Validate method that returns IEnumerable<ValidationResult>.
Example: A booking model where end date must be after start date.
```csharp public class BookingModel : IValidatableObject { [Required] public DateTime StartDate { get; set; }
[Required] public DateTime EndDate { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) { if (EndDate <= StartDate) { yield return new ValidationResult("End date must be after start date.", new[] { nameof(EndDate) }); } } } ```
The Validate method runs after property-level validation. You can yield multiple ValidationResult objects for different errors.
4. Using FluentValidation for Cleaner Validation
FluentValidation is a popular library that separates validation logic into dedicated validator classes. This keeps your models clean and makes validation rules more testable.
First, install the FluentValidation.AspNetCore NuGet package.
Create a validator class inheriting from AbstractValidator<T>:
```csharp using FluentValidation;
public class RegisterModelValidator : AbstractValidator<RegisterModel> { public RegisterModelValidator() { RuleFor(x => x.Username) .NotEmpty().WithMessage("Username is required") .Length(3, 50);
RuleFor(x => x.Email) .NotEmpty() .EmailAddress();
RuleFor(x => x.Password) .NotEmpty() .MinimumLength(6);
RuleFor(x => x.ConfirmPassword) .Equal(x => x.Password).WithMessage("Passwords do not match"); } } ```
Register the validator in Program.cs:
``csharp builder.``Services.AddFluentValidationAutoValidation(); builder.Services.AddScoped<IValidator<RegisterModel>, RegisterModelValidator>();
FluentValidation integrates with ASP.NET Core's validation pipeline, automatically validating models and adding errors to ModelState.
5. Client-Side Validation with Unobtrusive JavaScript
ASP.NET Core supports unobtrusive client-side validation using jQuery Validation. When you use data annotations, the framework generates data-* attributes on HTML elements, which the scripts interpret to provide instant feedback.
To enable client-side validation, include the following scripts in your layout or view:
``html <script src="~/lib/jquery-validation/dist/jquery.validate.min.js"></script> <script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js"></script> ``
In Razor views, use asp-for tag helpers to generate inputs with validation attributes:
```html @model RegisterModel
<form asp-action="Register" method="post"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <div> <label asp-for="Username"></label> <input asp-for="Username" class="form-control" /> <span asp-validation-for="Username" class="text-danger"></span> </div> <!-- other fields --> <button type="submit">Register</button> </form> ```
Client-side validation improves user experience by showing errors immediately without a round trip to the server. However, always validate on the server as well.
6. Global Validation Error Handling and Custom Responses
Instead of handling validation errors in every action, create a global validation filter or use the built-in [ApiController] behavior. For more control, implement a custom ActionFilter or use InvalidModelStateResponseFactory.
Example: Custom response format for validation errors.
```csharp builder.Services.AddControllers() .ConfigureApiBehaviorOptions(options => { options.InvalidModelStateResponseFactory = context => { var errors = context.ModelState .Where(e => e.Value.Errors.Count > 0) .ToDictionary( kvp => kvp.Key, kvp => kvp.Value.Errors.Select(e => e.ErrorMessage).ToArray() );
var response = new { StatusCode = 400, Message = "Validation failed", Errors = errors };
return new BadRequestObjectResult(response); }; }); ```
This ensures all validation errors return a consistent JSON structure.
7. Testing Validation Logic
Testing validation ensures your rules work as expected. For data annotations, you can manually validate a model using Validator.TryValidateObject. For FluentValidation, use the validator's Validate method.
Example: Testing a RegisterModel with data annotations.
```csharp [Fact] public void RegisterModel_InvalidEmail_FailsValidation() { var model = new RegisterModel { Username = "test", Email = "invalid-email", Password = "123456", ConfirmPassword = "123456" };
var context = new ValidationContext(model, null, null); var results = new List<ValidationResult>(); var isValid = Validator.TryValidateObject(model, context, results, true);
Assert.False(isValid); Assert.Contains(results, r => r.MemberNames.Contains(nameof(RegisterModel.Email))); } ```
For FluentValidation:
```csharp [Fact] public void RegisterModelValidator_InvalidEmail_Fails() { var validator = new RegisterModelValidator(); var model = new RegisterModel { Username = "test", Email = "invalid", Password = "123456", ConfirmPassword = "123456" };
var result = validator.Validate(model);
Assert.False(result.IsValid); Assert.Contains(result.Errors, e => e.PropertyName == "Email"); } ```
The $10M Validation Bug: How Missing Input Validation Crashed a Payment System
- Never trust client-side validation alone; always validate on the server.
- Use data annotations or FluentValidation to enforce required fields.
- Implement a global validation filter to automatically return validation errors.
- Log validation failures to detect potential attacks or bugs.
- Test with invalid data to ensure your validation works as expected.
Add a validation filter to log errors globally.Use [ApiController] attribute to automatically return 400.if (!ModelState.IsValid) return BadRequest(ModelState); in your action.| File | Command / Code | Purpose |
|---|---|---|
| RegisterModel.cs | using System.ComponentModel.DataAnnotations; | 1. Setting Up Model Validation with Data Annotations |
| ValidUrlAttribute.cs | using System.ComponentModel.DataAnnotations; | 2. Creating Custom Validation Attributes |
| BookingModel.cs | using System.ComponentModel.DataAnnotations; | 3. Implementing IValidatableObject for Model-Level Validatio |
| RegisterModelValidator.cs | using FluentValidation; | 4. Using FluentValidation for Cleaner Validation |
| Register.cshtml | @model RegisterModel | 5. Client-Side Validation with Unobtrusive JavaScript |
| Program.cs | var builder = WebApplication.CreateBuilder(args); | 6. Global Validation Error Handling and Custom Responses |
| ValidationTests.cs | using System.ComponentModel.DataAnnotations; | 7. Testing Validation Logic |
Key takeaways
Common mistakes to avoid
3 patternsRelying solely on client-side validation
Not validating on the server when using [ApiController]
Using complex logic inside custom validation attributes that depend on scoped services
Interview Questions on This Topic
How do you create a custom validation attribute in ASP.NET Core?
Frequently Asked Questions
20+ years shipping production .NET services in enterprise systems. Lessons pulled from things that broke in production.
That's ASP.NET. Mark it forged?
3 min read · try the examples if you haven't