How to Boost Your .NET Apps with Fluent Validation (Step-by-Step Tutorial)

How to Boost Your .NET Apps with Fluent Validation (Step-by-Step Tutorial)

Validating input is one of those tasks every .NET developer faces, yet many still rely on DataAnnotations or clunky manual checks that quickly become a maintenance nightmare. If you want clean, expressive, and testable validation, Fluent Validation is the tool you’ve been missing.

In this tutorial, I’ll walk you through setting up Fluent Validation in your .NET app, writing rules the way they should be: simple, reusable, and easy to maintain. No fluff, just practical steps to level up your validation game.

Traditional Validation in .NET

Let’s be honest: DataAnnotations are fine for tiny projects, but as soon as your validation needs get complex, they fall short. Complex rules? Forget about it. Mixing validation into your models or controllers? That’s a recipe for spaghetti code.

The real issue? Validation logic scattered all over your app makes it hard to test, reuse, or even understand. Fluent Validation solves this by isolating your rules in dedicated classes, providing a fluent, expressive API that fits naturally into modern .NET development.

 

Here’s a typical example using DataAnnotations:

C#
				public class PreviewNotificationRequest
{
    [Required]
    [MaxLength(50)]
    public string Title { get; set; }

    [Required]
    [MaxLength(1000)]
    public string Message { get; set; }
}

			

At first glance, this seems fine, until you need custom messages, localized errors, or dynamic rules. Then you end up moving validation into your business layer, services, endpoints or controllers:

C#
				if (notification.Message.Length > 1000)
{
    return Result<bool>.Failure(Error.Validation(
        Code.Generate(nameof(NotificationCommandHandler), nameof(ValidatePreviewAsync)),
        Message.MaxLength("Message", 1000)));
}

if (notification.Title.Length > 50)
{
    return Result<bool>.Failure(Error.Validation(
        Code.Generate(nameof(NotificationCommandHandler), nameof(ValidatePreviewAsync)),
        Message.MaxLength("Title", 50)));
}

			

This kind of validation is hard to read, hard to test, and repeated everywhere. Your business logic gets mixed with validation, which hurts maintainability.

Setting Up Fluent Validation

Let’s replace that repetitive validation logic with something more elegant

Add the NuGet package to your project:

PowerShell
				dotnet add package FluentValidation.DependencyInjectionExtensions
			

Then, register it in your Program.cs or Configuration File:

C#
				builder.Services.AddValidatorsFromAssembly(typeof(Program).Assembly, includeInternalTypes: true);
			

Let’s say you have a basic endpoint in your .NET Minimal API:

C#
				app.MapPost("/notification/preview", (PreviewNotificationRequest request) =>
{
    // ...
});

			

Instead of writing manual if statements to validate input, you can define strong validation rules using FluentValidation, making your code clean, testable, and easy to extend.

Create a “Validators” folder in your solution and create an internal sealed class PreviewNotificationRequestValidator:

C#
				internal sealed class PreviewNotificationRequestValidator : AbstractValidator<PreviewNotificationRequest>
{
    public PreviewNotificationRequestValidator()
    {
        RuleFor(x => x.Message)
            .NotEmpty().WithMessage(Message.Mandatory("Message"))
            .MaximumLength(1000).WithMessage(Message.MaxLength("Message", 1000));

        RuleFor(x => x.Title)
            .NotEmpty().WithMessage(Message.Mandatory("Title"))
            .MaximumLength(50).WithMessage(Message.MaxLength("Title", 50));
    }
}
			

The next step is returning all validation errors.

Here’s how you can do it:

C#
				app.MapPost("/notification/preview", (PreviewNotificationRequest request,
                                     IValidator<PreviewNotificationRequest> validator) =>
{
    var validationResult = _validator.Validate(request);

    if (!validationResult.IsValid)
    {
        var errors = validationResult.Errors
            .Select(e => new ValidationError
            {
                Field = e.PropertyName,
                Message = e.ErrorMessage
            })
            .ToList();

        //Or use ToDictionary with ProblemDetails

    
        return Results.BadRequest(Result<bool>.Failure(
            Error.Validation(
                ResponseCode.Generate(nameof(NotificationEndpoint), nameof(request)),
                errors)));
    }
    
    // Your logic here

    return Results.Ok(Result<bool>.Success(true));
});
			

By structuring the errors in a clear list inside the response, you keep your API consistent and user-friendly

Adding Detailed and Conditional Validation Scenarios with FluentValidation

Imagine that now you want to apply a logic where the validation rules change based on the notification type: email, SMS or push. Fluent validation also does this in a very elegant way using the “When”.

Here’s how I structure such a validator to cover detailed and conditional rules per notification type:

