Microsoft .NET Framework was first released on February 12, 2002 and has since been a very active set of languages and framework. Although it is very hard to determine the number of project across the globe, we can guesstimate the number, what is considered legacy apps, to be in the 10s of thousands. By legacy, I mean projects written pre .NET Core - a modern compiler and framework redesigned to run on cloud native environment. Moving from legacy to modern compiler and framework does not have to mean porting your app to a new language and rewriting decaded of business logic. 😉

Why?

This is a valid question and one that is easily glanced over. Why refactor to .NET Core? Why not port the code to another programming language? There are two angles to evaluate this proposition. The first, from the side of business, is an evaluation from the perspective of time, money and business continuity. The second, from the side of technology, is an evaluation of the language's capabilities and fit within a modern cloud architecture.

From the technical side, Microsoft has demonstrated the improvements of .NET in this area. Most notably, .NET Core is:

  • cross-platform
  • open for modularity
  • comes with modern toolings, and
  • backed by a world class technical support.

The business side is up to you!

Prerequisite

What can be ported? The answer to this is any .NET Framework app. The complexity resides in the end-of-life (EOL) technology stack used in that project and what are the recommend actions. This article is not an exhaustive list. Microsoft does provide a breakdown of these issues in their docs.

Microsoft also provides an "Overview of porting from .NET Framework to .NET Core." Along with "Tools to help with porting to .NET Core."

Main technologies intended to be left on .NET Framework.

  • Windows Communication Foundation (WCF)
    • See Core WCF OSS. The WCF Team has donated the code to the OSS community. According to the post, however, "Core WCF is not intending to be a 100% compatible port of WCF to .NET Core, but aims to allow porting of many WCF contract and service implementations with only a change of namespace."
  • ASP.NET Web Forms
  • Windows Workflow
    • See Core WF OSS. It, too, comes with the disclaimer, "This project only ports the WF runtime and ETW tracking provider to the .NET Standard. But much more work is needed before it can substitute for the .NET Framework version."
  • .NET Remoting

This may sound like a dead end for most. However, with technologies not coming over, Microsoft has provided other technologies to take their place. In most part, these are better solutions then what we had with .NET Framework. We will visit those in later posts and are outside the context of this article.

Porting Principles

These are guiding principles to help keep you on track as you go through the porting process.

  • Refrain from code improvements. This is not an opportunity to optimize the code base. Doing so will only complicate the process and potentially take you down endless rabbit holes.
  • Focus on minimal refactoring. .NET Core provides IoC capabilities as the core building blocks. Take advantage of these, but for the initial port keep it to a minimum and stay focused on code parity. Take note of patterns that can be revisited post port.
  • Practice TDD - Use test driven development practices to validate correctness of code
  • Pause feature development. This will only complicate the process and take you down a merge nightmare. If you keep the focus to minimal refactor, the port will be fairly quick and the team can get back to feature development in no time.
  • Bug Fixes Branch. If needed, continue code fixes on a different branch

What will we be porting?

The focus of the remaining of this article is a port of an app from Framework ASP.NET, Web API, Entity Framework, Tests and Console to .NET Core App 3.1. The project was made up of 4 project in a single solution. It took roughly four days to port from .NET Framework to .NET Core. Here are the stats on the project from a pre/post port view.

  • Pre Port Stats Pre-Core-Port-Stats
  • Post Port Stats Post-Core-Port-Stats

Some highlights:

  • Lines of code reduced by 1,915 lines, going from 17,296 to 15,381
  • Lines of executable code reduced by 638 lines, going from 6,037 to 5,399
  • WebApp vs WebApi had different results from maintainability index, cyclomatic complexity and class coupling
    • There are many explanations here. For one, DI did have an impact, as well as, running Scaffold-DbContext.

The important point to note, this exercise was primarily focused on porting. The motivators to remember why core are: 1) enable cross-platform, 2) inherit language improvement, 3) inherit performance improvement.

The techniques used are outlined below by perspective areas. Each area focuses on learning and key points of interest.

Project

