Model Binders in ASP.NET Core

When ASP.NET Core receives a web request, it routes the request to a specific action - a method that matches the route - of a controller.

Often there is not only the route, there are values too. Like user identifiers, names or complex types in a POST request. The ASP.NET middleware tries to bind the value to the parameter with the specific name - for all parameters.

For example if you have a route with a pattern like /users/{id} and a matching request users/123, the parameter id will have the value 123.

[Route("users/{id}", Name = "GetUserById")]
public async Task<ActionResult<User>> GetUser(int id)
{
    User user = await _userRepository.GetAsync(id);
    
    if ( user is null )
    {
        return NotFound();
    }

    return Ok(user);
}

If you now have a lot of places where you query the user by id from the database, you can move this code to a custom model binder.

public class UserModelBinder : IModelBinder
{
    private readonly IUserRepository _userRepository;

    public UserModelBinder(IUserRepository userRepository) // supports dependency injection
    {
        _userRepository = userRepository;
    }

    public async Task BindModelAsync(ModelBindingContext bindingContext)
    {
        if (bindingContext == null)
        {
            throw new ArgumentNullException(nameof(bindingContext));
        }

        // Specify a default argument name if none is set by ModelBinderAttribute
        string modelName = bindingContext.ModelName;

        // Try to fetch the value of the argument by name
        ValueProviderResult valueProviderResult =
            bindingContext.ValueProvider.GetValue(modelName);

        if (valueProviderResult != ValueProviderResult.None)
        {
            bindingContext.ModelState.SetModelValue(modelName,
                valueProviderResult);

            string value = valueProviderResult.FirstValue;

            // Check if the argument value is null or empty
            if (!string.IsNullOrEmpty(value))
            {
                // check if the value is an int as expected
                if (!int.TryParse(value, out int id))
                {
                    bindingContext.ModelState.TryAddModelError(modelName,
                        "User Id must be an integer.");
                }
                else
                {
                    // load the user from the database => repository
                    User user = await _userRepository.GetAsync(id);

                    // bind the user to the action
                    bindingContext.Result = ModelBindingResult.Success(result);
                }
            }
        }
    }
}

This model binder looks into the requests, tries to get the integer value from the user id parameter id, searched the database for the user and binds the value to the action.

Now we can remove the repository request from our controller:

[Route("users/{id}", Name = "GetUserById")]
public async Task<ActionResult<User>> GetUser(
		[ModelBinder(Name = "id")] User user) // we need the attribut here to "listen" for the parameter "id"
{
    if ( user is null )
    {
        return NotFound();
    }

    return Ok(user);
}

Finally, we still need to register the binder in the middleware. To do this, we first need a provider

public class UserModelBinderProvider : IModelBinderProvider
{
    public IModelBinder GetBinder(ModelBinderProviderContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (context.Metadata.ModelType == typeof(User)) // we want to use our custom model binder when action parameter is of type User
        {
            return new BinderTypeModelBinder(typeof(UserModelBinder));
        }
        else
        {
            return null; // ignore all other types
        }
    }
}

Now we can register the binder in the startup class.

services.AddMvc(options =>
{ 
    // Model Binder
    options.ModelBinderProviders.Insert(0, new UserModelBinderProvider()); // register the binder to be the first one in the pipe
});

Conclusion

The model binder may seem a bit over the top in this example.

However, if you imagine that you have an application or an API where models or data are needed over and over again, model binders saves you a lot of code - and also makes the actions cleaner.