June 2019

Volume 34 Number 6

[Cutting Edge]

Revisiting the ASP.NET Core Pipeline

By Dino Esposito | June 2019

Dino EspositoNearly any server-side processing environment has its own pipeline of pass-through components to inspect, re-route or modify incom­ing requests and the outgoing responses. Classic ASP.NET had it arranged around the idea of HTTP modules, whereas ASP.NET Core employs the more modern architecture based on middleware components. At the end of the day, the purpose is the same—letting configurable external modules intervene in the way the request (and later the response) goes through in the server environment. The primary purpose of middleware components is altering, and filtering in some way, the flow of data (and in some specific cases just short-circuiting the request, stopping any further processing).

The ASP.NET Core pipeline is nearly unchanged since version 1.0 of the framework, but the upcoming release of ASP.NET Core 3.0 invites a few remarks on the current architecture that have gone for the most part unnoticed. So, in this article, I’ll revisit the overall functioning of the ASP.NET Core runtime pipeline and focus on the role and possible implementation of HTTP endpoints.

ASP.NET Core for the Web Back End

Especially in the past couple of years, building Web applications with the front end and back end completely decoupled has become quite common. Therefore, most ASP.NET Core projects are today simple Web API, UI-less projects that just provide an HTTP façade to a single-page and/or mobile application built, for the most part, with Angular, React, Vue and their mobile counterparts.

When you realize this, a question pops up: In an application that’s not using any Razor facilities, does it still make sense to bind to the MVC application model? The MVC model doesn’t come for free, and in fact, to some extent, it may not even be the most lightweight option once you stop using controllers to serve action results. To press the question even further: Is the action result concept itself strictly necessary if a significant share of the ASP.NET Core code is written just to return JSON payloads?

With these thoughts in mind, let’s review the ASP.NET Core pipeline and the internal structure of middleware components and the list of built-in runtime services you can bind to during startup.

The Startup Class

In any ASP.NET Core application, one class is designated as the application bootstrapper. Most of the time, this class takes the name of Startup. The class is declared as a startup class in the configuration of the Web host and the Web host instantiates and invokes it via reflection. The class can have two methods—ConfigureServices (optional) and Configure. In the first method, you receive the current (default) list of runtime services and are expected to add more services to prepare the ground for the actual application logic. In the Configure method, you configure for both the default services and for those you explicitly requested to support your application.

The Configure method receives at least an instance of the application builder class. You can see this instance as a working instance of the ASP.NET runtime pipeline passed to your code to be configured as appropriate. Once the Configure method returns, the pipeline workflow is fully configured and will be used to carry on any further request sent from connected clients. Figure 1 provides a sample implementation of the Configure method of a Startup class.

Figure 1 Basic Example of the Configure Method in the Startup Class

public void Configure(IApplicationBuilder app)
{
  app.Use(async (context, nextMiddleware) =>
  {
    await context.Response.WriteAsync("BEFORE");
    await nextMiddleware();  
    await context.Response.WriteAsync("AFTER");
  });
  app.Run(async (context) =>
  {
    var obj = new SomeWork();
    await context
      .Response
      .WriteAsync("<h1 style='color:red;'>" +
                   obj.SomeMethod() +
                  "</h1>");
  });
}

The Use extension method is the principal method you employ to add middleware code to the otherwise empty pipeline workflow. Note that the more middleware you add, the more work the server needs to do to serve any incoming requests. The most minimal is the pipeline, the fastest will be the time-to-first-byte (TTFB) for the client.

You can add middleware code to the pipeline using either lambdas or ad hoc middleware classes. The choice is up to you: The lambda is more direct, but the class (and preferably some extension methods) will make the whole thing easier to read and maintain. The middleware code gets the HTTP context of the request and a reference to the next middleware in the pipeline, if any. Figure 2 presents an overall view of how the various middleware components link together.

The ASP.NET Core Runtime Pipeline
Figure 2 The ASP.NET Core Runtime Pipeline