Keep current project in tact. This is similar in practice as applying the strangler pattern. Doing so will keep the project as a point of reference. More importantly, it keeps the compiler happy and allows for porting each project independently. When selecting the project, always start with the core project (i.e. one with the least to no dependency).

Preserve project name and namespace. When creating the project, keep the project name and namespace the same. Do choose a new folder to create the project in. This could be as simple as naming the folder port. If you choose to create a Visual Studio solution folder, make sure it does not end up in the namespace.

Create an equivalent project under the .NET Core template. For an ASP.NET MVC project create the equivalent ASP.NET Core MVC project. Doing this step will create an empty project with a .NET Core template. Mainly the csproj XML structure.

  • Port AppSettings & ConnectionStrings. Notice no Web.Config file. Instead copy all <appSettings> key/value pairs to a new appsettings.json file. Do the same for ConnectionStrings. Here's an example file.
    {
        "Logging": {
            "LogLevel": {
                "Default": "Information",
                "Microsoft": "Warning",
                "Microsoft.Hosting.Lifetime": "Information"
            }
        },
        "ConnectionStrings": {
            "Name": "server=; database=; User Id=; password=; MultipleActiveResultSets=False; Encrypt=False; TrustServerCertificate=False; Connection Timeout=30;"
        },
        "OtherKey": "OtherValue"
    }
    
  • Move core folders and files. Depending on the app type, start to move folders and files needed by the project. For example, for an ASP.NET MVC app this would include.
    • Content
    • Controllers
    • Views
    • Models
    • Filters
    • DAL
    • BLL
    • Scripts
  • New Entry .NET Core introduced the Program.cs and Startup.cs files to coordinate the entry point and configuration of the app. You will be in the Startup.cs class for majority of the configuration steps.

Nuget

Not to be an exhaustive list, the following Nugets were used in this particular port and we found a release that had support for .NET Core.

According to Microsoft, the number of missing Nuget Packages are very small. The difficult task here is finding a library that is equal or better for a replacement of EoL packages. With a great community support the chances here are high.

