Skip to main content

Command Palette

Search for a command to run...

Data Annotations vs FluentValidation in .NET — Which to Use When

Updated
6 min readView as Markdown
Data Annotations vs FluentValidation in .NET — Which to Use When
A
I'm a Full-Stack .NET Developer with 2.5 years of experience building enterprise web applications. I write practical articles about ASP.NET Core, Clean Architecture, JWT, Angular, and Azure — based on real projects I've built and deployed. Currently building and sharing what I learn.

Every API needs validation. The title can't be empty, the rating has to be between one and ten, the email has to look like an email. Simple rules.

What isn't simple is where to put them.

I've used three different approaches across my projects, and I've settled on a rule for when to reach for each. Here's the comparison.


Option 1 — Manual Checks in the Controller

This is where most of us start.

csharp

[HttpPost]
public async Task<IActionResult> Create(CreateReviewDto dto)
{
    if (string.IsNullOrWhiteSpace(dto.Title))
        return BadRequest("Title is required.");

    if (dto.Title.Length > 200)
        return BadRequest("Title cannot exceed 200 characters.");

    if (dto.Rating < 1 || dto.Rating > 10)
        return BadRequest("Rating must be between 1 and 10.");

    if (string.IsNullOrWhiteSpace(dto.Content))
        return BadRequest("Content is required.");

    await _service.CreateAsync(dto);
    return Ok();
}

It works. There's nothing to install, nothing to configure, and you can read exactly what happens.

The problem shows up on the second endpoint. Update needs the same rules. So does the draft endpoint. Now the same checks exist in three places, and when the product decides ratings go to five instead of ten, you have to find all three.

The other issue is that the controller is now doing two jobs. It handles the request and it owns the business rules. Those change for different reasons and at different times.

Use it when: you have one endpoint and one rule. Genuinely — don't add a library for that.


Option 2 — Data Annotations

Built into .NET. You put the rules on the model itself.

csharp

public class CreateReviewDto
{
    [Required(ErrorMessage = "Title is required.")]
    [MaxLength(200)]
    public string Title { get; set; }

    [Range(1, 10, ErrorMessage = "Rating must be between 1 and 10.")]
    public int Rating { get; set; }

    [Required]
    [MinLength(20)]
    public string Content { get; set; }
}

The controller gets its life back:

csharp

[HttpPost]
public async Task<IActionResult> Create(CreateReviewDto dto)
{
    await _service.CreateAsync(dto);
    return Ok();
}

With [ApiController] on the controller, ASP.NET Core runs the validation automatically before your action executes and returns a 400 with the errors. You don't write a line of plumbing.

The rules also live right next to the property they describe, which is genuinely nice to read.

Where it gets awkward is anything conditional or contextual. A rule like "the rating is required only when the review is being published, not saved as a draft" doesn't fit an attribute. Neither does "the country has to be in the list of supported countries" when that list comes from configuration — attributes are compile-time constructs and can't take an injected service.

You can write a custom ValidationAttribute for these, and people do. But the code gets ugly fast, and testing it is harder than it should be.

Use it when: the rules are simple, static, and only about the shape of the data. Required, max length, range, regex. Most DTOs are exactly this.


Option 3 — FluentValidation

A separate class per request, with rules defined in a fluent chain.

csharp

public sealed class CreateReviewValidator : AbstractValidator<CreateReviewDto>
{
    public CreateReviewValidator(IReviewSettings settings)
    {
        RuleFor(x => x.Title)
            .NotEmpty().WithMessage("Title is required.")
            .MaximumLength(200);

        RuleFor(x => x.Rating)
            .InclusiveBetween(1, settings.MaxRating)
            .When(x => x.IsPublished);

        RuleFor(x => x.Content)
            .NotEmpty()
            .MinimumLength(settings.MinContentLength);
    }
}

Two things in there that the previous options can't do.

That constructor takes IReviewSettings. The validator is resolved from the DI container like any other service, so it can depend on configuration, a repository, or anything else registered. If you need to check that an email isn't already taken, you can inject the repository and do it right here.

And .When(x => x.IsPublished) makes the rule conditional on the state of the object. Draft reviews skip the rating check. Published ones don't.

The tradeoff is a separate file per request type and a bit of setup. Your model no longer tells you its own rules — you have to go find the validator.

Use it when: rules are conditional, depend on other fields, or need something from the container. Also when you want validation logic under unit test, which is much easier with a plain class than with an attribute.


Side by Side

Manual Data Annotations FluentValidation
Setup required None None Package + registration
Rules live In the controller On the model In a separate class
Reusable across endpoints No Yes Yes
Conditional rules Yes, messily Awkward Built in
Can use injected services Yes No Yes
Easy to unit test No Not really Yes
Good for simple DTOs Overkill to maintain Yes Slight overkill

What I Actually Do

I use both Data Annotations and FluentValidation in the same project, and I don't think that's inconsistent.

Data Annotations handle the shape of the data — required fields, lengths, ranges. They're right there on the property, there's no setup, and for a DTO with four simple fields that's the whole job.

FluentValidation takes over when a rule needs to know something the model doesn't have access to. Configuration values, database lookups, or conditions based on other fields.

The mistake I'd avoid is treating this as a one-or-the-other decision. Reaching for FluentValidation on a DTO with three [Required] fields adds a file and a registration for no benefit. Trying to force a database-dependent rule into a custom attribute produces code nobody wants to maintain.

Match the tool to the rule.


One Thing That Applies to All Three

Validation on the request is not validation on your domain. An API-level check that the rating is between one and ten stops bad input at the door — it doesn't stop your own code from setting a rating of fifty somewhere downstream.

If a rule is genuinely an invariant of your domain, it belongs in the domain object too, not only in the request validator.


Both my projects use a mix of these approaches if you want to see it in context: