Wednesday, February 21, 2024

Method Injection in ASP.NET Core: API Controllers vs. MVC Controllers

Method Injection in ASP.NET Core got a little bit easier in .NET 7. Before .NET 7, we had to use the [FromServices] attribute on a method parameter in order for the parameter to be injected from the services collection / dependency injection container. Starting with .NET 7, the [FromServices] parameter became optional, but only in some places.

Short Version:
[FromServices] is no longer required for API Controllers. 
[FromServices] is still required for MVC Controllers.
The reason I'm writing about this is that I had a bit of confusion when I first heard about it, and I suspect others may have as well. If you'd like more details, and some samples, keep reading.

Confusion

I first heard about [FromServices] being optional from a conference talk on ASP.NET Core. The speaker said that [FromServices] was no longer required for method injection and removed it from the sample code to show it still worked.

Now ASP.NET Core is not one of my focus areas, but I do have some sample apps that use method injection. When I tried this out for myself on an MVC controller, it did not work. After a bit of digging (and conversation with the speaker), we found that the change only applied to API controllers.

Let's look at some samples. This code shows a list of data from a single source -- either through an API or displayed in an MVC view. (Sample code is available here: https://github.com/jeremybytes/method-injection-aspnetcore)

Data and Service Configuration

Both projects use the same library code for the data. This code is in the "People.Library" folder/project.

IPeopleProvider:
    public interface IPeopleProvider
    {
        Task<List<Person>> GetPeople();
        Task<Person?> GetPerson(int id);
    }

The method we care about is the "GetPeople" method that returns a list of "Person" objects. There is also a "HardCodedPeopleProvider" class in the same project that implements the interface. This returns a hard-coded list of objects.

