Data Annotations vs FluentValidation: Which should you use?

Published: Monday 30 September 2024

Data Annotations and FluentValidation are popular validation methods that are crucial for your .NET project.

Both have their benefits when it comes to validation. But which one should you choose? We'll dive into both validation methods and review which one you should use.

Validation is key

Validation is key when working with any kind of input, whether it's from forms, APIs, or databases. This is so the data we are collecting is valid and it restricts malicious data from being added by the end user.

C# coding challenges

C# coding challenges

Our online code editor allows you to compile the answer.

In the .NET world, we have two main players:

  • Data Annotations - This is more "baked in" to the .NET framework.
  • FluentValidation - An external library which is gaining a lot of popularity.

Let's break down the differences between them.

How to use Data Annotations

Data Annotations can be added to any class using attributes. The attributes are added to properties in the class and it will validate the property based on the attribute's rules. You can also add your own custom error message to each attribute. There are a number of attributes that can be used including:

  • Required - Ensures that the property has a value
  • StringLength - Restricts the number of characters in a property
  • Range - The property's value must be between two values
  • EmailAddress - The property must have a valid email address

Custom logic can also be added by implementing the IValidatableObject interface to the class and implementing the Validate method. Here is an example of how to add Data Annotations to a UserModel class.

// UserModel.cs
public class UserModel : IValidatableObject
{
	[Required(ErrorMessage = "Name is required")]
	[StringLength(50, ErrorMessage = "Name cannot exceed 50 characters")]
	public string? Name { get; set; }

	[Range(18, 100, ErrorMessage = "Age must be between 18 and 100")]
	public int Age { get; set; }

	[EmailAddress(ErrorMessage = "Invalid email format")]
	public string? Email { get; set; }

	public bool IsPremiumMember { get; set; }

	public decimal Discount { get; set; }	

	public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
	{
		if (IsPremiumMember && Discount <= 0)
		{
			yield return new ValidationResult(
				$"Customer should have a discount if a premium member.",
				new[] { nameof(Discount) });
		}
	}
}

In this class, the name is required and must not exceed 50 characters, the age must be between 18 and 100 and the email address must be a valid one. In addition, there is an additional validation rule where an error is thrown if the user is a premium member but the discount is equal or lower to 0.

An important note with the Validate method is that it will only execute if all property-based validation rules pass. If you want to include it as part property based validation, consider creating your own custom Data Annotation.

Validate an ASP.NET Core Web API controller endpoint

Data Annotations are already built in to an ASP.NET Core Web API controller endpoint. As a result, we only have to pass in the model that we want to be validated.

// UserController.cs
[Route("api/[controller]")]
[ApiController]
public class UserController : ControllerBase
{
	[HttpPost]
	public IActionResult Create(UserModel userModel)
	{
		return NoContent();
	}
}

We can test this by inputting the following request body:

{
	"name": "",
	"age": 135,
	"email": "sdas",
	"isPremiumMember": true,
	"discount": 33
}

When we do that, we get a 400 Bad Request response similar to this:

{
	"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
	"title": "One or more validation errors occurred.",
	"status": 400,
	"errors": {
		"Age": [
			"Age must be between 18 and 100"
		],
		"Name": [
			"Name is required"
		],
		"Email": [
			"Invalid email format"
		]
	},
	"traceId": "00-e6184a0e69d365fc8beb7c3aa17ceb86-4f775d2c5ffee258-00"
}

Let's update the request body to add valid inputs:

{
	"name": "David",
	"age": 40,
	"email": "david@david.com",
	"isPremiumMember": true,
	"discount": 33
}

If we were to run this request body with the API endpoint, the response would return a valid 204 No Content result.

Using dependency injection and async methods

Lets see how we can injected a service from dependency injection into our validation and run an async method.

We have created a UserService class and added it with a scoped lifetime into our ASP.NET Core Web API.

// IUserService.cs
public interface IUserService
{
	Task<bool> DoesUserExistAsync(string? email);
}
// UserService.cs
public class UserService
{
	public async Task<bool> DoesUserExistAsync(string? email)
	{
		await Task.Delay(100);

		return true;
	}
}

We can inject this service into the Validate method in UserModel. That is because the Validate method passes in an ValidationContext instance that allows us to get any service from the IOC container.

However, the Validate method is not asynchronous. That means we need to get the result of the DoesUserExistAsync method in the UserService before we can use it.