Project Structure & Configuration

  • wwwroot Website content need to go under wwwroot folder. All other folders can be copied as is to preserve namespace hierarchy.
  • Program this is the entry point of the app. Here's a boiler plate code.
    using Microsoft.AspNetCore.Hosting;
    using Microsoft.Extensions.Hosting;
    using Steeltoe.Common.Hosting;
    using Steeltoe.Extensions.Configuration.CloudFoundry;
    
    public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }
    
        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    // if using Steeltoe.Common.Hosting
                    webBuilder.UseCloudHosting();   
                    // if using Steeltoe.Extensions.Configuration.CloudFoundryCore
                    webBuilder.AddCloudFoundry();
                    // standard - the startup methods for the application
                    webBuilder.UseStartup<Startup>();
                });
    }
    
  • Match on Route within the Startup.cs file, configure your routes as required by legacy code.
    public void Configure(IApplicationBuilder app)
    {
        app.UseRouting();
        app.UseEndpoints(
            endpoints =>
            {
                // Area registration in core
                endpoints.MapControllerRoute(
                    name: "areas", 
                    areaName: "areas", 
                    pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
                    // in preceding example, exists is a constraint applied to match an area.
    
                // for named area mapping
                endpoints.MapAreaControllerRoute(
                    name: "MyAreaProducts",
                    areaName: "Products",
                    pattern: "Products/{controller=Home}/{action=Index}/{id?}");
    
                // default controller route mapping
                endpoints.MapControllerRoute(
                    name: "default",
                    pattern: "{controller=Home}/{action=Index}/{id?}");
                
                // If pattern is the default setting for MapControllerRoute, you can use:
                endpoints.MapDefaultControllerRoute();
            });
    }
    
  • HttpClient downstream calls, configure once at startup and use DI where needed.
    public void ConfigureServices(IServiceCollection services)
    {
        services
            .AddHttpClient<TClientType>("HttpClientName", 
                client =>
                {
                    client.BaseAddress = new Uri(Configuration["EndpointUrl"]);
                    client.DefaultRequestHeaders.Add("Accept", "application/json");
                })
            .ConfigurePrimaryHttpMessageHandler(() =>
            {
                var clientHandler = new HttpClientHandler()
                {
                    UseDefaultCredentials = true,
                    // Setup proxy, if needed
                    UseProxy = true,
                    Proxy = new WebProxy() { Address = new Uri("http://proxy.address") }
                };
    
                return clientHandler;
            });
    }
    
  • Layered Design DI Wiring using SQL Connection this will depend on the legacy code's approach. Here's a common setup if using a layered design.
    // Startup.cs
    public class Startup
    {
        using Microsoft.Data.SqlClient;
        using Microsoft.Extensions.Configuration;
        using Microsoft.Extensions.DependencyInjection;
    
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }
    
        public IConfiguration Configuration { get; }
    
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddSingleton(new SqlConnection(Configuration.GetConnectionString("ConnectionString")));
            services.AddScoped<ProductDal>();
            services.AddScoped<ProductBal>();
    
            services.AddControllersWithViews();
        }
    }
    
    // ProductDal.cs - Data Access Layer
    public class ProductDal 
    {
        readonly Microsoft.Data.SqlClient.SqlConnection dbConnection;
        public ProductDal(SqlConnection connection)
        {
            this.dbConnection = connection;
        }
    }
    
    // ProductBal.cs - Business Access Layer
    public class ProductBal 
    {
        readonly ProductDal productDal;
        public ProductDal(ProductDal productDal)
        {
            this.productDal = productDal;
        }
    }
    
    // ProductController.cs -  Controller
    public class ProductController 
    {
        readonly ProductBal productBal;
        public ProductDal(ProductBal productBal)
        {
            this.productBal = productBal;
        }
    }
    
  • AreaRegistration additional tasks
    • Read more on Areas in ASP.NET Core
    • Remove all {AreaName}AreaRegistration.cs from all areas. Remove web.config from views folders.
    • Area folder structure
    • Associate the controller with an Area:
      [Area("Products")]
      public class ProductController : Controller
      {
          public IActionResult Index()
          {
              return View();
          }
      }
      
    • Link generation
      @Html.ActionLink("Product/Manage/About", "About", "Manage", 
          new { area = "Products" })
      