Each middleware component is given a double chance to intervene in the life of the ongoing request. It can pre-process the request as received from the chain of components registered to run earlier, and then it’s expected to yield to the next component in the chain. When the last component in the chain gets its chance to pre-process the request, the request is passed to the terminating middleware for the actual processing aimed at producing a concrete output. After that, the chain of components is walked back in the reverse order as shown in Figure 2, and each middleware has its second chance to process—this time, though, it will be a post-processing action. In the code of Figure 1, the separation between pre-­processing and post-processing code is the line:

await nextMiddleware();

The Terminating Middleware

Key in the architecture shown in Figure 2 is the role of the terminating middleware, which is the code at the bottom of the Configure method that terminates the chain and processes the request. All demo ASP.NET Core applications have a terminating lambda, like so:

app.Run(async (context) => { ... };

The lambda receives an HttpContext object and does whatever it’s supposed to do in the context of the application.

A middleware component that deliberately doesn’t yield to the next actually terminates the chain, causing the response to be sent to the requesting client. A good example of this is the UseStaticFiles middleware, which serves a static resource under the specified Web root folder and terminates the request. Another example is Use­Rewriter, which may be able to order a client redirect to a new URL. Without a terminating middleware, a request can hardly result in some visible output on the client, though a response is still sent as modified by running middleware, for exam­ple through the addition of HTTP headers or response cookies.

There are two dedicated middleware tools that can also be used to short circuit the request: app.Map and app.MapWhen. The former checks if the request path matches the argument and runs its own terminating middleware, as shown here:

app.Map("/now", now =>
{
  now.Run(async context =>
  {
    var time = DateTime.UtcNow.ToString("HH:mm:ss");
    await context
      .Response
      .WriteAsync(time);
  });
});

The latter, instead, runs its own terminating middleware only if a specified Boolean condition is verified. The Boolean condition results from the evaluation of a function that accepts an HttpContext. The code in Figure 3 presents a very thin and minimal Web API that only serves a single endpoint and does that without anything like a controller class.

Figure 3 A Very Minimal ASP.NET Core Web API

public void Configure(IApplicationBuilder app,
                      ICountryRepository country)
{
  app.Map("/country", countryApp =>
  {
    countryApp.Run(async (context) =>
    {
      var query = context.Request.Query["q"];
      var list = country.AllBy(query).ToList();
      var json = JsonConvert.SerializeObject(list);
      await context.Response.WriteAsync(json);
    });
  });
  // Work as a catch-all
  app.Run(async (context) =>
  {
    await context.Response.WriteAsync("Invalid call");
  }
});

If the URL matches /country, the terminating middleware reads a parameter from the query string and arranges a call to the repository to get the list of matching countries. The list object is then manually serialized in a JSON format directly to the output stream. By just adding a few other map routes you could even extend your Web API. It can hardly be simpler than that.

What About MVC?

In ASP.NET Core, the whole MVC machinery is offered as a black-box runtime service. All you do is bind to the service in the ConfigureServices method and configure its routes in the Configure method, as shown in the code here:

public void ConfigureServices(IServiceCollection services)
{
  // Bind to the MVC machinery
  services.AddMvc();
}
public void Configure(IApplicationBuilder app)
{
  // Use the MVC machinery with default route
  app.UseMvcWithDefaultRoute();
  // (As an alternative) Use the MVC machinery with attribute routing
  app.UseMvc();
}

At that point, you’re welcome to populate the familiar Controllers folder and even the Views folder if you intend to serve HTML. Note that in ASP.NET Core you can also use POCO controllers, which are plain C# classes decorated to be recognized as controllers and disconnected from the HTTP context.

The MVC machinery is another great example of terminating middleware. Once the request is captured by the MVC middle­ware, everything goes under its control and the pipeline is abruptly terminated.

It’s interesting to notice that internally the MVC machinery runs its own custom pipeline. It’s not middleware-centric, but it’s nonetheless a full-fledged runtime pipeline that controls how requests are routed to the controller action method, with the generated action result finally rendered to the output stream. The MVC pipeline is made of various types of action filters (action name selectors, authorization filters, exception handlers, custom action result managers) that run before and after each controller method. In ASP.NET Core content negotiation is also buried in the runtime pipeline.

At a more insightful look, the whole ASP.NET MVC machinery looks like it’s bolted on top of the newest and redesigned middleware-centric pipeline of ASP.NET Core. It’s like the ASP.NET Core pipeline and the MVC machinery are entities of different types just connected together in some way. The overall picture is not much different from the way MVC was bolted on top of the now-dismissed Web Forms runtime. In that context, in fact, MVC kicked in through a dedicated HTTP handler if the processing request couldn’t be matched to a physical file (most likely an ASPX file).

Is this a problem? Probably not. Or probably not yet!

Putting SignalR in the Loop

When you add SignalR to an ASP.NET Core application, all you need to do is create a hub class to expose your endpoints. The interesting thing is that the hub class can be completely unrelated to controllers. You don’t need MVC to run SignalR, yet the hub class behaves like a front-end controller for external requests. A method exposed from a hub class can perform any work—even work unrelated to the cross-app notification nature of the framework, as shown in Figure 4.

Figure 4 Exposing a Method from a Hub Class

public class SomeHub : Hub
{
  public void Method1()
  {
    // Some logic
    ...
    Clients.All.SendAsync("...");
  }
  public void Method2()
  {
    // Some other logic
    ...
    Clients.All.SendAsync("...");
  }
}

Can you see the picture?

The SignalR hub class can be seen as a controller class, without the whole MVC machinery, ideal for UI-less (or, rather, Razor-less) responses.

Putting gRPC in the Loop

In version 3.0, ASP.NET Core also provides native support for the gRPC framework. Designed along with the RPC guidelines, the framework is a shell of code around an interface definition language that fully defines the endpoint and is able to trigger communication between the connected parties using Protobuf binary serialization over HTTP/2. From the ASP.NET Core 3.0 perspective, gRPC is yet another invokable façade that can make server-side calculations and return values. Here’s how to enable an ASP.NET Core server application to support gRPC:

public void ConfigureServices(IServiceCollection services)
{
  services.AddGrpc();
}
public void Configure(IApplicationBuilder app)
{
  app.UseRouting(routes =>
    {
      routes.MapGrpcService<GreeterService>();
    });
}

Note also the use of global routing to enable the application to support routes without the MVC machinery. You can think of UseRouting as a more structured way of defining app.Map middleware blocks.

The net effect of the previous code is to enable RPC-style calls from a client application to the mapped service—the Greeter­Service class. Interestingly, the GreeterService class is conceptually equivalent to a POCO controller, except that it has no need to be recognized as a controller class, as shown here:

public class GreeterService : Greeter.GreeterBase
{
  public GreeterService(ILogger<GreeterService> logger)
  {
  }
}

The base class—GreeterBase is an abstract class wrapped in a static class—contains the necessary plumbing to carry out the request/response traffic. The gRPC service class is fully integrated with the ASP.NET Core infrastructure and can be injected with external references.

The Bottom Line

Especially with the release of ASP.NET Core 3.0, there will be two more scenarios in which having an MVC-free controller-style façade would be helpful. SignalR has hub classes and gRPC has a service class, but the point is that they’re conceptually the same thing that has to be implemented in different ways for different scenarios. The MVC machinery was ported to ASP.NET Core more or less as it was originally devised for classic ASP.NET, and it maintains its own internal pipeline around controllers and action results. At the same time, as ASP.NET Core is more and more used as a plain provider of back-end services, with no support for views, the need for a possibly unified, RPC-style façade for HTTP endpoints grows.


Dino Esposito has authored more than 20 books and 1,000-plus articles in his 25-year career. Author of “The Sabbatical Break,” a theatrical-style show, Esposito is busy writing software for a greener world as the digital strategist at BaxEnergy. Follow him on Twitter: @despos.

Thanks to the following technical expert for reviewing this article: Marco Cecconi


Discuss this article in the MSDN Magazine forum