// UserModel.cs
public class UserModel : IValidatableObject
{
	...

	public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
	{
		...

		var userService = validationContext.GetRequiredService<IUserService>();

		if (userService.DoesUserExistAsync(Email).Result)
		{
			yield return new ValidationResult(
				$"Customer exists.",
				new[] { nameof(UserModel) });
		}
	}
}

Here is the full code to the UserModel class:

// UserModel.cs
public class UserModel : IValidatableObject
{
	[Required(ErrorMessage = "Name is required")]
	[StringLength(50, ErrorMessage = "Name cannot exceed 50 characters")]
	public string? Name { get; set; }

	[Range(18, 100, ErrorMessage = "Age must be between 18 and 100")]
	public int Age { get; set; }

	[EmailAddress(ErrorMessage = "Invalid email format")]
	public string? Email { get; set; }

	public bool IsPremiumMember { get; set; }

	public decimal Discount { get; set; }

	public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
	{
		if (IsPremiumMember && Discount <= 0)
		{
			yield return new ValidationResult(
				$"Customer should have a discount if a premium member.",
				new[] { nameof(Discount) });
		}

		var userService = validationContext.GetRequiredService<IUserService>();

		if (userService.DoesUserExistAsync(Email).Result)
		{
			yield return new ValidationResult(
				$"Customer exists.",
				new[] { nameof(UserModel) });
		}
	}
}

FluentValidation

We're now going to add the same validation, but this time we are going to use FluentValidation. To begin with, we need to add the following NuGet packages to our .NET project:

The big difference with FluentValidation is that you add your validation to a separate class. As a result, you don't include any validation in your model.

// UserModel.cs
public class UserModel
{
	public string? Name { get; set; }

	public int Age { get; set; }

	public string? Email { get; set; }

	public bool IsPremiumMember { get; set; }

	public decimal Discount { get; set; }
}

Let's create a UserModelValidator class and inherit the AbstractValidator class from FluentValidation, passing in the class that we are validating as its generic type. As we are validating UserModel, that's the class that we would add.

// UserModelValidator.cs
public class UserModelValidator : AbstractValidator<UserModel>
{
	public UserModelValidator()
	{
		RuleFor(user => user.Name)
			.NotEmpty().WithMessage("Name is required")
			.MaximumLength(50).WithMessage("Name cannot exceed 50 characters");

		RuleFor(user => user.Age)
			.InclusiveBetween(18, 100).WithMessage("Age must be between 18 and 100");

		RuleFor(user => user.Email)
			.EmailAddress().WithMessage("Invalid email format");
			
		RuleFor(user => user.Discount)
			.GreaterThan(0).When(s => s.IsPremiumMember)
			.WithMessage("Customer should have a discount if a premium member.");
	}
}

Validate using dependency injection

In-order to use this class to validate the UserModel, we need to add it to dependency injection. We do that using the scoped lifetime, adding the IValidator interface as the service with the UserModel as it's generic type and adding the UserModelValidator as the implementation.

// Program.cs
...
builder.Services.AddScoped<IValidator<UserModel>, UserModelValidator>();
...

In-order to validate the ASP.NET Core Web API endpoint, we need to add some additional code. We inject the IValidator<UserModel> instance into the UserController and then call the Validate method. We can then decide what to do if the input is not valid. For this, we return a 400 Bad Request response and output each invalid property name with the error message. If it's valid, we return a valid 204 No Content response.

// UserController.cs
public class UserController : ControllerBase
{
	private readonly IValidator<UserModel> _userModelValidator;

	public UserController(IValidator<UserModel> userModelValidator)
	{
		_userModelValidator = userModelValidator;
	}

	[HttpPost]
	public IActionResult Create(UserModel userModel)
	{
		var result = _userModelValidator.Validate(userModel);

		if (!result.IsValid)
		{
			return BadRequest(result.Errors.Select(s => new { s.PropertyName, s.ErrorMessage }));
		}

		return NoContent();
	}
}

Once again, we'll test the API endpoint out using this request body:

{
	"name": "",
	"age": 135,
	"email": "sdas",
	"isPremiumMember": true,
	"discount": 33
}

When we do that, we get the following 400 Bad Request response:

