π§Ό Clean API Validation in .NET β FluentValidation + Global Filter Done Right

Building a modern API? Donβt let your validation logic spiral into an unmaintainable mess. Combine the power of FluentValidation and a Global Filter to enforce clean, testable rules and return consistent, user-friendly error responses.
Letβs break it down π
π Request Lifecycle β Validation Flow
graph TD
A[Client Request] --> B[Model Binding]
B --> C[FluentValidation]
C -->|Validation Fails| D[Global Error Filter]
D --> E[Formatted Error Response]
C -->|Validation Passes| F[Controller Action]
F --> G[Success Response]
π Why Use Both FluentValidation + Global Filter?
Layer | Purpose |
β FluentValidation | Clean, reusable, testable rules (separated from domain/UI logic) |
β Global Filter | Centralized formatting of validation errors for consistency |
β Data Annotations | Inline and tightly coupled β not ideal for scalability/testability |
β Custom Logic | Useful for DB lookups, async rules, and cross-field validations |
π§© Think of It Like This
FluentValidation β What to validate?
e.g..NotEmpty()
,.Must(...)
,.GreaterThan(...)
Global Filter β How to respond?
Format errors like this:{ "success": false, "errors": [ { "field": "Type", "message": "Invalid bus type" } ] }
Custom Validation β Complex checks?
Ideal for service/DB-based or async validation rulesData Annotations β Quick & dirty
Avoid mixing validation with models in scalable apps
π Best Practice Cheatsheet
Concern | Best Tool |
Field-level validation | β FluentValidation |
Cross-property logic | β FluentValidation |
Uniform error output | β Global Filter (IActionFilter/Middleware) |
Async/complex logic | β Custom Validators or Middleware |
One-off inline rules | β Avoid Data Annotations |
β οΈ Why NOT Use Just One?
β FluentValidation Alone?
Default output:
{
"errors": {
"field": ["Some error message"]
}
}
No success: false
, no flattened format, no UX consistency.
β Only Global Filter?
Manually writing validations = Repetitive, fragile code.
β Data Annotations?
β Hard to test
β No DI
β Mixes concerns
β Poor for complex rules
β Real Example: FluentValidation + Global Filter
BusValidator.cs
public class BusValidator : AbstractValidator<Bus>
{
private static readonly List<string> AllowedTypes = new() { "Mini", "DoubleDecker", "Electric" };
public BusValidator()
{
RuleFor(x => x.BusName)
.NotEmpty().WithMessage("Bus name is required");
RuleFor(x => x.Type)
.Must(type => AllowedTypes.Contains(type))
.WithMessage("Invalid bus type");
}
}
Global Filter Output
{
"success": false,
"errors": [
{ "field": "Type", "message": "Invalid bus type" }
]
}
π§ͺ Unit Test Example (xUnit + FluentValidation.TestHelper)
public class BusValidatorTests
{
private readonly BusValidator _validator = new();
[Fact]
public void Should_Have_Error_When_BusName_Is_Empty()
{
var model = new Bus { BusName = "", Type = "Mini" };
var result = _validator.TestValidate(model);
result.ShouldHaveValidationErrorFor(x => x.BusName);
}
}
β Final Comparison β When to Use What?
Validation Type | Use It? | Why |
FluentValidation | β Yes | Clean, testable, supports DI |
Global Filter | β Yes | Centralized error formatting |
Custom Validation | β Yes | Async logic, DB lookups, cross-entity rules |
Data Annotations | β No | Obsolete in scalable architecture |
π Wrapping Up
Combining FluentValidation + Global Filter gives you:
β
Clean separation of concerns
β
Predictable, consistent client responses
β
Maintainable and testable validation logic
π This pattern is a must-have for any professional .NET Web API project!
π‘ Bonus Tip: Middleware Over Filters?
Prefer middleware? You can handle validation globally via middleware too β ideal for advanced async or cross-cutting rules.
βοΈ Drop a comment if you'd like to see that version!
π Useful Resources
Subscribe to my newsletter
Read articles from Priya directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
