A custom validation attribute puts an application-specific rule on a model property or class instead of repeating the rule in every action or Razor Page handler. ASP.NET Core applies the attribute before invoking the action, and the result contributes to ModelState.IsValid. section 29.5 section 29.5.2
To create one:
- Derive an attribute class from ValidationAttribute and override IsValid. It receives the value being checked and a ValidationContext. section 29.5.1
- Return a ValidationResult for an invalid value; use ErrorMessage to let the attribute's caller supply the message. ValidationContext can provide application services through GetService when the rule needs them. section 29.5.1
- Apply a property-level attribute to the property it checks, or a model-level attribute to the class when the rule involves multiple properties. section 29.5.2
Two rules for a product form
A PrimaryKeyAttribute checks whether a submitted key exists by obtaining an Entity Framework Core database context from ValidationContext. A PhraseAndPriceAttribute checks the product's Name and Price together; applied to the Product class, it rejects a matching phrase when the price exceeds its configured limit. The controller can then use ModelState.IsValid rather than duplicate those checks. Try submitting an invalid key or clearing the form fields at /controllers/form or /pages/form. section 29.5.1 section 29.5.2
Watch the Razor Pages wrinkle: an error from a custom model-level attribute on a bound Product can be associated with the Product property, so a validation summary may not display it. PromotePropertyErrors copies those errors to model-level errors. If code changes a bound object after validation, clear ModelState and call TryValidateModel to validate it again. section 29.5.2 section 29.5.1
Listing 29.17 The contents of the PrimaryKeyAttribute.cs file in the Validation folder
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations;
namespace WebApp.Validation {
public class PrimaryKeyAttribute : ValidationAttribute {
public Type? ContextType { get; set; }
public Type? DataType { get; set; }
protected override ValidationResult? IsValid(object? value,
ValidationContext validationContext) {
if (ContextType != null && DataType != null) {
DbContext? context =
validationContext.GetRequiredService(ContextType)
as DbContext;
if (context != null
&& context.Find(DataType, value) == null) {
return new ValidationResult(ErrorMessage ??
"Enter an existing key value");
}
}
return ValidationResult.Success;
}
}
}
See the property-level attribute's IsValid override, ValidationContext service lookup, and database key check. section 29.5.1
Listing 29.19 Applying validation attributes in the Product.cs file in the Models folder
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using WebApp.Validation;
namespace WebApp.Models {
[PhraseAndPrice(Phrase = "Small", Price = "100")]
public class Product {
public long ProductId { get; set; }
[Required(ErrorMessage = "Please enter a name")]
public required string Name { get; set; }
[Range(1, 999999, ErrorMessage = "Please enter a positive price")]
[Column(TypeName = "decimal(8, 2)")]
public decimal Price { get; set; }
[PrimaryKey(ContextType = typeof(DataContext),
DataType = typeof(Category))]
public long CategoryId { get; set; }
public Category? Category { get; set; }
[PrimaryKey(ContextType = typeof(DataContext),
DataType = typeof(Supplier))]
public long SupplierId { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public Supplier? Supplier { get; set; }
}
}
See where the model-level PhraseAndPrice attribute and property-level PrimaryKey attribute go on Product. section 29.5.2


