Tales from the .NET Migration Trenches - Migrating Initial Business Logic

Posts in this series:

In the last post we moved just our initial controller over but none of the code used by the controller yet. The first "feature" migrated will be this controller and its vertical slice of backend and frontend functionality. We picked a "small" and easy controller but in practice we found that this first controller inevitably brings quite a bit of other baggage along with it, like:

  • DI container configuration
  • ORM setup and configuration
  • Middleware
  • Layouts, view components, and HTML helpers
  • Static assets (JS, CSS)

It's not really possible to skip these for our first shipped feature, so our team tried to break down each of these separately:

  • Request pipeline junk (Controller, filters, middleware)
  • Business logic junk
  • HTML junk

Basically each part of the "MVC" pattern separately. Right now, we have a migrated controller but none of the associated middleware. This is because our first controller didn't require any of our customized middleware but this will change soon.

Next up is the "M" part of MVC which really just entails "everything needed to produce a View Model". It can be tempting to try and port all business logic all at once but that can skip some very important steps, such as evaluating our dependencies and determining if we need to upgrade first before migration. For example, if your existing application logic uses some custom logger or 3rd party logger, you may want to migrate to Microsoft's ILogger<T> first before moving your business logic. Regardless, we need to examine our controller's logic and vertical slices of functionality to understand what needs to be migrated over.

Analyzing Existing Dependencies

First, our controller's constructor:

public HomeController(IMediator mediator)
{
    _mediator = mediator;
}

Right away we can see that our ASP.NET MVC 5 is configured to use dependency injection, and its injection a MediatR IMediator interface. We need to decide:

  • What DI container to use in the .NET 6 application
  • How to register MediatR with the container

DI container registration in ASP.NET MVC 5 was...ugly to say the least. Quite a bit of extra middleware and setup existed solely to work around the lack of a built-in container. Nested containers, custom startup methods, custom filters, it was a mess. I'm going to ignore all of that except to call out that your application may have a bit of static service location going on. You'll again need to decide if you want to modernize or migrate.

Looking at the container's configuration/registration code, it's pretty simple:

public static class IoC {
    private static IContainer _container;
    public static IContainer Container => _container ?? (_container = Initialize());

    private static IContainer Initialize()
    {
        return new Container(
            c => c.AddRegistry<DefaultRegistry>());
    }
}

It's a single entry point to a registration class that has all the gory details:

public class DefaultRegistry : Registry {
    #region Constructors and Destructors

    public DefaultRegistry() {
        Scan(
            scan => {
                scan.TheCallingAssembly();
                scan.WithDefaultConventions();
                scan.LookForRegistries();
                scan.AssemblyContainingType<DefaultRegistry>();
                scan.AssemblyContainingType<ILogger>();
                scan.AddAllTypesOf(typeof(IModelBinder));
                scan.AddAllTypesOf(typeof(IModelBinderProvider));
                scan.With(new ControllerConvention());
            });
        For<SchoolContext>().Use(() => 
            new SchoolContext("SchoolContext"))
            .LifecycleIs<TransientLifecycle>();
        For<IControllerFactory>().Use<ControllerFactory>();
        For<ModelValidatorProvider>().Use<FluentValidationModelValidatorProvider>();
        For<IValidatorFactory>().Use<StructureMapValidatorFactory>();
        For<IBackgroundJobClient>().Use<BackgroundJobClient>();
        For<IBackgroundJobFactory>().Use<BackgroundJobFactory>();
        For<IBackgroundJobStateChanger>().Use<BackgroundJobStateChanger>();

        var connectionString = ConfigurationManager.ConnectionStrings["SchoolContext"].ConnectionString;
        For<JobStorage>().Use(new SqlServerStorage(connectionString));
        For<IJobFilterProvider>().Use(JobFilterProviders.Providers);

        For<IServiceProvider>().Use(ctxt => new StructureMapServiceProvider(ctxt));

        For<INotificationPublisher>().Use<ForeachAwaitPublisher>();
    }

    #endregion
}

Well, there's a LOT going on here. This registrations class:

  • Scans assemblies and registers:
    • Classes with default conventions (IFoo to Foo)
    • Looks for additional registries
    • Adds all implementations of IModelBinder
    • Adds all implementations of IModelBinderProvider
    • Adds all controllers
  • Registers explicitly:
    • EF6
    • Fluent Validation and integration with MVC 5
    • Hangfire services and persistence
    • Individual application-specific services

We also have other registration classes:

  • AutoMapperInitializer.cs (self-explanatory)
  • CommandProcessingRegistry.cs - registers both MediatR and FluentValidation
  • HtmlTagRegistry.cs - registers HtmlTags

