Back to Blog
C#

C# Property Validation: Choosing the Right Approach

c# property validation: Compare DataAnnotations, IValidatableObject, custom attributes, and setter checks to decide where C# property validation belongs in your applic...

DataAnnotationsIValidatableObjectASP.NET CoreModel ValidationCustom Attributes
Illustration of C# property validation showing a validation shield checking property boxes on a code object diagram.

When a C# object receives data from a request body, a form, or a configuration file, its properties need to be checked before the object is trusted. The practical question behind c# property validation is where those checks belong: in the property setter, in a validation method, or in declarative attributes that run at a defined point. Each approach changes how the code behaves when invalid data arrives, so the choice matters before you write the first check.

Validating Properties with DataAnnotations Attributes

DataAnnotations provides a set of attributes that describe constraints directly on the property. [Required], [StringLength], [Range], and [EmailAddress] are the most common ones.

public class OrderRequest { [Required] public string CustomerName { get; set; } [Range(1, 100)] public int Quantity { get; set; } [StringLength(200)] public string Note { get; set; } }

These attributes do nothing by themselves. You have to invoke the validator explicitly, or rely on a framework that does it for you.

var request = new OrderRequest { Quantity = 0 }; var results = new List<ValidationResult>(); var isValid = Validator.TryValidateObject( request, new ValidationContext(request), results, true);

The fourth parameter, validateAllProperties, tells the validator to check every property that carries validation attributes. Without it, only [Required] attributes are evaluated. After the call, results contains a ValidationResult for each failed constraint, and isValid is false if any rule failed.

The declarative style keeps each rule visible next to the property it constrains. The limitation is that a single attribute can only express a single-property rule. A condition like "the discount cannot exceed 30% for orders below $100" needs more than one attribute.

Cross-Property Validation with IValidatableObject

When a rule depends on two or more properties, implement IValidatableObject on the class.

public class BookingRequest : IValidatableObject { public DateTime StartDate { get; set; } public DateTime EndDate { get; set; } public IEnumerable<ValidationResult> Validate(ValidationContext validationContext) { if (EndDate <= StartDate) { yield return new ValidationResult( "EndDate must be after StartDate.", new[] { nameof(EndDate) }); } } }

The Validate method runs as part of the same validation pass as the property-level attributes. Its errors are collected alongside the attribute errors, so the caller sees the complete set of problems in one result list. The member name passed to ValidationResult tells the consumer which property the error belongs to, which matters when the errors are rendered into a form or an API response.

IValidatableObject is the natural place for rules that span properties, but it has a limit: one Validate method per class. If a class accumulates many cross-property rules, the method grows and becomes harder to read. In that case, split the rules into custom attributes or move the validation into a separate validator class.

Custom Validation Attributes for Reusable Rules

When the same rule appears on many properties or classes, a custom attribute keeps the logic in one place.

public class NotInPastAttribute : ValidationAttribute { protected override ValidationResult IsValid(object value, ValidationContext context) { if (value is DateTime date && date < DateTime.Today) { return new ValidationResult( $"{context.DisplayName} cannot be in the past."); } return ValidationResult.Success; } }

IsValid receives the property value and a ValidationContext that exposes the object instance and the display name. Returning ValidationResult.Success signals that the check passed. The attribute can then be applied to any DateTime property:

public class EventRequest { [NotInPast] public DateTime ScheduledAt { get; set; } }

A custom attribute is the right choice when the rule is self-contained and reusable across models. If the rule needs to compare several properties on the same object, IValidatableObject is usually simpler because it has direct access to the whole instance.

Throwing Exceptions in Property Setters

A different strategy is to enforce the rule at assignment time by throwing in the setter.

public class Temperature { private double _celsius; public double Celsius { get => _celsius; set { if (value < -273.15) { throw new ArgumentOutOfRangeException( nameof(Celsius), "Temperature cannot be below absolute zero."); } _celsius = value; } } }

This guarantees that an invalid value can never exist in the object. The tradeoff is that the exception is thrown at the point of assignment, which may be far from where the data was validated. For domain objects constructed with known-valid data, this fail-fast behavior is appropriate. For objects that receive untrusted user input, it is usually better to collect validation errors and return them to the caller instead of throwing.

The distinction is between protecting an object's internal invariants and validating data at the system boundary. A setter exception keeps the object consistent. Validation attributes protect the boundary where data enters the application.

Validation in ASP.NET Core Model Binding

In an ASP.NET Core controller marked with [ApiController], DataAnnotations attributes on the model are validated automatically during model binding. If validation fails, the framework returns a 400 response with the error details before the action method runs.

[ApiController] [Route("api/orders")] public class OrdersController : ControllerBase { [HttpPost] public IActionResult Create(OrderRequest request) { return Ok(); } }

With [ApiController], you do not need to call Validator.TryValidateObject yourself. The framework populates ModelState with the validation results and short-circuits the request. For controllers without [ApiController], you check ModelState.IsValid manually at the start of the action.

This automatic behavior is why DataAnnotations is the default choice for request models in ASP.NET Core. The same attributes that work with Validator.TryValidateObject in a unit test work with model binding in production, so the validation contract is defined once and reused in both places.

Runtime Cost of Reflection-Based Validation

DataAnnotations validation relies on reflection. When Validator.TryValidateObject runs, it inspects the type's properties, reads their attributes, and invokes the validation logic. For a single object with a handful of properties, that cost is negligible. The concern appears when validation runs in a hot path, such as validating thousands of objects per second in a batch process.

In that scenario, the reflection overhead repeats for every object. A hand-written validation method that checks each property directly avoids attribute discovery entirely.

public bool TryValidate(OrderRequest request, out List<string> errors) { errors = new List<string>(); if (string.IsNullOrWhiteSpace(request.CustomerName)) errors.Add("CustomerName is required."); if (request.Quantity is < 1 or > 100) errors.Add("Quantity must be between 1 and 100."); return errors.Count == 0; }

The manual approach is faster but duplicates the rules. If the same validation must also run in the model binder, you now have two sources of truth that can drift apart. The practical rule is to measure before optimizing. Reflection-based validation is rarely the bottleneck in a request-driven application; it becomes relevant only when validation is called in a tight loop over many objects.

Choosing the Right Validation Strategy

The decision depends on where the data comes from and how the object is used.

ApproachBest forLimitation
DataAnnotationsRequest models, DTOs, framework-integrated validationLimited to single-property rules unless combined with IValidatableObject
IValidatableObjectRules that span multiple propertiesOne Validate method per class; logic can grow large
Custom attributeReusable single-property rulesRequires a new class per rule
Setter validationDomain invariants that must never be violatedThrows instead of collecting errors; not suitable for user input
Manual methodPerformance-sensitive batch validationRules are duplicated or live outside the model

For most web applications, DataAnnotations plus IValidatableObject covers the common cases. Setter validation is appropriate for domain objects where an invalid state is a programming error rather than a user error. A manual validation method is justified only when you have measured that reflection-based validation is a bottleneck.

The important detail is to keep validation close to the data it protects. Scattering checks across controllers or services makes the rules hard to find and easy to bypass. Putting them on the model, either as attributes or as a Validate method, keeps the contract visible where the data is defined. When a rule grows beyond what a single attribute can express, move it to IValidatableObject or a custom attribute rather than adding ad-hoc checks at each call site.

c# property validation: Practical Usage and Code Examples | RYUSLOG DEV