← Open in the full interactive course (progress tracking, search & more)

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.

What Is It?

The Simple Explanation

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.

The Technical Definition

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.

Manual if checks

DataAnnotations attributes

Why Does It Exist?

The Problem — Every Request Body Is Untrusted

An 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.

The Need

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.

The Solution

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.

Big Picture

FROM RAW REQUEST TO YOUR ACTION METHOD
HTTP request body (JSON) arrives

Model binding deserializes it into your RegisterRequest object — covered by the model binding lesson elsewhere in this Part

Validation framework reflects over the model, runs every ValidationAttribute it finds on each property

Failures are recorded into ModelState

If [ApiController] is applied and ModelState.IsValid is false → the pipeline short-circuits and returns HTTP 400 automatically, before your action method runs at all

Only valid, rule-satisfying data ever reaches the first line of your action method

How It Works

DECLARING AND ENFORCING VALIDATION — STEP BY STEP
1. DECORATE THE REQUEST MODEL WITH VALIDATION ATTRIBUTES
public 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; }
}
2. MARK THE CONTROLLER WITH [ApiController]
[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();
    }
}
3. SEND AN INVALID REQUEST — THE ACTION NEVER RUNS
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."]
  }
}

Simple Example

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.

A genuine gotcha with [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.

Real-World Example

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.

When a Rule Needs More Than One Property — FluentValidation

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.

Analogy

An airport check-in counter, not the boarding gate

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.

Under the Hood

WHAT [ApiController] IS ACTUALLY DOING
MODEL STATE, ValidationAttribute, AND THE AUTOMATIC 400

Common Confusion

1. "Validation attributes replace business rule validation" — no, they check shape, not meaning

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.

2. "Client-side validation means I don't need server-side validation" — never true

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.

Common Mistakes

Mistake 1 — Forgetting [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.

Mistake 2 — Using [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.

Mistake 3 — Validating the same rule redundantly and inconsistently across layers

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.

When Should I Use It?

Rule of thumb: If the question is "is this piece of data well-formed on its own?" — reach for a DataAnnotations attribute. If the question is "is this allowed, given everything else the system currently knows?" — that's a business rule, and it belongs deeper in your application, checked after structural validation has already passed.

Mental Model

Validation attribute = a rule, declared once, right next to the property it governs.
Model binding + reflection = the mechanism that finds and runs every rule automatically.
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.

Remember: Attributes check shape — is this data well-formed? Business logic checks meaning — is this allowed, right now, for this system? Both are needed; they are not the same job.

Key Takeaway


Check Your Understanding

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?

Show answer

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?

Show answer

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?

Show answer

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?

Show answer

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]?

Show answer

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.