Hello everyone, it's my first post here. In this post I wanna talk about our migration of large enterprise project from .Net Framework 4.7.2 to .Net Core 3.1, problems that we faced and how we overcame them. It will be a long post so take a cup of coffee or tea and let's go!
Our goal
Our primary goal was to make our large monorepo solution work on Linux, so latest LTS Net Core version was selected in order to achieve this. Previously we had our production running on Windows which is pretty expensive. Also it was hosted by another company so we had no real control over it. Our own data centers have all required infrastructure for running our services but they support Linux only so our target was obvious and clear - port existing code and infrastructure to Net Core and run it on Linux environment.
Let's go through all our long way step by step!
Porting all libraries and nugets to netstandard2.0
During migration process we had to support both old and new versions of code and it's too difficult when you have to keep two separate codebases. Our first step was to migrate all our shared code to netstandard2.0. Netstandard2.0 is supported by both Net Core 3 and .Net472 so it was a good choice.
In most cases migration to netstandard doesn't require too much effort. We had to remove a bit of .Net Framework specific code, update all our libraries to versions with netstandard support. Anyway even here we got few pitfalls. They were related to .Net core transitive dependencies. As longer we still had web api project on 472 then it doesn't load dependencies required by converted to netstandard projects that ended with runtime errors. Also in different projects we had different versions of same nugets installed. After switching to netstandard we missed that point and got classic runtime errors about missing methods and so on.
Every time you want to update library - update it in whole solution, that's important!
Porting all tests to .Net core 3.1
If compare with libraries there is only one huge difference. Test can't target net standard. They target specific platform instead. So we targeted .Net Core 3.1 instead. We also had to update all libs to latest version and got few hundreds compile errors due to changed fluent assertion syntax π Anyway it was easy to fix. Auto replace did it for us π Fixed tests showed that we have no issues in code except transitive dependencies mentioned above π Also we had to skip our current DI tests because we had nothing to test at that moment
Migration of web project itself: new project structure
We created new Net Core project alongside with old one that was under active development at that moment. We created project from scratch and decided to copy logic from old project step by step. We didn't have ability to keep only old or only new project so it was hard to copy-paste changes from old project to new one, it was merge hell π
Migration of web project itself: middleware and filters
We decided to start from pipeline in our migration process. Previously on .Net472 we used few global and few local filters, some of them didn't work as real filters because they actually added some info to request-response and didn't filter anything π Instead of global filters Net Core recommends to use middleware. Middleware concept came to dotnet world from NodeJS I suppose. We rewrote our global filters as set of middlewares. Here we did our first serious mistake. In middleware world order is important. First registered middleware will be executed first before request execution and last after and so on. We had invalid order initially and didn't check it later because of time trouble. We had logging middleware that processed all unhandled errors, but it was executed after middleware that suppressed all errors so we lost logs about exceptions on production. Oops.
Always check your middlewares order!
Also we had few filters. We rewrote them using new filter syntax. Also we used Net Core type filter that allowed us to inject dependencies into filter constructor (on .Net472 we had terrible property injection, don't use it unless you want to obfuscate your code). Here we did another serious mistake. It appeared that we had our filters as singletons previously while Net Core version were recreated each time. We resolved it using IsReusable
property for filter. Our code:
public class CustomFilterAttribute : TypeFilterAttribute // attribute for acion or class
{
public CustomFilterAttribute(bool someArgument = true)
: base(typeof(CustomFilter)) // filter itself
{
Arguments = new object[] {someArgument}; // some attribute argument that we want to pass to filter too
IsReusable = true;
}
}
In order to improve cod quality some code was moved to global filter. It was our third mistake here - we missed a point that global filter is being executed before others. Here is good article about filters
Don't recreate filters each time unless you really need this behavior
Migration of web project itself: controllers
Controllers syntax changed a bit in Net core but we didn't have any real problems with it. We refactored all our controllers - removed injections in methods, property injections and other horrible things. It took some time because we had a lot of controllers with many actions inside. Also we splitted too large controllers into separate ones for readability purposes. When you have 90% of all actions in one controller think if your code violates SRP π
I should also mention problem with serialization. In Net Core default serializer is different so we got different JSON. In some services we had custom formatters and adapted it to new framework. In other services we had to change serializer settings as well.
Migration of frontend stuff
Also we had few views used mostly for testing purposes. We moved them to wwwroot
and added static files middleware. It was enough to make them work but also we found out that in html our scripts and styles were included with names in wrong case. It worked fine on case-insensitive Windows but on Linux it created a lot of 404 errors.
Check if case is correct everywhere if you wanna use Linux!
The most problematic part on frontend side was SignalR. Latest version was completely different from version that we used before. After update we had issue with transport because previously default one was LongPolling
and it was switched to WebSockets
. Our nginx liked that and ate all websockets requests so we had to use LongPolling
again π
Docker
We used Docker for creating containers for our apps. Dockerizing Net Core app is easy task and probably it was the easiest part of our journey π
Final fixes
During local testing on final phase we realized that our DI framework doesn't instantiate controllers for some time after start. After digging it appeared that we had no warmup for it and tried to create too much services on first request. So we added warmup for DI. It created all controllers and services on start for us so our app was able to execute requests right after start. This thing replaced DI tests for us as well. Example of such simple warmup is provided below (don't forget to add .AddControllersAsServices()
to make it work):
private void WarmUpControllers(IServiceProvider serviceProvider)
{
var controllersList = _serviceCollection.Where(x =>
x.ServiceType.IsSubclassOf(typeof(ControllerBase)) &&
!x.ServiceType.IsAbstract)
.Select(x => x.ServiceType);
foreach (var controller in controllersList)
{
serviceProvider.GetRequiredService(controller);
}
}
Also we added some technical stuff like health checks. We also had funny moment here. I wrote health check that pinged Mongo database, it worked on Windows. But on Linux it throwed OperationCanceledException
immediately if cancellation token is passed! I had to use older version of Mongo driver to make it work because I didn't want to remove that cancellation token π Also please note that we have separate port (called management port) for health checks and similar stuff. These port is not exposed outside.
Test your code on env similar to production
Testing phase: how everything went wrong
Finally we were ready to ask our QA to start testing. Our api is consumed by mobile clients mostly while we tested it using Swagger mostly so we didn't know details of client implementation. Soon after start of testing QA found strange problem. 2nd request to endpoint failed each time for them. We started to digging this issue and faced situation from these horror films where tech debt eats developers. In Net472 solution DI framework called LightInject was used. We copied it too because we didn't want to spent a month on fixing registrations. As its name says it's light, but in fact it allows to do a lot of weird things because of its "lightweight" checks. So in old solution we found a lot of cases where devs tried to optimize DI calls and injected scoped services into singletons. In this case scoped service becomes a singleton too. In order to avoid this in most cases factory Func<T>
was injected and resolved into services right in method! It worked in old Net Framework but it didn't in Net Core.
After a week playing with this we found a lot of related cool bugs. For example we had bug when each first request to action fails but others work. Even if different actions have same code inside first requests to each one failed O_o. Finally we found a hack that helped us fix that. Instead of using default LightInject factories for services we used such factory:
var serviceProvider = httpContextAccessor.HttpContext.RequestServices;
return serviceProvider.GetRequiredService<TService>();
It's a terrible hack and I hope I won't use it again (we had to copy it to another service anyway) π Hopefully this hack helped us to fix our issue and our QA started to test our dotnet service once again.
Use frameworks that limit their incorrect usage. I recommend to use default frameworks if possible. For example Net Core is good for 99% of projects.
Testing and deployment to production
I was kindly surprised but we didn't have any business logic related bugs in our code after migration (probably we had but QA didn't find them π). All we had was poor performance and missing logs π I think performance issue was related mostly to our Mongo driver that showed worse speed on Net Core (in fact we used Mongo in a wrong way but it's a long story), but difference wasn't critical. Probably on third or forth attempt we successfully deployed our work to production with all fixes. So now our production uses .Net Core 3.1 and it's running on Linux.
Conclusion
Migration to Net Core was a real challenge for us. It's really painful to migrate large enterprise solution from old framework, especially when you are in time trouble and have technical debt like ours π Anyway risks worth it. Net Framework is obsolete so I recommend to switch to cross-platform Net Core and enjoy dotnet apps running on Linux!
Thanks for reading!
Top comments (1)
Very useful article and gives us a peek into what to expect when carrying out a similar exercise. Thank you and all the best!