Tales from the .NET Migration Trenches - Empty Proxy

Posts in this series:

In the previous post, we looked at techniques for determining the size and scope of our .NET migration effort, as well as clarifying what our goals should be. But before we start migrating anything, we wanted to "establish a beachhead" and work through any build/package/deployment/production issues with a proxy. So rather than trying to make our first deployment a simple controller, we deployed a proxy that handled nothing and proxied everything.

Our ASP.NET Core App will define zero controllers, APIs, routes, or even any middleware like authentication/authorization. Every request will proxy to the .NET Framework app (we're even skipping this "business logic" common library for now).

The main motivation here is we want to work this app all the way through build, package, and deployment without worrying about the ASP.NET Core app actually doing anything besides the minimal proxying.

The code side of this is actually quite straightforward, we can use the Visual Studio wizard to upgrade our project. We right-click the project and select "Upgrade" where we can upgrade our project to a newer version OR upgrade project features:

The "Upgrade Project Features" selection is new and allows you to upgrade an older-style class library project to SDK-style without changing its target framework. Another nice way of moving in small, verifiable steps.

The first selection is what we want and only offers one upgrade path, but it's the one we want:

Maybe in the future there's a full project migration but I can't really imagine it working except in the simplest of scenarios. And this app has AutoMapper AND MediatR so clearly it's not simple.

In the next prompt we select "New Project" as we don't already have an ASP.NET Core project, and finally the project name/type:

Since app is entirely an MVC application, I went with the first option but it's not hard to add API controllers if you like after the fact. Finally, we can pick the target framework:

This will be up to you on which you choose, but keep in mind that not all features in ASP.NET MVC 5 are available to migrate across to ASP.NET Core. For example, we found that our .NET Framework app used Output Caching but this feature isn't available until .NET 7. That's why we took the time to catalog what features and middleware our app used in the assessment phase.

Finishing this out, we get a second project added to our solution:

There aren't any controllers here. We get a couple services added:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSystemWebAdapters();
builder.Services.AddHttpForwarder();

I'm not configuring any options, just yet. Finally, the reverse proxy middleware is added:

app.UseRouting();
app.UseAuthorization();
app.UseSystemWebAdapters();

app.MapDefaultControllerRoute();
app.MapForwarder("/{**catch-all}", app.Configuration["ProxyTo"]).Add(static builder => ((RouteEndpointBuilder)builder).Order = int.MaxValue);

app.Run();

The order is important here - we map the default controller routes first and then our forwarder. Any other middleware/routes we want to have our ASP.NET Core app need to be added before the MapForwarder call. This ensures we give our ASP.NET Core app the chance to handle any routes before our forwarder does.

The configuration there is just for the forwarding address which we can find in our launchsettings.json file:

"environmentVariables": {
  "ASPNETCORE_ENVIRONMENT": "Development",
  "ProxyTo": "http://localhost:12810"
}

That URL is the one the .NET Framework app uses for local debugging. Going through this wizard also means that our Visual Studio solution is configured to launch both applications when running.

With this in place, we can run our nearly pointless application:

This is the .NET 6 application's URL, but the content is from the .NET Framework application. Success! And all content is served from the proxy - HTML, CSS, JS, SignalR.

With this in place, we need to get the application pushed through our build and deployment pipeline. This is pretty specific to your situation, but some things we had to do:

  • Package the application using "dotnet package"
  • Create appsettings.FOO.json files for all of the application configuration across our environments
  • Figure out how to poke secrets into our .NET 6 application. The current system used XML poking
  • Configure IIS to include these additional web applications, and pull in the ASP.NET Core 6 hosting module
  • Modify our deployment pipeline to deploy the ASP.NET Core 6 app

Again, we worked in small steps, going from Build -> Package -> Deploy. Since the ASP.NET Core 6 application didn't actually do anything, we could methodically work towards deployment without any big-bang switches. This was especially helpful as our build and deployment servers had pipelines defined outside of our repository (TeamCity).

At the end of this step, we had the empty .NET 6 application packaged and deployed across all environments (our beachhead).

In the next post, we'll take our first steps of migrating the actual code.