.NET Core Controller Changes

  • using Microsoft.AspNetCore.Mvc;
  • using Microsoft.AspNetCore.Mvc.Filters; (ActionFilterAttribute)
  • Extract all layered dependencies from the controller, e.g. DataAccess/BusinessAccess/DbConnection, and instead use DependencyInjection (DI). To start use constructor based dependency.
  • 'Server.MapPath' can be replaced with 'IWebHostEnvironment'
    • Add IWebHostEnvironment to class constructor
  • HTTP Request Model Binding Sources default sequence.
    • Form fields
    • The request body (For controllers that have the [ApiController] attribute.)
    • Route data
    • Query string parameters
    • Uploaded files
  • JsonResult
    • Json will serialize the results based on the configured serializer. JsonRequestBehavior is no longer part of the API.
          public JsonResult GetProduct(){
              object productObject = new { @ProductId = 1 };
              
              // JsonRequestBehavior is obsolete
              // return Json(productObject, JsonRequestBehavior.AllowGet);
              return Json(productObject);
          }
      
    • SerializerSettings parameter
      • When using System.Text.Json, this should be an instance of System.Text.Json.JsonSerializerOptions.
      • When using Newtonsoft.Json, this should be an instance of JsonSerializerSettings.
      • To use Newtonsoft.Json
        • Add Nuget Package: Microsoft.AspNetCore.Mvc.NewtonsoftJson
        • Update Startup.cs
          services.AddControllersWithViews().AddNewtonsoftJson(
              // If needed to preserve casing based on model Properties.
              options => options.UseMemberCasing())
          );
          
        • New code: return Json(dataObj, new JsonSerializerSettings { Formatting = Formatting.Indented });
  • FileUpload API Change
    • Request.Form.Files instead of Request.Files
    • IFormFile.Length instead of ContentLength to get the uploaded file length in bytes.
    • SaveAs(path) is obsolete. Instead use:
      using var writeStream = new FileStream(targetPath, FileMode.CreateNew);
      postedFile.CopyTo(writeStream);
      
  • FileResult API Change - Use FileResult in place of the manual coding of returning a file.
    // Legacy code
    Response.ClearContent();
    Response.Buffer = true;
    Response.AddHeader("content-disposition", "attachment; filename=" + fileName);
    Response.ContentType = "application/ms-excel";
    Response.Charset = "";
    Response.BinaryWrite(data);
    Response.Flush();
    Response.End();
    
    // replace above with
    return new FileContentResult(data, "application/ms-excel") { 
        FileDownloadName = fileName };
    
  • JavaScript Ajax success data already in deserialized to JSON notation. No need for the following step JSON.parse(response). e.g. JS ajax call to controller method with JsonResult
    $.ajax({
        url: "/Controller/Method",
        contentType: 'application/json; charset=utf-8',
        type: "GET",
        headers: {
            Accept: "application/json"
        },
        success: function (response) {
            // not needed as response is deserialized
            // result = JSON.parse(response);
        }})
    
  • Session Management
  • Response Caching Middleware in ASP.NET Core
    • For example, creating a No Cache ActionFilter.
      public class NoCacheAttribute : ActionFilterAttribute
      {
          public override void OnResultExecuting(ResultExecutingContext filterContext)
          {
              if (filterContext == null) throw new ArgumentNullException("filterContext");
      
              filterContext.HttpContext.Response.GetTypedHeaders().CacheControl =
                  new Microsoft.Net.Http.Headers.CacheControlHeaderValue()
                  {
                      Public = true,
                      MaxAge = TimeSpan.FromDays(-1),
                      MustRevalidate = true,
                      NoCache = true,
                      NoStore = true
                  };
              filterContext.HttpContext.Response.Headers[Microsoft.Net.Http.Headers.HeaderNames.Expires] = "-1";
      
              base.OnResultExecuting(filterContext);
          }
      }
      
    • Can be implement by adding it as so.
      public void ConfigureServices(IServiceCollection services)
      {
          services.AddControllersWithViews(options => {
              options.Filters.Add<Filters.NoCacheAttribute>();
          });
      }
      

Entity Framework Changes

EF EDMX designer is not available in the .NET Core tooling for Visual Studio as of this writing. Because app settings has been ported to appSetting.json, EF DbContext constructor will fail looking for the ConnectionString in web.config. The following are two options to port EF to .NET Core.

  • Option 1 The first and least preferred is to use DI and new up the DBContext and update the code to support reading the ConnectionString from appSettings.
  • Option 2 Convert to EF Core. The good news is that EF Core does have feature parity with EF, see the comparison. The conversion should be seamless and straight forward. You can read more here. To port EF EDMX to EF Core, follow these steps.
    • Add EF Core Nugets:
      • Microsoft.Data.SqlClient
      • Microsoft.EntityFrameworkCore
      • Microsoft.EntityFrameworkCore.Design
      • Microsoft.EntityFrameworkCore.Relational
      • Microsoft.EntityFrameworkCore.SqlServer
      • Microsoft.EntityFrameworkCore.Tools
    • Run the Scaffold-DbContext command
      Scaffold-DbContext "Server=;Database=;User ID=; Password=; MultipleActiveResultSets=False; Encrypt=True; TrustServerCertificate=False; Connection Timeout=30;" Microsoft.EntityFrameworkCore.SqlServer -ContextDir <ContextDir> -Context <ContextName>
      
    • Inconsistent coding practices could cause pain to reconcile, e.g. class and member casing are not consistent with the database, singular vs plural naming. This step could be time consuming to clean up.
  • Configure Services
    using Microsoft.Data.SqlClient;
    using Microsoft.EntityFrameworkCore;
    using Microsoft.Extensions.Configuration;
    using Microsoft.Extensions.DependencyInjection;
    
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }
    
        public IConfiguration Configuration { get; }
    
        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddDbContext<TContext>(options => 
                options.UseSqlServer(
                    Configuration.GetConnectionString("ConnectionStringKey")));
        }
    }
    

Other Useful Links