[
	{
		"propertyName": "Name",
		"errorMessage": "Name is required"
	},
	{
		"propertyName": "Age",
		"errorMessage": "Age must be between 18 and 100"
	},
	{
		"propertyName": "Email",
		"errorMessage": "Invalid email format"
	}
]

Let's update the request body to add valid inputs:

{
	"name": "David",
	"age": 40,
	"email": "david@david.com",
	"isPremiumMember": true,
	"discount": 33
}

If we were to run this request body, it would return a valid 204 No Content response.

Support for async and dependency injection

If we wish to inject a service from the IoC container, we can just add it to the constructor in the validator class. In-addition, FluentValidation has async support. We can use the MustAsync method to invoke an async method using it.

In this instance, we have injected the IUserService into the UserModelValidator constructor and when validating the email address, we have called the MustAsync method and await the call to the DoesUserExistAsync method in the UserService class.

// UserModelValidator.cs
public class UserModelValidator : AbstractValidator<UserModel>
{   
	public UserModelValidator(IUserService userService)
	{
		...

		RuleFor(user => user.Email)
			.MustAsync(async (userModel, email, validationContext, CancellationToken) =>
			{
				if (await userService.DoesUserExistAsync(email))
				{
					return false;
				}

				return true;
			}).WithMessage("Customer exists.");        
	}
}

Here is the full code for the UserModelValidator class.

// UserModelValidator.cs
public class UserModelValidator : AbstractValidator<UserModel>
{
	public UserModelValidator(IUserService userService)
	{
		RuleFor(user => user.Name)
			.NotEmpty().WithMessage("Name is required")
			.MaximumLength(50)
			.WithMessage("Name cannot exceed 50 characters");

		RuleFor(user => user.Age)
			.InclusiveBetween(18, 100)
			.WithMessage("Age must be between 18 and 100");

		RuleFor(user => user.Email)
			.EmailAddress()
			.WithMessage("Invalid email format");

		RuleFor(user => user.Discount)
			.GreaterThan(0).When(s => s.IsPremiumMember)
			.WithMessage("Customer should have a discount if a premium member");

		RuleFor(user => user.Email)
			.MustAsync(async (userModel, email, validationContext, CancellationToken) =>
			{
				if (await userService.DoesUserExistAsync(email))
				{
					return false;
				}

				return true;
			}).WithMessage("Customer exists.");

	}
}

To use an async method for validation, we need to replace the Validate call with ValidateAsync. This is what we've done when validating the UserModel in our ASP.NET Core Web API controller endpoint.

// UserController.cs
public class UserController : ControllerBase
{
	...

	[HttpPost]
	public async Task<IActionResult> Create(UserModel userModel)
	{
		var result = await _userModelValidator.ValidateAsync(userModel);

		...
	}
}

Here is the full code for the UserController:

// UserController.cs
[Route("api/[controller]")]
[ApiController]
public class UserController : ControllerBase
{
	private readonly IValidator<UserModel> _userModelValidator;

	public UserController(IValidator<UserModel> userModelValidator)
	{
		_userModelValidator = userModelValidator;
	}

	[HttpPost]
	public async Task<IActionResult> Create(UserModel userModel)
	{
		var result = await _userModelValidator.ValidateAsync(userModel);

		if (!result.IsValid)
		{
			return BadRequest(result.Errors.Select(s => new { s.PropertyName, s.ErrorMessage }));
		}

		return NoContent();
	}
}

The differences between the validation methods

Now that we've covered the basics, let's summarise some of the key differences between Data Annotations and FluentValidation.

  • Data Annotations can make your model class messy, as it combines data structure with validation rules.
  • FluentValidation keeps your validation logic separate, which promotes cleaner, more maintainable code.
  • Data Annotations are built into the .NET framework, so they don't require any extra libraries.
  • FluentValidation is an external library, which means adding more dependencies to your project.
  • Data Annotations lacks async support. Any async calls would need to return the result before they can be used.
  • FluentValidation has async support meaning it's better support at making calls to a database and third-party services.

Which one should you choose?

Use Data Annotations when:

  • You have simple validation rules.
  • You want the built-in simplicity of .NET.
  • You're working on smaller projects or prototypes where adding an external library might be overkill.

Use FluentValidation when:

  • You need more complex validation logic.
  • You want to separate validation logic from your models.
  • You need better testability and cleaner code for larger projects.

To help you decide on which one you should choose, watch our video where we add each validation method to an ASP.NET Core Web API.