Both the API project and the MVC project configure the "IPeopleProvider" the same way. (API code: ControllerAPI/Program.cs -- MVC code: PeopleViewer/Program.cs


    // Add services to the container.
    builder.Services.AddScoped<IPeopleProvider, HardCodedPeopleProvider>();

This registers the HardCodedPeopleProvider with the dependency injection container so that we can inject it into our controllers.

API Controller

Using [FromServices]
Prior to .NET 7, we could inject something from our dependency injection container by using the [FromServices] attribute. Here's what that code looks like in the API controller (from the "PeopleController.cs" file)

    [HttpGet(Name = "GetPeople")]
    public async Task<IEnumerable<Person>> Get(
        [FromServices] IPeopleProvider provider)
    {
        return await provider.GetPeople();
    }

When we call this method, the "HardCodedPeopleProvider" is automatically used for the "provider" parameter.

The result is the following:


[{"id":1,"givenName":"John","familyName":"Koenig","startDate":"1975-10-17T00:00:00-07:00","rating":6,"formatString":""},{"id":2,"givenName":"Dylan","familyName":"Hunt","startDate":"2000-10-02T00:00:00-07:00","rating":8,"formatString":""},...,{"id":14,"givenName":"Devon","familyName":"","startDate":"1973-09-23T00:00:00-07:00","rating":4,"formatString":"{0}"}]

Note: I'm sorry about the JSON formatting. "SerializerOptions.WriteIndented" is a topic for another day.
Update: Here's an article on that: Minimal APIs vs Controller APIs: SerializerOptions.WriteIndented = true.

Removing [FromServices]
As noted, the "FromServices" is now optional:


    [HttpGet(Name = "GetPeople")]
    public async Task<IEnumerable<Person>> Get(
        IPeopleProvider provider)
    {
        return await provider.GetPeople();
    }

The output is the same as we saw above:


[{"id":1,"givenName":"John","familyName":"Koenig","startDate":"1975-10-17T00:00:00-07:00","rating":6,"formatString":""},{"id":2,"givenName":"Dylan","familyName":"Hunt","startDate":"2000-10-02T00:00:00-07:00","rating":8,"formatString":""},...,{"id":14,"givenName":"Devon","familyName":"","startDate":"1973-09-23T00:00:00-07:00","rating":4,"formatString":"{0}"}]
[FromServices] is no longer required for API Controllers.
Now let's take a look at an MVC controller.

MVC Controller

Using [FromServices]
The [FromServices] attribute lets us use method injection in MVC controllers as well. Here is an action method from the "PeopleViewer" project (from the "PeopleController.cs" file):


    public async Task<IActionResult> GetPeople(
        [FromServices] IPeopleProvider provider)
    {
        ViewData["Title"] = "Method Injection";
        ViewData["ReaderType"] = provider.GetType().ToString();

        var people = await provider.GetPeople();
        return View("Index", people);
    }

As with the API controller, the "HardCodedPeopleProvider" is automatically passed for the method parameter. Here is the output of the view:

Web browser showing output of People objects in a grid. Example item is "John Koenig 1975 6/10 Stars"


This shows the same data as the API in a colorful grid format (as a side note, the background color of each item corresponds with the decade of the date.)

Removing [FromServices]
Because we can remove [FromServices] from the API controller, I would guess that we can remove it from the MVC controller as well.


    public async Task<IActionResult> GetPeople(
        IPeopleProvider provider)
    {
        ViewData["Title"] = "Method Injection";
        ViewData["ReaderType"] = provider.GetType().ToString();

        var people = await provider.GetPeople();
        return View("Index", people);
    }

However, when we run this application, we get a runtime exception:


InvalidOperationException: Could not create an instance of type 'People.Library.IPeopleProvider'. Model bound complex types must not be abstract or value types and must have a parameterless constructor. Record types must have a single primary constructor. Alternatively, give the 'provider' parameter a non-null default value.

ASP.NET Core MVC does not automatically look in the dependency injection container for parameters that it cannot otherwise bind. So, without the [FromServices] attribute, this method fails.
[FromServices] is still required for MVC Controllers.

Messaging and Documentation Frustrations

If you've read this far, then you're looking for a bit more than just the "Here's how things are", so I'll give a bit of my opinions and frustrations.

I've had a problem with Microsoft's messaging for a while now. It seems like they are very good at saying "Look at this cool new thing" without mentioning how it differs from the old thing or what is not impacted by the new thing. (I had a similar experience with minimal APIs and behavior that is different from controller APIs but not really called out anywhere in the documentation: Returning HTTP 204 (No Content) from .NET Minimal API.)

I see the same thing happening with [FromServices].

Here is the documentation (from "What's New in ASP.NET Core 7.0")
Parameter binding with DI in API controllers
Parameter binding for API controller actions binds parameters through dependency injection when the type is configured as a service. This means it's no longer required to explicitly apply the [FromServices] attribute to a parameter.
Unfortunately, the "hype" shortens this message:
[FromServices] is no longer required.
Please don't write to me and say "It's obvious from the documentation this only applies to API controllers. There is no reason to believe it would apply to anything else." This is easy to say when you already know this. But what about if you don't know?

Let's look at it from an average developer's perspective. They have used API controllers (and may be using minimal APIs) and they have used MVC controllers. The methods and behaviors of these controllers are very similar: parameter mapping, routing, return types, and other bits. It is not a very far leap to assume that changes to how a controller works (whether API or MVC) would apply in both scenarios.

As noted above, the speaker I originally heard this from did not realize the limitations at the time, and this speaker is an expert in ASP.NET Core (including writing books and teaching multi-day workshops). They had missed the distinction as well.

And unfortunately, the documentation is lacking (at least as of the writing of this article in Feb 2024): 

  • The "FromServicesAttribute" documentation does not have any usage notes that would indicate that it is required in some places and optional in others. This is the type of note I expect to see (as an example  the UseShellExecute default value changed between .NET Framework and .NET Core, and it is noted in the language doc.)
  • The Overview of ASP.NET Core MVC documentation does have a "Dependency Injection" topic that mentions method injection. However, the corresponding Create web APIs with ASP.NET Core documentation does not have a "Dependency Injection" topic.

Stop Complaining and Fix It, Jeremy

I don't like to rant without having a solution. But this has been building up for a while now. The obvious answer is "Documentation is open source. Anyone can contribute. Update the articles, Jeremy."

If you know me, then you know that I have a passion for helping people learn about interfaces and how to use them appropriately. If I have "expertise" in any part of the C# language, it is interfaces. When it came to a set of major changes (specifically C# 8), I looked into them and wrote about them (A Closer Look at C# 8 Interfaces), and I have also spoken quite a bit about those changes: Caching Up with C# Interfaces, What You Know may be Wrong.

I have noted that the documentation is lacking in a lot of the areas regarding the updates that were made to interfaces. So why haven't I contributed?
I do not have enough information to write the documentation.
I can write about my observations. I can point to things that look like bugs to me. But I do not know what the feature is supposed to do. It may be working as intended (as I have discovered about "bugs" I have come across in the past). I am not part of the language team; I am not part of the documentation team; I do not have access to the resources needed to correctly document features.

Wrap Up

I will apologize for ranting without a solution. I was a corporate developer for many years. I understand the pressures of having to get code completed. I see how difficult it is to keep up with changing environments while releasing applications. I know what it is like to support multiple applications written over a number of years that may or may not take similar approaches.

I have a love and concern for the developers working in those environments. There are times when I have difficulty keeping up with things (and keeping up with things is a big part of my job). If I have difficulty with this, what chance does the average developer have? Something has got to give.

Anyway, I have some more posts coming up about some other things that I've come across. Hopefully those will be helpful. (No ranting, I promise.)

Happy Coding!

No comments:

Post a Comment