Adding a Razor Pages ModelBindingProvider in ASP.NET Core

Using `MvcOptions` without calling `AddMvc()`

Microsoft’s official documentation on adding custom model binding providers to convert between (typically) a string and a custom type for complex model binding in ASP.NET Core as of .NET Core 3.1 goes something like this:

  • Create an IModelBinder for your class and use [ModelBinder(BinderType = typeof(MyModelEntityBinder)] to decorate each and every binding site, e.g.
    public async Task<IActionResult> OnPost([ModelBinder(BinderType = typeof(MyModelEntityBinder)]) MyModel model), which provides the runtime with the type information it needs to instantiate the model binding provider and convert the input to a model.
  • Optionally create an IModelBinderProvider class and register it with the ASP.NET Core host to provide the type information ahead-of-time (once and for all), so that you can instead use the barebones and much shorter decoration at each model binding site instead:
    public async Task<IActionResult> OnPost([ModelBinder] MyModel model)

The latter is significantly easier on the eyes and far less error prone… but where does the type registration take place? Per the linked documentation, the recommendation is the following in Startup.cs:

services.AddMvc(options =>
{
    // add custom binder to beginning of collection
    options.ModelBinderProviders.Insert(0, new MyModelBindingProvider());
});

All of which is fairly straight and to the point, except it requires pulling in the MVC dependency and components, which may not be desirable for overhead if you’re working on a greenfield project with Razor Pages only.

Unfortunately, the default Razor Pages options configurator does not have a similar ModelBinderProviders property that we can set instead, as the RazorPagesOptions object exposed by the AddRazorPages() extension method does not have any such property.

However, we can actually call the AddMvcOptions(options => { ... }) extension method on the IServiceCollection directly, allowing us to configure the ModelBinderProviders collection without explicitly calling AddMvc() in our project:

services.AddRazorPages()
    .AddMvcOptions(options =>
    {
        options.ModelBinderProviders.Add(new MyModelBindingProvider());
    }

In this case, AddMvcOptions is actually just syntactic sugar hiding the dependency injection and explicitly providing the types. When the (Razor Pages) runtime needs to get the model binding configuration, it just obtains a reference to the globally-injected MvcOptions returned by the setup action, from where it has access to the ModelBinderProviders that’s now been configured with our model’s custom binding provider… except it’s actually even more dogfooded and with Razor Pages you can actually just inject the IModelBinderProvider directly into the IServiceCollection and have it resolved at runtime all the same:

services.AddRazorPages();
services.AddSingleton<IModelBinderProvider>(new MyModelBindingProvider());

I don’t know if that works for actual MVC projects, and I imagine that there are scenarios there that would directly access the ModelBinderProviders instance rather than directly use dependency injection to query the binding provider instance.

More importantly, IModelProvider isn’t a generic interface and its type info does not contain any hint about the type of models it can resolve: this is one of the reasons the official documentation says to use the first code sample with ModelBinderProviders.Insert(0, ...) rather than the second with ModelBinderProviders.Add(...) because it explicitly adds your custom model binding provider to the top of the providers list, whereas neither of .Add(...) and AddSingleton<IModelBindingProvider> provide any explicit precedence or type specificity, meaning it is possible that a different model binding provider could be picked over your own.

But that also gives us a hint about something else: performance. An IModelBinderProvider declaration, like we said, doesn’t contain any hints about the supported types. Instead, each registered provider must be invoked (in some order) to see whether or not it’ll match the binding site, which is why a typical IModelBinderProvider implementation will look something like this:

public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
    if (context?.Metadata.ModelType == typeof(Ulid))
    {
        return new BinderTypeModelBinder(typeof(UlidEntityBinder));
    }

    return null;
}

This may test for supported types and error out quickly, but it’s not hard to imagine a poor implementation doing more work (and even some heap allocations!) before failing to providing a converter and returning null. Even if not, you never know how many model binders will run before yours and how long it’ll take to invoke them all. That’s why if you’re just doing a simple text-to-object conversion and don’t have any complex binding requirements, it is recommended to use a TypeConverter class instead.

If you would like to receive a notification the next time we release a nuget package for .NET or release resources for .NET Core and ASP.NET Core, you can subscribe below. Note that you'll only get notifications relevant to .NET programming and development by NeoSmart Technologies. If you want to receive email updates for all NeoSmart Technologies posts and releases, please sign up in the sidebar to the right instead.

Leave a Reply

Your email address will not be published. Required fields are marked *