Dot Net library compatibility with Core and Framework
The challenges of compatibility with historical versions of Dot Net Framework
I’m very grateful that the .NET ecosystem is now properly open source. Prior to the introduction of DotNetCore, we lived with what is now known as the DotNet Framework —and there’s still lots of DotNet Framework software out there in the wild.
For those of us publishing software on NuGet, it’s worth spending some time making our software compatible with the DotNet Framework. Let’s investigate what it takes to set up a library compatible with as many versions as possible.
How far back should compatibility go?
We have to expect that slow-moving companies can be years behind. I’ve seen large companies whose major applications are a decade behind on DotNet releases!
Let’s see how much backwards-compatible support we can provide:
- January 2002: DotNet 1.0
- October 2005: DotNet 2.0 added breaking changes to common language runtime to support generic classes
- November 2006: DotNet 3.0 was mostly a release of new libraries for desktop and network app development, few if any breaking changes
- November 2007: DotNet 3.5 introduced LINQ and a few library changes
- April 2010: DotNet 4.0 added many small features but few major changes
- August 2012: DotNet 4.5 added async/await
Starting in June 2016 Microsoft began to release versions of Dot Net Core, a fully open-source library that would eventually replace the Dot Net Framework. This threw a monkey wrench into the works with the introduction of NetStandard 1.0, a specification that was intended to enable libraries to straddle the Framework/Core divide. NetStandard 1.0 had a lot of issues, and it only settled down with the release of NetStandard 2.0.
With this information in mind, I decided on the following:
- There’s no point in supporting DotNet 1.0 anymore.
- If you are unafraid, targeting DotNet 2.0 will give you compatibility back to software written in 2005. It’s a stretch.
- The benefits of properly supporting async/await are huge, so I need a version specifically compatible with DotNet 4.5.
- Adding NetStandard 2.0 allows some compatibility with older frameworks as well as some Core releases.
- I’d then add versions for Net50 and Net60, the two modern releases.
Let’s get started.
Compatibility with DotNet 2.0
Clearly, the biggest things that I would miss when working with DotNet 2.0 are LINQ and async/await. What I didn’t expect are the little nuisances:
- StringBuilder doesn’t have a
Clear()
method — instead, you have to set its length to zero. - Extension methods, ones that take a
this
parameter, aren’t yet supported. - Some classes like
SmtpClient
have slightly different interfaces.
It was possible to create little #if/#else/#endif blocks around my code to manage most of these differences. My code winds up looking like this:
The next challenge is to resist the temptation to allow modern IDEs to clarify your code. When viewing code in DotNet 2.0 mode, I implement things that compile correctly; but when I view them in Net50 mode, my IDE starts suggesting helpful refactoring options which won’t be compatible with older frameworks. Be careful!
Compatibility with DotNet 4.5
When we first built the Lockstep’s C# SDK we used the built-in DotNetCore library for JSON parsing. Older versions of DotNet don’t provide this feature, so I had to use the extremely reliable Newtonsoft for DotNet 4.5 and older. I prefer to avoid adding dependencies on external packages if I can avoid it, but this one was necessary.
Most of the other small changes in older versions of DotNet were easy to handle, such as the HttpUtility
class that moves between System.Net
and System.Web
. I was very surprised though to find that DotNet 4.5 doesn’t provide HttpMethod.PATCH
! Fortunately, you can just call new HttpMethod("PATCH")
and get similar results.
Another small nuisance presented itself. Although DotNet 4.5 was the version that introduced async/await, not all class libraries yet supported it. For example, the File I/O classes only provided File.ReadAllBytes
whereas later revisions of DotNetCore providedFile.ReadAllBytesAsync
.
As I continued creating compatible versions, I discovered that it was tricky to switch back and forth between one library and another. I chose to have a completely separate project file for each DotNet version:
After a bit of trial and error, I chose to have each project in its own folder. This meant that I could be sure each library was compiled correctly, and that I could observe the packaging in my .nuspec file like this:
Now that I had a bunch of different projects, I often look at the exact same code through different framework lenses. I found it useful to add a marker to see which version I was currently on:
When I see a confusing compiler warning or error, I can consult this little bit of code to see which framework generated the message.
I eventually decided that 4.5 was as old as I would go for an API software development kit. Support for async/await is very necessary for an API client.
Which IDE should I use?
At about this time I started discovering nuisances in my selection of IDE. I often switch between Visual Studio Code, JetBrains Rider, and Visual Studio Community edition — unfortunately, none of them were quite what I wanted.
Out of each, I like JetBrains Rider for its more advanced linting and refactoring suggestions, and VS Code is clearly the fastest and cleanest. Both lacked compatibility with older DotNet framework versions, so I ended up with Visual Studio 2022 which could run all frameworks in a single solution.
The curse of nullability
I have often taught my students that NULL, in SQL, is like poison: anything it touches automatically becomes tainted. The same is often true of async/await; the instant you start trying to support it your entire program must be rewritten to be fully async.
The DotNet architecture community made a great choice recently by abandoning the concept of nullable reference types. This simple change radically rewrites DotNet in more ways than I can count, and it thankfully does away with the endless parade of if (x == null) { special case }
code that litters older dotnet projects.
This is also a curse of compatibility. To properly support nullability, I had to now litter my code with #if/#else conditionals to make the code work correctly across Net60. In some cases this just didn’t make sense, so I left legacy nullability rules in force for CSVFile.
Automated build and test
I originally wrote CSVFile using Travis-CI, an automated continuous integration service that’s existed for many years. I was able to expand my travis.yml file to support build and test for NetStandard20, Net50, and Net60 — but implementing support for older frameworks proved challenging.
According to Travis’ C# documentation, it should be possible to install NUnit using Mono and run tests on older frameworks. GitHub also provides Workflow Actions which can potentially handle some of the same use cases, but the farther back you go in time the more difficult it is to create CI scripts.
Whether or not I have continuous integration working for all old frameworks, with Visual Studio 2022, I can at least run all tests on all versions from my desktop. It’s clear that the compatibility situation with DotNet has improved significantly, and it is definitely possible to build a class library compatible with all versions of DotNet released since 2005.
Ted Spence teaches at Bellevue College and leads engineering at Lockstep. If you’re interested in software engineering and business analysis, I’d love to hear from you on LinkedIn.