C#
				internal sealed class PreviewNotificationRequestValidator : AbstractValidator<PreviewNotificationRequest>
{
    public PreviewNotificationRequestValidator()
    {
        RuleFor(c => c.Message)
            .NotEmpty().WithMessage(Message.Mandatory("Message"));

        RuleFor(c => c.Title)
            .NotEmpty().WithMessage(Message.Mandatory("Title"));
        
        // WHEN EMAIL
        When(c => c.Type == NotificationType.Email, () =>
        {
            RuleFor(c => c.Title)
                .MaximumLength(255).WithMessage(Message.MaxLength("Title", 255));

            RuleFor(c => c.Email)
                .NotEmpty().WithMessage(Message.Mandatory("Email"))
                .EmailAddress().WithMessage(Message.InvalidEmail);
        });

        // WHEN SMS
        When(c => c.Type == NotificationType.Sms, () =>
        {
            RuleFor(c => c.Message)
                .MaximumLength(160).WithMessage(Message.MaxLength("Message", 160));

            RuleFor(c => c.Title)
                .MaximumLength(11).WithMessage(Message.MaxLength("Title", 11));

            RuleFor(c => c.PhoneNumber)
                .NotEmpty().WithMessage(Message.Mandatory("Phone Number"));
        });

        //WHEN PUSH
        When(c => c.Type == NotificationType.Push, () =>
        {
            RuleFor(c => c.Message)
                .MaximumLength(1000).WithMessage(Message.MaxLength("Message", 1000));

            RuleFor(c => c.Title)
                .MaximumLength(50).WithMessage(Message.MaxLength("Title", 50));

            RuleFor(c => c.PushType)
                .NotEmpty().WithMessage(Message.Mandatory("Push Type"));
        });
    }
}

			

Let me walk you through the key parts:

–   Basic Required Checks

  • We start by ensuring that the Message and Title properties are not empty. This is fundamental to avoid processing invalid or incomplete requests.

 

–   The magic happens here. Depending on the Type of notification (Email, Sms, or Push), different validation rules apply:

  • Email: The Title can be up to 255 characters, and the Email must be present and properly formatted.

  • Sms: The Message has a stricter length limit of 160 characters, the Title is limited to 11 characters (e.g., sender ID in SMS), and the PhoneNumber must be provided.

  • Push: Here, the Message max length is 1000, Title is limited to 50 characters, and the PushType must be specified.

Extracting FluentValidation Configuration to appsettings.json

Hardcoding max lengths and validation rules can lead to rigid code that’s harder to maintain and test. Instead, you can externalize these settings to your appsettings.json and inject them into your validators via IOptions<T>.

Define the settings class:

C#
				public class NotificationValidationSettings
{
    public int EmailTitleMaxLength { get; set; }
    public int SmsTitleMaxLength { get; set; }
    public int PushTitleMaxLength { get; set; }
    public int SmsMessageMaxLength { get; set; }
    public int PushMessageMaxLength { get; set; }
}

			

Register the configuration in Program.cs:

C#
				builder.Services.Configure<NotificationValidationSettings>(
    builder.Configuration.GetSection("NotificationValidationSettings"));

			

Add it to your appsettings.json:

JSON
				"NotificationValidationSettings": {
  "EmailTitleMaxLength": 255,
  "SmsTitleMaxLength": 11,
  "PushTitleMaxLength": 50,
  "SmsMessageMaxLength": 160,
  "PushMessageMaxLength": 1000
}
			

Inject settings into your validator:

C#
				public class PreviewNotificationRequestValidator : AbstractValidator<PreviewNotificationRequest>
{
    public PreviewNotificationRequestValidator(
        IOptions<NotificationValidationSettings> _options)
    {
        var options = _options.Value;
        
        //Example:
        When(c => c.Type == NotificationType.Sms, () =>
        {
            RuleFor(c => c.Message)
                .MaximumLength(options.SmsMessageMaxLength)
                .WithMessage(Message.MaxLength("Message", options.SmsMessageMaxLength));
        });
    }
}

			

You can even reload these settings at runtime using IOptionsSnapshot<T> or IOptionsMonitor<T> depending on your needs.

Conclusion

Using FluentValidation the right way can be the difference between a codebase that’s easy to maintain and one that’s full of duplication and confusion.

Making validation dynamic through configuration settings not only future-proofs your app, it also empowers non-developers (like Product or QA teams) to participate in tuning your platform’s behavior, without touching your code.

By embracing FluentValidation + configuration, you’re:

  • Speaking the language of maintainability.

  • Making your code easier to test.

  • Improving developer experience for your team.

And most importantly, you’re writing code that lasts.

Thank you for reading.

See you next time!

Share the Post:
plugins premium WordPress