The stock container would work with the ".NET Core" version of all of these 3rd-party dependencies. Each either has built-in support or an extension library for the Microsoft.Extensions.DependencyInjection stock DI container.

What's not in the stock DI container is any support for scanning and auto-registration. In this application there's not too much of that but in larger applications there might be a lot of features that leverage custom registration capabilities, resolution, or other random features not present in the stock container. Because of this, I'll opt for modernization and move my container usage from StructureMap to Lamar, the rewrite of StructureMap for .NET Core. As a bonus, a lot of my registration code will work "as-is", depending on if I want to keep it or not.

Finally, the business logic itself:

public class About
{
    public class EnrollmentDateGroup
    {
        [DataType(DataType.Date)]
        public DateTime? EnrollmentDate { get; set; }

        public int StudentCount { get; set; }
    }

    public class Query : IRequest<IEnumerable<EnrollmentDateGroup>> { }

    public class Handler : IRequestHandler<Query, IEnumerable<EnrollmentDateGroup>>
    {
        private readonly SchoolContext _dbContext;

        public Handler(SchoolContext dbContext)
        {
            _dbContext = dbContext;
        }

        public async Task<IEnumerable<EnrollmentDateGroup>> Handle(Query message, CancellationToken token)
        {
            // Commenting out LINQ to show how to do the same thing in SQL.
            //IQueryable<EnrollmentDateGroup> = from student in db.Students
            //           group student by student.EnrollmentDate into dateGroup
            //           select new EnrollmentDateGroup()
            //           {
            //               EnrollmentDate = dateGroup.Key,
            //               StudentCount = dateGroup.Count()
            //           };

            // SQL version of the above LINQ code.
            string query = "SELECT EnrollmentDate, COUNT(*) AS StudentCount "
                + "FROM Person "
                + "WHERE Discriminator = 'Student' "
                + "GROUP BY EnrollmentDate";
            IEnumerable<EnrollmentDateGroup> data = await _dbContext.Database
                .SqlQuery<EnrollmentDateGroup>(query)
                .ToListAsync();

            return data;
        }
    }
}

Yes this "business logic" is a bit weird but it's what existed in the original ContosoUniversity EF6/MVC 5 sample app that I converted to vertical slice architecture. What's important here is that it only depends on the EF6 DbContext so if I migrate that I should be OK. And in the "Shared Library" post, we already migrated it to a netstandard2.1-targeted library, so we're all set.

Migrating Dependencies

Keeping with the theme of "Only Migrate What's Required", I only want to bring in Lamar, MediatR, and EF6. This will also force me to migrate a little bit of configuration (the connection string). After pulling in the Lamar package, the initialization simply becomes:

builder.Host.UseLamar(registry => registry.IncludeRegistry<DefaultRegistry>());

With the DefaultRegistry migrating to:

public class DefaultRegistry : ServiceRegistry
{
    public DefaultRegistry()
    {
        Scan(
            scan => {
                scan.TheCallingAssembly();
                scan.WithDefaultConventions();
                scan.LookForRegistries();
                scan.AssemblyContainingType<DefaultRegistry>();
                scan.AssemblyContainingType<ILogger>();
                scan.AddAllTypesOf(typeof(IModelBinder));
                scan.AddAllTypesOf(typeof(IModelBinderProvider));
            });
        For<SchoolContext>()
            .Use(ctxt => new SchoolContext(ctxt.GetInstance<IConfiguration>().GetConnectionString("SchoolContext"))))
            .Scoped();

        For<INotificationPublisher>().Use<ForeachAwaitPublisher>();
    }
}

Some things I've left out because they're not migrated, but others simply aren't needed anymore. Technically the IModelBinder and IModelBinderProvider custom implementations haven't been migrated but I pulled those two lines over and simply corrected the namespace.

The connection string I'll pull over from the original Web.config and drop it into the appsettings.Development.json file:

"ConnectionStrings": {
  "SchoolContext": "Data Source=.\\sqlexpress;Initial Catalog=ContosoUniversity;Integrated Security=SSPI;"
}

For MediatR, I can now use the built-in registration extension:

builder.Services.AddMediatR(config =>
{
    config.RegisterServicesFromAssemblyContaining<HomeController>();
});

The business logic is copy-and-paste with no changes. We already migrated common dependencies and logic into a shared library so our .NET 6 and .NET 4.8 app can both reference and use the same EF6 code and models.

With this in place, the business logic all compiles and runs, riiiiight until we need to show a view. In the next post, we'll climb the next hill in our journey, migrating our first views.