In this article, we are going to talk about what is ModelState validation, why, and how to use it in ASP.NET Core.
We always strive to separate concerns and decouple different application layers when designing our APIs. Since client input is the first thing that comes to our web API, we usually want to validate that input before further processing. Also, we want to separate that processing from the rest of the application. Let’s see how we can achieve that in ASP.NET Core.
VIDEO: Use ModelState Validation in .NET Core Properly to Clean Your Actions.
What is a ModelState C#?
When we talk about ModelState
, we mean ModelState
property of the ControllerBase abstract class in the Microsoft.AspNetCore.Mvc
namespace.
It is of ModelStateDictionary type and it represents errors that come from two subsystems: model binding and model validation. The model binding arises errors that can occur when attempting to bind values from an HTTP request to an action method, generally data conversion errors. After the model binding comes model validation, the moment when we check the business rules of our domain.
Let’s explain that theory with one small example:
public class CreateBookInputModel { public string? Title { get; set; } public string? Description { get; set; } public string? ISBN { get; set; } }
For example, the model binding logic will check that proper value types are assigned to each property. That means we can’t bind the following JSON to our CreateBookInputModel
since it is going to fail because the Title
is not a number:
{ "Title": 100, "Description": "Book description", "ISBN": "123456789" }
On the other hand, checking that the ISBN string is a 10 or 13 digit number that conforms to a particular digit calculation could be part of the model validation logic.
ModelState Validation Setup
We are going to create our examples with Visual Studio 2022 and the ASP.NET Core Web API project template.
After creating the app, we are going to rename WeatherForecastController
to BooksController
and change the boilerplate code:
[ApiController] [Route("[controller]")] public class BooksController : ControllerBase { }
Furthermore, we are going to place our CreateBookInputModel
class in the Models
folder and delete WeatherForecast
class.
Also, let’s create one POST endpoint in our BooksController
:
[HttpPost] public IActionResult Post([FromBody] CreateBookInputModel createBookInputModel) { return Ok(createBookInputModel); }
For the sake of simplicity, our controller method is going to return the OK
result with the same object it has received. We are not going to have an infrastructure layer in this article or work with 201 status codes for the POST action. The main focus is on the ModelState validation.
Since our BookController
inherits ControllerBase
we are able to access ModelState
property in our Post
method, but we are not going to do that yet. Before going further with our example, let’s explain how validation works.
What Does ModelState Validation Validate?
Model binding and model validation occur before executing a controller action in our APIs. Moreover, the ModelState
object has an IsValid
property where we can check its state. This property is exposed since it should be an application’s responsibility to review ModelState
and act accordingly. We are going to see later how the framework can help us with that.
Adding the Validation Rules to the Model
Until now, we have seen just one example of model binding validation. And that was the one we got with defining our properties in the model binding class.
That said, let’s add more validation rules to our CreateBookInputModel
class:
public class CreateBookInputModel { [Required] public string? Title { get; set; } [MaxLength(250)] public string? Description { get; set; } [Required] [StringLength(13, MinimumLength = 13)] public string? ISBN { get; set; } }
[Required]
, [MaxLength(250)]
and [StringLength(13, MinimumLength = 13)]
are validation attributes from the System.ComponentModel.DataAnnotations namespace, and let us specify validation rules for model properties. If we can’t find any appropriate built-in attributes we can write custom validation attributes as well.
It is worth mentioning that we could also define our Title
without [Required]
attribute:
public string Title { get; set; }
Since non-nullable properties are treated as if they had a [Required(AllowEmptyStrings = true)]
attribute, missing Title
in the request will also result in a validation error. We can override this behavior in our Program
class:
builder.Services.AddControllers( options => options.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true);
Sometimes, it is good to separate validation logic from the binding models. We could achieve that by using the FluentValidation library. You can read more about it in our FluentValidation in ASP.NET Core article.
Getting the Validation Errors for Invalid Requests
Let’s see validation in action. We are going to run our solution and make a POST request via Postman with the request body:
{ "Description": "Book description", "ISBN": "123456789" }
After sending the request, we get validation errors with 400 Bad Request
status code in the response:
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "traceId": "00-e98123452c30c41dcb42a9b6c0c61f00-87ee6b6464b24a46-00", "errors": { "ISBN": [ "The field ISBN must be a string with a minimum length of 13 and a maximum length of 13." ], "Title": [ "The Title field is required." ] } }
Validation attributes let us specify the error messages we want to return for invalid input. Since we didn’t specify any for the ISBN
property, we have got the one that the attribute generated for us. Let’s change the error message to a more appropriate one:
public class CreateBookInputModel { [Required] public string? Title { get; set; } [MaxLength(250)] public string? Description { get; set; } [Required] [StringLength(13, MinimumLength = 13, ErrorMessage = "The ISBN must be a string with the exact length of 13.")] public string? ISBN { get; set; } }
After resending the same request we now get the response with the changed error message:
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "traceId": "00-b80c1c2a49c3c63ab6d34dd87cc4bdb4-d10e4537a96b88ad-00", "errors": { "ISBN": [ "The ISBN must be a string with the exact length of 13." ], "Title": [ "The Title field is required." ] } }
We have stated that it is an application’s responsibility to check ModelState
and act accordingly. But in our code, we don’t check ModelState
anywhere. Yet, we’ve got validation errors.
That’s possible because our controller has an [ApiController]
attribute. One of its functionalities is to validate ModelState
for us and return an automatic HTTP 400 response.
However, sometimes we don’t want to rely on automatic responses. For example, when the model is invalid, the proper status code should be 422 Unprocessable Entity
.
Let’s see how we can achieve that.
Manually Invoking ModelState Validation
There are multiple ways of achieving the “manual” ModelState
validation. Since we can access ModelState
from our controller, the first way is to check the ModelState.IsValid
property from our controller method:
[HttpPost] public IActionResult Post([FromBody] CreateBookInputModel createBookInputModel) { if (!ModelState.IsValid) { return UnprocessableEntity(ModelState); } return Ok(createBookInputModel); }
We can do better. If our controller ends up having, e.g., five endpoints, we will have some duplicated code. That leads us to the second way of doing the validation, using action filters. Let’s implement one simple action filter:
public class ValidationFilterAttribute : IActionFilter { public void OnActionExecuting(ActionExecutingContext context) { if (!context.ModelState.IsValid) { context.Result = new UnprocessableEntityObjectResult(context.ModelState); } } public void OnActionExecuted(ActionExecutedContext context) {} }
We can use that filter on our Post
action after we register it as a scoped service in our Program
class:
builder.Services.AddScoped<ValidationFilterAttribute>();
Now we are ready to remove the validation code from our method and use our validation filter as a service:
[HttpPost] [ServiceFilter(typeof(ValidationFilterAttribute))] public IActionResult Post(CreateBookInputModel createBookInputModel) { return Ok(createBookInputModel); }
But if we try to run our solution and make a request, we will still get an automatic 400
error response. One idea to fix this could be to remove the [ApiController]
attribute, and yes, our code will work. However, we shouldn’t do that since this attribute also brings other functionalities to our controller.
So, we are going to fix this differently by disabling the automatic validation in our Program
class:
... builder.Services.Configure<ApiBehaviorOptions>(options => options.SuppressModelStateInvalidFilter = true); ...
Now, if we run the same request as before via Postman:
{ "ISBN": [ "The ISBN must be a string with the exact length of 13." ], "Title": [ "The Title field is required." ] }
We can see that our input was validated, and we receive the 422 Unprocessable Entity
status code.
Adding the Custom Validation Logic to the Controller
Apart from the IsValid
property we can also access ModelState.AddModelError
method from the controller. It allows us to implement custom business logic for our model. For example, we could add interdependency between the Title
and Description
properties:
[HttpPost] public IActionResult Post([FromBody] CreateBookInputModel createBookInputModel) { if (createBookInputModel.Title != null && createBookInputModel.Description != null && !createBookInputModel.Description.Contains(createBookInputModel.Title)) { ModelState.AddModelError(nameof(createBookInputModel.Description), "Book description should contain book title!"); } if (!ModelState.IsValid) { return UnprocessableEntity(ModelState); } return Ok(createBookInputModel); }
Conclusion
In this article, we have explained what is a ModelState
validation. Also, we have shown different ways of doing validation in our API and how to override automatic validation provided by [ApiController]
attribute.