Generic and dynamically generated controllers in ASP.NET Core MVC

One of those recurring themes that seem to come back fairly regularly among .NET web developers, is the usage of generic controllers to define endpoints in their Web APIs. I have witnessed these discussions as part of ASP.NET MVC, then ASP.NET Web API and most recently in ASP.NET Core MVC.

While I don’t necessarily see a huge need or benefit for generic controllers, I can imagine that – especially in enterprise context – there are scenarios where exposing similarly structured, “cookie-cutter” CRUD endpoints quickly and seamlessly, could possibly have some business value.

Let’s have a look at generic controllers then, and how we could also dynamically feed types into them.

Some background

So for the purpose of our today’s discussion, let’s invent a couple of types. First we will need some dummy entities, which will represent our data objects that will be generically exposed from the API.

They are regular POCOs and are shown below.

Aside from our entities, let’s also come up with a very simple generic storage mechanism. It is entirely illustrative, and it’s sole purpose is for us to be able to fill our generic controller with some more meaningful code.

The generic storage service I’d propose for this post is a very simple one, based on an in-memory dictionary. It only exposes read and save operations, and is shown next.

Finally, equipped in all these, we can proceed to writing a generic controller.

Generic controller

Generic controllers are not supported out of the box by ASP.NET Core MVC. That said, it’s not difficult to imagine how a generic controller would look like.

Let’s create it for now, and see how we can make it “light up” in a moment.

There is not much to discuss here as the code speaks for itself – we simply expose operations from our generic storage over as GET/POST operations. We could take it further by adding PUT, DELETE or whatever else you’d envision in your generically designed API – the actual implementation details are of secondary importance here.

As mentioned already, ASP.NET Core wouldn’t consider BaseController<T> as a valid controller. The reason is quite obvious – it wouldn’t know what to put in the T.

Let’s now explore the ways we could bridge this gap.

Approach 1: Inheriting from the generic controller

The simplest solution would be to make child controllers, that inherit from BaseController<T> and fill in the type parameter. This way, the child controller is a perfectly valid non-generic controller anymore, and it can be discovered by MVC without problems.

This works straight away, and we don’t need any additional configuration or custom extensions. The names of our controllers – book and album slot themselves into the route template from the base class [Route(“api/[controller]”)], and all of the defined GET/POST operation are automatically available.

Of course this approach is not the best, because it means we have to manually create a controller type per every entity type we’d like to include in our application.

Approach 2: Dynamic controllers

We could avoid having to manually create a controller type per entity, if we create our own custom IApplicationFeatureProvider<ControllerFeature>. This extensibility point is invoked at application startup, and allows us to explicitly inject certain types that should be treated by MVC as controllers. This means, that we could use as controllers certain types that would normally not be discovered by the default controller discovery mechanism.

In our case, we could leverage that extensibility point to match our BaseController<T> with specific entity types, and use those as concrete controllers.

To get there, we will need to introduce an extra marker attribute. It is not necessary, as we could achieve that in other ways too, but I think it’s a neat way to demonstrate the feature. We will introduce a GeneratedControllerAttribute.

It will be used by us to mark the types we’d like to use in conjunction with the BaseController<T> as HTTP endpoints. The reason for that is of course we don’t want generic controllers for all the types in our current Web API assembly. Obviously, we could do that differently too i.e. by dedicated a specific assembly for the DTOs, and just creating controllers for all the types from that – it’s up to you how you’d want to handle that.

As part of the attribute, we also require route to be specified. This would allows us to have custom base path for each of our types, instead of relying on the generic inherited route.

Next step is to introduce the aforementioned IApplicationFeatureProvider<ControllerFeature> implementation, that would discover types annotated with the attribute, create the generic controller types and include them as controller candidates for the MVC framework.

So in other words, in our specific case, we will suggest the MVC framework to use BaseController<Book> and BaseController<Album> as controllers.

We still need to handle the route that we defined as part of our GeneratedControllerAttribute. We can do that easily using a custom MVC convetion, which is an excellent extensibility point for modifying how things are laid out in the framework after the controllers have been discovered.

In our custom convention, we’ll pick up the route template from the attribute and inject it into the controller as if it was an inline attribute route (equivalent to using [Route(…)] attribute on a controller).

In the convention, we iterate over all controller candidates, and if any of them has generic type arguments (for example BaseController<Book>) we will pick up the route from the attribute and use it as a so-called SelectorModel. Again, at the end of the day, this is equivalent to defining an inline attribute route.

Both of these custom features – GenericTypeControllerFeatureProvider and GenericControllerRouteConvention – must be added to the MVC framework at startup. That’s done as part of the Startup class, as soon as we call AddMvc().

Finally, we should add the attribute on the entities:

And that’s it – everything will automatically light up now, and we have our generic controllers set up. We could add however many DTO types now, annotate them with the GeneratedControllerAttribute, and have them show up as publicly callable HTTP endpoint.

Approach 3: Dynamic controllers with dynamic types

A slightly more flexible and sophisticated variation of the previous approach could be that types may be generated on demand.

For example – what if the types for which we generate our dynamic controllers don’t exist at compile time? Perhaps we want to load them from an HTTP endpoint or from a database? I think this is when we could really reap some of the benefits of such generic setup.

To illustrate this, I have moved the type definitions out of the project and into a gist file. In this case I removed the GeneratedControllerAttribute from the types – we don’t need it anymore, since, after all, we know which types we want to use with the generic controller (we are not fishing them out from an assembly with many types).

So in our IApplicationFeatureProvider<ControllerFeature> we will have to download them, use the C# compiler as a service to compile them, emit an assembly and use types from that dynamically emitted assembly as type arguments for our BaseController<T>.

This is shown in the next snippet, which uses the Microsoft.CodeAnalysis.CSharp (“Roslyn”) package to facilitate dynamic compilation.

The one piece missing here is that we’d want the routing to work. Remember, we removed the GeneratedControllerAttribute, which allowed us to specify the route before. In this case though, we could solve this by relying on the generic route as it was defined in the BaseController<T>[Route(“api/[controller]”)]. So the name of the controller will dictate how the route looks like.

Of course this won’t work straight away, because the controller name is normally the type name of the controller type, and in our generic setup the controller type is something like this BaseController<Book>, and its type name is not a friendly one (something like BaseController[FullNamespace.Book]). However we can easily recitify it by using the name of our type parameter as controller name – and this can be done by modifying our existing convention.

Notice the new else code path which is used when no GeneratedControllerAttribute was found.

And that’s it – the HTTP endpoints are alive and they come entirely from the types defined outside of the application and compiled on the fly at startup. As soon as we add more type definitions to our external storage, the HTTP API would expand automatically upon next application restart.

We only scratched the surface here, however I hope this will nudge you in the direction you wanted to go. A lot of the decisions we made could have been taken differently – and I’m sure you will want to adapt this to your own needs if you start exploring these generic concepts.

Source code

The source code for this post can be found on Github.

Share the post!