Where's the remote

Custom model binding in .Net core MVC

Sometimes when you are writing a web app you want to store data one way and display it another way. This sounds simple enough, but somehow I found myself a bit stuck by a task exactly like this recently. Almost pulling my hair out, I came up with a solution I thought would be worth writing about so at the very least I can avoid this frustration in the future.

Let’s set the scene.

So for a bit of context we are working on an app that stores percentage values as decimals (e.g 0.85 is 80%). That seems fairly normal, and .Net has a myriad of ways to format data for display purposes. In fact let’s set the scene with a little bit of code…

Imagine we have a simple model of a product in a shop. We store the product name, sku, price and the profit margin on product as a percentage (this is the important part for us in this example), it looks something like this.

namespace Example.Models
{
    using System;
    using System.ComponentModel.DataAnnotations;
    using Newtonsoft.Json;
    
    public class Product
    {
        [Key]
        [Display(Name = "Product Id")]
        [JsonProperty(PropertyName = "productId")]
        public int ProductId { get; set; }
        
        [Display(Name = "Product Name")]
        [JsonProperty(PropertyName = "productName")]
        public string ProductName { get; set; }

        [Display(Name = "SKU")]
        [JsonProperty(PropertyName = "sku")]
        public string SkuNumber { get; set; }

        [Display(Name = "Price")]
        [JsonProperty(PropertyName = "price")]
        public decimal Price { get; set; }

        [Display(Name = "Profit Margin")]
        [JsonProperty(PropertyName = "profitMargin")]
        public decimal ProfitMargin { get; set; }
    }
}

Now in our database the we have a table something like the following:

CREATE TABLE Product(
    ProductId       INT NOT NULL IDENTITY(1,1) PRIMARY KEY,
    ProductName     NVARCHAR(255),
    SkuNumber       NVARCHAR(255),
    Price           MONEY,
    ProfitMargin    DECIMAL(6, 5)
);

Note the profit margin column definition, we are effectively restricting the values that can be inserted there to be decimal, or the decimal representation of a percentage. This means that for example, if the profit margin is 23% the value that we are going to store is 0.23000.

So nothing crazy has happened, and in this imaginary system we can happily map the object to the Database table for products.

Saving and displaying data, oh and an annoying little bug.

Let’s jump ahead to the part where I got into a bit of a rage because things weren’t working the way I expected. All the CRUD code is written and products are being saved and displayed, updated and deleted, all the things we need.

It’s time to make the data appear a little nicer to the human eye, and to do that the profit margin should be displayed as a percentage and not a long decimal. To get that done we lean on .NET’s data annotations, so we change the model’s profit margin property to have the DisplayFormat annotation like follows:

[Display(Name = "Profit Margin")]
[JsonProperty(PropertyName = "profitMargin")]
[DisplayFormat(ApplyFormatInEditMode = true, DataFormatString = "{0:P6}")]
public decimal ProfitMargin { get; set; }

Hey bug time!

Notice that the ApplyFormatInEditMode field is set to true, because “hey, humans like percentages more that decimals”.

So now when a user wants to edit the profit margin, the form will be populated with the decimal formatted as a percentage. i.e. column value times 100.

So now the user will input the new value as a percentage, and you would imagine (well I imagined) that the operation(s) used to change the display format would be reversed, when the data is sent back to the controller.

Turns out that’s not the case, and all of a sudden profit margins are huge! (Or infact the db isn’t allowing the values to be saved).

This caused me to do many google searches, many scrolls through StackOverflow and not much luck. So I started working on a fix that used Javascript that just started to feel wrong…So I started looking at how the data binds to the model.

The Fix!

Turns out that you can write custom code for how a property binds to a model, on a property by property basis. So lets dive right into the code to see how this works.

namespace Example.Utils
{
    using System.Threading.Tasks;
    using Microsoft.AspNetCore.Mvc.ModelBinding;
    public class PercentModelBinder: IModelBinder
    {
        /// <summary>
        /// Binds the annotated decimal attribute to change it from it's displayed format percentage to a decimal.
        /// e.g.
        ///     User submits: 85.34
        ///     0.8534 is bound to the model as the value.
        /// </summary>
        /// <param name="bindingContext"></param>
        /// <returns></returns>
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            var value = valueProviderResult.FirstValue; 
            if (decimal.TryParse(value, out var result))
            {
                result /= 100;
                bindingContext.Result = ModelBindingResult.Success(result);
            }

            return Task.CompletedTask;
        }
    }
}

Ok, lets take a look at what is going on here. We have a class that implements the IModelBinder interface, which needs to implement the BindModelAsync method, this is the method that gets called during model binding.

The first step is to get the actual value from the bindingContext, and because we are going to use this model binder per property, we can just get the first value and manipulate it however we like, in this case dividing it by 100 and setting the value of the binding context to a success result with the value we are binding.

This is pretty cool but now how do we tie this to the profit margin property? Glad you asked, we need to add another data annotation to the profit margin property, making it now look like follows.

[Display(Name = "Profit Margin")]
[JsonProperty(PropertyName = "profitMargin")]
[DisplayFormat(ApplyFormatInEditMode = true, DataFormatString = "{0:P6}")]
[BindProperty(BinderType = typeof(PercentModelBinder))]
public decimal ProfitMargin { get; set; }

And that’s it, that’s how you write a custom model binder for a property. Hopefully that can help you or more likely my future self if this sort of issue ever pops up.