Every request from the outside world is a stranger at the door — validation is the check you run before you let it any further in.
Picture a controller action that creates a new user account:
[HttpPost]
public IActionResult Register(RegisterRequest request)
{
var user = new User(request.Email, request.Age);
_repository.Save(user);
return Ok();
}
Nothing here checks whether request.Email actually looks like an email address, whether it was even supplied at all, or whether request.Age is a plausible human age instead of -40 or 2,000,000. Whatever a client sends — a malformed script, an empty string, a wildly out-of-range number — sails straight through into _repository.Save(user), and from there into your database, your business logic, maybe an email sent to "" . The bug this creates might not surface for weeks, and by the time it does, the bad data is already sitting in production.
The fix isn't to sprinkle if checks through every action method by hand. It's to declare the rules once, directly on the shape of the data you expect, and let the framework enforce them automatically before your action method's first line ever runs.
In this lesson: the real, built-in DataAnnotations validation attributes ([Required], [Range], [EmailAddress], [StringLength], [RegularExpression], and more), how [ApiController] turns invalid data into an automatic HTTP 400 without you writing that check yourself, how to write your own custom validation attribute by inheriting ValidationAttribute, and where a library like FluentValidation fits in as an alternative.
Validation is the process of checking that incoming data satisfies the rules your application requires before that data is allowed to reach your business logic. In ASP.NET Core, the most common way to declare those rules is by attaching DataAnnotations validation attributes directly to the properties of the model class that represents an incoming request — the same bracket syntax you already know from 183 - Attributes, just applied to a new, very practical job.
Every DataAnnotations validation attribute — [Required], [Range], [StringLength], and the rest — is a class deriving (directly or indirectly) from System.ComponentModel.DataAnnotations.ValidationAttribute, living in the System.ComponentModel.DataAnnotations namespace. Each one implements a rule for whether a given value is valid. When ASP.NET Core's model binder finishes populating a request model from an incoming request, the validation framework reflects over that model's properties, finds any validation attributes on them, and runs each one's check — recording every failure into an object called ModelState. This is the exact same attributes-plus-reflection pairing you already saw with the custom [Range] validator in 183: DataAnnotations attributes are, in effect, a fully-built, production-grade version of that same pattern.
if checks[ApiController] converts failures into a standard 400 responseAn API cannot control what shows up on the wire. A client might be a well-behaved front-end app, a buggy mobile client sending stale data, a QA script, or an attacker deliberately probing for weaknesses. Whatever the source, the raw JSON that arrives could be missing fields, have the wrong types coerced into something technically valid, contain absurd values, or contain text carefully crafted to break something downstream. If your business logic and your data layer are the first things to see that data, every single piece of code from that point on has to defensively re-check it, or risk acting on garbage.
What's needed is a single, consistent checkpoint — as close to the boundary of the application as possible — where the rules for "what does a valid request look like" are declared once, enforced automatically on every request, and produce a clear, structured error response when they're broken, without every controller action having to reimplement the same checks by hand.
DataAnnotations validation attributes declared directly on the request model, combined with ASP.NET Core's model binding and validation pipeline, plus [ApiController]'s automatic 400 behavior. The rule lives in exactly one place — the model — and every action that accepts that model gets the same enforcement for free.
RegisterRequest object — covered by the model binding lesson elsewhere in this PartValidationAttribute it finds on each propertyModelState[ApiController] is applied and ModelState.IsValid is false → the pipeline short-circuits and returns HTTP 400 automatically, before your action method runs at allpublic class RegisterRequest
{
[Required, StringLength(50, MinimumLength = 2)]
public string Name { get; set; } = "";
[Required, EmailAddress]
public string Email { get; set; } = "";
[Range(13, 120)]
public int Age { get; set; }
}
[ApiController]
[Route("api/[controller]")]
public class AccountsController : ControllerBase
{
[HttpPost("register")]
public IActionResult Register(RegisterRequest request)
{
// If we get here, request already satisfies every validation attribute above.
var user = new User(request.Name, request.Email, request.Age);
_repository.Save(user);
return Ok();
}
}
[ApiController] (covered in the ASP.NET Core controllers lesson elsewhere in this Part) opts the controller into several API-friendly conventions — automatic model-state validation with an automatic 400 response is one of the most important ones.POST /api/accounts/register
{ "name": "A", "email": "not-an-email", "age": 5 }
// Response — 400 Bad Request, generated automatically, Register() body never executed:
{
"errors": {
"Name": ["The field Name must be a string or array type with a minimum length of '2'."],
"Email": ["The Email field is not a valid e-mail address."],
"Age": ["The field Age must be between 13 and 120."]
}
}
if (!ModelState.IsValid) return BadRequest(...) was written anywhere — [ApiController] does that automatically, every time, for every action that takes a model.A tour of the everyday built-in attributes — each one is a real, shipped class in System.ComponentModel.DataAnnotations:
public class ProductRequest
{
[Required] // must be present and non-empty
public string Sku { get; set; } = "";
[Required, StringLength(100, MinimumLength = 3)] // length between 3 and 100
public string Name { get; set; } = "";
[Range(0.01, 100000)] // numeric bounds, inclusive
public decimal Price { get; set; }
[EmailAddress] // must look like a valid email
public string? ContactEmail { get; set; }
[Phone] // must look like a valid phone number
public string? ContactPhone { get; set; }
[Url] // must be a well-formed URL
public string? ProductPageUrl { get; set; }
[RegularExpression(@"^[A-Z]{2}\d{4}$",
ErrorMessage = "SKU must be two uppercase letters followed by four digits.")]
public string SkuFormat { get; set; } = "";
[Compare(nameof(Price), ErrorMessage = "DiscountedPrice cannot exceed Price.")]
public decimal DiscountedPrice { get; set; }
}
Meaning: None of this is new syntax — it's exactly the bracket attribute syntax from 183, applied to a handful of purpose-built attribute classes Microsoft already wrote for you. [Required] checks presence; [Range] checks numeric bounds; [StringLength] checks length; [EmailAddress], [Phone], and [Url] check that a string matches the shape of that kind of value; [RegularExpression] checks an arbitrary pattern; [Compare] checks one property against another.
[Required] on value types: [Required] on a non-nullable value type like int Age is almost meaningless — a JSON payload that omits age entirely simply binds it to its default, 0, which is a perfectly valid int and satisfies [Required] without complaint. To genuinely require a numeric field to have been supplied, make the property nullable (int? Age) so "missing" and "zero" are actually distinguishable, and let [Required] reject the missing case.
A custom validation attribute, written by inheriting ValidationAttribute directly — the exact same technique 183 taught for writing any custom attribute, now producing a real, working validation rule that DataAnnotations doesn't ship out of the box: rejecting weekend dates for a scheduled delivery.
public class NotWeekendAttribute : ValidationAttribute
{
public NotWeekendAttribute()
: base("{0} cannot fall on a Saturday or Sunday.") { }
protected override ValidationResult? IsValid(object? value, ValidationContext context)
{
if (value is DateOnly date &&
(date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday))
{
// FormatErrorMessage substitutes {0} with the display name of the property
return new ValidationResult(FormatErrorMessage(context.DisplayName));
}
return ValidationResult.Success; // null value is allowed here — combine with [Required] to also forbid missing
}
}
public class DeliveryRequest
{
[Required]
[NotWeekend]
public DateOnly RequestedDeliveryDate { get; set; }
}
This inherits from ValidationAttribute instead of Attribute directly, and overrides IsValid — everything else (how the attribute is applied with brackets, how [AttributeUsage] could restrict it, how it's ultimately discovered through reflection) is exactly what 183 already taught. The validation pipeline finds any attribute deriving from ValidationAttribute on a model property and calls its IsValid, whether that attribute shipped with .NET or you wrote it yourself an hour ago — the framework does not distinguish between the two.
DataAnnotations attributes work property-by-property, which covers the large majority of real validation needs. But some rules genuinely need to reason across several properties at once — "EndDate must be after StartDate," "DiscountPercent is only allowed when CustomerTier is Premium" — or need to be unit-tested independently of the model class itself, without spinning up the whole ASP.NET Core pipeline. For those scenarios, FluentValidation is the widely-used, popular third-party library worth knowing exists: it lets you express validation rules as a separate, fluent, code-based "validator" class rather than attributes scattered across the model. This lesson won't walk through its API in depth — the built-in DataAnnotations attributes and a custom ValidationAttribute are the right starting point and cover most real applications — but knowing FluentValidation exists, and roughly what problem it solves better than attributes alone, is worth carrying forward.
Validation attributes are like the checks at an airport check-in counter: valid ticket, matching ID, bag within the weight limit. None of that checks whether you'll behave well on the flight — it just confirms the basic paperwork is in order before you're allowed any further into the airport. If your ticket is for the wrong date or your ID doesn't match your name, you're turned back right there, at the counter — you never even reach airport security, let alone the gate.
Your business logic is the flight itself. It shouldn't have to re-check that you have a valid ticket — that was already handled, once, at the door. Validation attributes are exactly that first counter: a single, consistent checkpoint that keeps malformed travelers (requests) from ever reaching the parts of the system that assume they're dealing with someone who already passed the basic checks.
GetCustomAttribute<T>()-style discovery from 183) looking for anything deriving from ValidationAttribute.IsValid method. A failure adds an entry to ModelState, keyed by property name, holding the error message.RegisterRequest has an Address Address { get; set; } property, and Address itself carries validation attributes, those are checked too, without any extra configuration.[ApiController] doesn't invent this mechanism — ModelState and validation attributes work even without it. What [ApiController] adds is automatic behavior: it registers a filter that runs before your action executes, checks ModelState.IsValid, and — if it's false — immediately produces an HTTP 400 response containing a ValidationProblemDetails body (the structured { "errors": { ... } } shape you saw earlier), short-circuiting the request entirely. Your action method's code never runs at all.[ApiController], all of this validation-attribute checking still happens and ModelState still gets populated — but nothing automatically returns 400 for you; you'd need if (!ModelState.IsValid) return BadRequest(ModelState); written explicitly in every action.DataAnnotations attributes verify that data is well-formed: present, in range, correctly shaped. They cannot verify things that depend on the current state of your system — "is this SKU already taken," "does this customer have enough account balance," "is this the customer's own order." Those are business rules, and they belong in your domain or application layer, checked after the data has already passed structural validation. Validation attributes are the first gate, not the only gate.
A front-end form might disable the submit button until every field looks valid — that's a genuinely good user experience improvement, catching mistakes early with instant feedback. But it is trivial to bypass: a request can be sent directly with a tool like curl or Postman, completely skipping the browser and any JavaScript validation it ran. The server has no way to know whether a request actually came through your form's validated UI or was crafted by hand. Server-side validation attributes are not optional — they are the only validation that's actually enforced, regardless of what client sent the request.
[ApiController] and assuming validation "just happens" Assuming a plain [Controller]-attributed MVC controller (not [ApiController]) automatically returns 400 on invalid model state, then being surprised when it doesn't.
Either add [ApiController] for API controllers (the common case for JSON APIs), or explicitly check if (!ModelState.IsValid) return BadRequest(ModelState); at the top of every action that needs it.
[Required] on a non-nullable value type and expecting "missing" to be caught [Required] public int Age { get; set; } — a request that omits age binds it to 0, which satisfies [Required] without complaint.
Use int? Age so a truly missing value binds to null, which [Required] correctly rejects — then combine it with [Range] if there's also a valid numeric range.
Duplicating an email-format check as a hand-written regex in the controller, a slightly different one in a service class, and a third version buried in the database layer — each one drifting out of sync over time.
Declare structural rules once, on the request model, as attributes. Keep business rules in exactly one place too (your domain/application layer), and don't re-implement the same check redundantly elsewhere.
ValidationAttribute when a rule is specific to your domain (like the weekend-delivery example) but still only depends on a single property's own value.ModelState = where every rule's pass/fail result is collected.[ApiController] = the switch that turns "ModelState is invalid" into an automatic HTTP 400, before your action ever runs.[Required], [Range], [EmailAddress], [StringLength], [RegularExpression], and others) declare structural rules directly on a request model's properties.[ApiController] automatically checks ModelState.IsValid and returns an HTTP 400 with structured errors — without a single explicit if check in your action method.ValidationAttribute, overriding IsValid — the exact custom-attribute-writing skill from 183, now producing a working, project-specific rule.You've seen how validation attributes declare rules, how [ApiController] enforces them automatically, and how to write your own. Let's check your understanding.
1. A controller has [ApiController] applied, and its action accepts a model with [Required] and [Range] attributes. A client sends a request that violates [Range]. What happens?
Correct: B
Why B is correct: This is exactly what [ApiController] adds: a filter that checks ModelState.IsValid before the action runs, and short-circuits with an automatic 400 (as a ValidationProblemDetails body) when it's false.
Why A is incorrect: That's the behavior without [ApiController] — with it, the check and the 400 response are automatic.
Why C is incorrect: The client always gets a real, structured 400 response describing what failed — it is never silently dropped.
Why D is incorrect: Validation attributes only report pass/fail; they never modify or "correct" the submitted value.
Reinforcement: [ApiController]'s automatic 400 behavior is precisely the payoff of declaring rules as attributes instead of writing manual checks.
2. Why is [Required] public int Quantity { get; set; } a common source of confusion?
Correct: B
Why B is correct: A non-nullable value type always has a default value (0 for int), so a missing field binds to that default instead of triggering [Required]'s "missing" check. Making the property nullable (int?) is what makes "missing" genuinely distinguishable from "explicitly zero."
Why A is incorrect: [Required] compiles fine on any property type — the issue is behavioral, not a compile error.
Why C is incorrect: The example already has public; access modifiers are unrelated to this gotcha.
Why D is incorrect: [Required] is a complete, standalone attribute; it has no dependency on [Range] being present.
Reinforcement: Always consider whether a value type's default value could accidentally satisfy [Required] — use a nullable type when "not supplied" genuinely needs to be distinguishable from a legitimate default.
3. You need a rule that rejects an order if DiscountPercent is set but CustomerTier isn't "Premium". Which approach is the best fit?
Correct: B
Why B is correct: This rule depends on the relationship between two properties, not just one property's own value — exactly the kind of scenario the lesson calls out as a good fit for a cross-property check (a custom attribute with access to the whole object via ValidationContext, or a dedicated library like FluentValidation) rather than a simple built-in attribute.
Why A is incorrect: [Range] checks numeric bounds on one property; it has no way to also inspect CustomerTier.
Why C is incorrect: [Required] only checks presence, not a conditional relationship between two fields.
Why D is incorrect: This is exactly the kind of rule that should be caught before hitting the database — pushing it to a database constraint would give a far worse, less specific error experience, and it's entirely expressible in application code.
Reinforcement: A rule that reasons across multiple properties together is the specific gap DataAnnotations attributes alone don't cleanly cover — recognize that shape and reach for a cross-property or library-based solution.
4. A front-end form disables its submit button until every field passes JavaScript validation. Does this mean the server-side [Required]/[Range] attributes on the corresponding API endpoint are now redundant?
Correct: B
Why B is correct: Client-side validation is a UX improvement, not a security or correctness guarantee — the server has no way to know a request actually went through the validated UI. Server-side validation attributes are the only check that's genuinely enforced, regardless of how the request was constructed.
Why A is incorrect: This is precisely the mistake the lesson warns against — trusting client-side validation as sufficient on its own.
Why C is incorrect: The distinction has nothing to do with HTTP verb; it's about whether a request is trusted, which is never guaranteed for any verb.
Why D is incorrect: Nothing in ASP.NET Core forces a client to run any particular validation before sending a request — the server must assume it might not have.
Reinforcement: Every request from outside your server is untrusted by default, no matter what UI (if any) supposedly produced it.
5. You write a custom attribute that inherits ValidationAttribute and overrides IsValid to reject weekend dates. What makes this work the same way as any built-in attribute like [Range]?
Correct: B
Why B is correct: The validation framework's discovery mechanism doesn't distinguish between built-in and custom attributes — it looks for anything deriving from ValidationAttribute via reflection, exactly as described in "Under the Hood." A correctly-written custom attribute plugs into the exact same pipeline with zero extra registration.
Why A is incorrect: No separate registration is needed — applying the attribute to a property is sufficient, same as any built-in one.
Why C is incorrect: [Serializable] is unrelated — it concerns legacy binary serialization, not validation discovery.
Why D is incorrect: A custom ValidationAttribute works with [ApiController]'s built-in automatic validation exactly like [Range] does; FluentValidation is a separate, alternative approach, not a requirement for custom attributes.
Reinforcement: This is the direct payoff of 183's lesson — once you know how to write and apply a custom Attribute-derived class, writing a custom validation rule is the same skill applied to one specific, very useful base class.
You now know how to declare validation rules once, enforce them automatically, and write your own when the built-ins don't cover a case — the first real checkpoint every request passes through before it reaches your business logic.
dotnetmadeeasy.com — Learn C# and .NET, the right way.