The base class library (BCL) provides the fundamental APIs that you use to build all kinds of applications, no matter whether they are console apps, class libraries, desktop apps, mobile apps, websites or cloud services. One of the challenges we had at Microsoft was making the BCL easier to reason about. This includes questions like “which APIs are available on which .NET implementation,” “do I use any APIs that aren't supported on all the operating systems that I want to run on,” “do I use problematic APIs,” and of course, “do I use the APIs correctly?”
In this article, I'm going to tell you about how we're making it easier for you to answer these questions moving forward.
The Future of .NET Standard
.NET 5 will be a shared code base for .NET Core, Mono, Xamarin, and future .NET implementations.
To better reflect this, we've updated the target framework names (TFMs). TFMs are the strings you use to express which version of .NET you're targeting. You see them most often in project files and NuGet packages. Starting with .NET 5, we're using these values:
- net5.0. This is for code that runs everywhere. It combines and replaces the netcoreapp and netstandard names. This TFM will generally only include technologies that work cross-platform (except for pragmatic concessions, like we already did in .NET Standard).
- net5.0-windows. These kinds of TFMs represent OS specific flavors of .NET 5 that include net5.0 plus OS-specific bindings. In the case of net5.0-windows, these bindings include Windows Forms and WPF. In .NET 6, we'll also add TFMs to represent the mobile platforms, such as net6.0-android and net6.0-ios, which include .NET bindings for the Android and iOS SDKs.
There isn't going to be a new version of .NET Standard, but .NET 5 and all future versions will continue to support .NET Standard 2.1 and earlier. You should think of net5.0 (and future versions) as the foundation for sharing code moving forward.
Advantages of Merging .NET Core and .NET Standard
Before .NET 5, there were completely disjointed implementations of .NET (.NET Framework, .NET Core, Xamarin, etc.). In order to write a class library that can run on all of them, we had to give you a target that defines the set of shared APIs, which is exactly what .NET Standard is.
This means that every time we want to add a new API, we have to create a new version of .NET Standard and then work with all .NET platforms to ensure that they add support for this version of the standard. The first problem is that this process isn't very fast. The other problem is that this requires a decoder ring that tells you which version of which .NET platform support needs which version of the standard.
But with .NET 5, the situation is very different. We now have a shared code base for all .NET workloads, whether it's desktop apps, cloud services, or mobile apps. And in a world where all .NET workloads run on the same .NET stack, we don't need to artificially separate the work into API definition and implementation work. We just add the API to .NET and the next time the stack ships, the implementation is instantaneously available for all workloads.
This doesn't mean that all workloads will have the exact same API surface because that simply wouldn't work. For example, Android and iOS have a huge amount of OS APIs. You'll only be able to call those when you're running on those appropriate devices.
The new TFMs I mentioned earlier solve this problem: net5.0
(and future version) represent the API set that's available to all platforms. On top of that, we added OS-specific TFMs (such as net5.0-windows
) that have everything in net5.0
plus all the APIs that are specific to Windows (such as Windows Forms and WPF). And in .NET 6, we're extending this to Android and iOS by adding net6.0-android
and net6.0-ios
. The naming convention solves the decoder ring problem: Just by looking at the names, it's easy to understand that an app targeting net6.0-ios
can reference a library built for net5.0
and net6.0
but not a library that was built for net6.0-android
or net5.0-windows
.
What You Should Target
.NET 5 and all future versions will always support .NET Standard 2.1 and earlier. The only reason to retarget from .NET Standard to .NET 5 is to gain access to more APIs. So you can think of .NET 5 as .NET Standard vNext.
What about new code? Should you still start with .NET Standard 2.0 or should you go straight to .NET 5? It depends on what you're building:
- App components. If you're using libraries to break down your application into several components, my recommendation is to use netX.Y where X.Y is the lowest number of .NET that your application (or applications) are targeting. For simplicity, you probably want all projects that make up your application to be on the same version of .NET because it means you can assume the same BCL features everywhere.
- Reusable libraries. If you're building reusable libraries that you plan to ship on NuGet, you'll want to consider the trade-off between reach and API set. .NET Standard 2.0 is the highest version of .NET Standard that's supported by .NET Framework, so it will give you the most reach, while also giving you a fairly large API set to work with. We'd generally recommend against targeting .NET Standard 1.x as it's not worth the hassle anymore. If you don't need to support .NET Framework, you can go with either .NET Standard 2.1 or .NET 5. Most code can probably skip .NET Standard 2.1 and go straight to .NET 5.
So what should you do? My expectation is that widely used libraries will end up multi-targeting for both .NET Standard 2.0 and .NET 5: supporting .NET Standard 2.0 gives you the most reach while supporting .NET 5 ensures that you can leverage the latest platform features for customers that are already on .NET 5.
In a couple of years, the choice for reusable libraries will only involve the version number of netX.Y, which is basically how building libraries for .NET has always worked - you generally want to support some older version in order to ensure that you get the most reach.
To summarize:
- Use
netstandard2.0
to share code between .NET Framework and all other platforms. - Use
netstandard2.1
to share code between Mono, Xamarin, and .NET Core 3.x. - Use
net5.0
for code sharing moving forward.
Platform-Specific APIs
The goal of .NET Standard has always been to model the set of APIs that work everywhere. And we started with a very small set. Too small, as it turns out, which is why we ended up bringing back many .NET Framework APIs in .NET Standard 2.0. We did this to increase compatibility with existing code, especially NuGet packages. Although most of these APIs are general purpose, cross-platform APIs, we also included APIs that only work on Windows.
In some cases, we were able to make these APIs available as separate NuGet packages (such as Microsoft.Win32.Registry
), but in some cases we couldn't because they were members on types that were already part of .NET Standard, for example APIs to set Windows
file system permissions on the File
and Directory
classes. Moving forward, we'll try to avoid designing types where only parts of them work everywhere. But as always, there will be cases where we couldn't predict the future and are forced to throw a PlatformNotSupportedException
for some operating system down the road.
Dealing with Windows-Specific APIs
Wouldn't it be nice if Visual Studio could make you aware when you accidentally call a platform-specific API? Enter the platform compatibility analyzer. It's a new feature in .NET 5 that checks your code for usages of APIs that aren't supported on all the platforms you care about. It's a Roslyn analyzer, which means that it's running live in the IDE as you're editing code but will also raise warnings when you're building on the command line or the CI computer, thus making sure that you don't miss it.
Let's take a closer look at this method. It first checks the registry to see whether a logging directory is configured. If there isn't, it falls back to the application's directory:
private static string GetLoggingDirectory()
{
using (var key = Registry.CurrentUser.OpenSubKey(@"Software\Fabrikam"))
{
var path = "LoggingDirectoryPath";
if (key?.GetValue(path)
is string configuredPath)
{
return configuredPath;
}
}
var exePath = Process.GetCurrentProcess().MainModule.FileName;
var folder = Path.GetDirectoryName(exePath);
return Path.Combine(folder, "Logging");
return "Logging";
}
To make this code ready for cross-platform use, you have several options:
- Remove the Windows-only portions. This isn't usually desirable because when you port existing code, you generally want to support your existing customers without losing features.
- Multi-target the project to build for both .NET Framework and .NET 5, and use conditional compilation with #if to only include the Windows-specific parts when building for .NET Framework. This makes sense when the Windows-only portion depends on large components (such as Windows Forms or WPF) but it means you're producing multiple binaries. This works best for cases where you're building NuGet packages because NuGet will ensure that consumers get the correct binary without them having to manually pick the correct one.
- Guard the calls to Windows-only APIs with an operating system check. This option is the least intrusive for all consumers and works best when the OS-specific component is small.
In the case above, the best option is to guard the call with an OS check. To make these super easy, we've added new methods on the existing System.OperatingSystem
class. You only need to surround the code that uses the registry with OperatingSystem.IsWindows()
:
private static string GetLoggingDirectory()
{
// Only check registry on Windows
if (OperatingSystem.IsWindows())
{
using (var key = Registry .CurrentUser.OpenSubKey(@"Software\Fabrikam"))
{
var path = "LoggingDirectoryPath";
if (key?.GetValue(path)
is string configuredPath)
{
return configuredPath;
}
}
}
var exePath = Process.GetCurrentProcess().MainModule.FileName;
var folder = Path.GetDirectoryName(exePath);
return Path.Combine(folder, "Logging");
return "Logging";
}
As soon as you do that, the warnings automatically disappear because the analyzer is smart enough to understand that these calls are only reachable when running on Windows.
Alternatively, you could have marked GetLoggingDirectory()
as being Windows-specific:
[SupportedOSPlatform("windows")]
private static string GetLoggingDirectory()
{
// ...
}
The analyzer will also no longer flag the use of the registry inside of GetLoggingDirectory()
because it understands that it only gets called when running on Windows. However, it will now flag all callers of this method instead. This allows you to build your own platform-specific APIs and simply forward the requirement to your callers, as shown in Listing 1.
Listing 1: Platform Guards
namespace System
{
public sealed class OperatingSystem
{
public static bool IsOSPlatform(string platform);
public static bool IsOSPlatformVersionAtLeast(string platform, int major, int minor = 0, int build = 0, int revision = 0);
public static bool IsWindows();
public static bool IsWindowsVersionAtLeast(int major, int minor = 0, int build = 0, int revision = 0);
// Analogous APIs exist for Android, Browser, FreeBSD, iOS, Linux, macOS, tvOS, and watchOS
}
}
Dealing with Unsupported APIs
The previous case was an example of an API that only works on a specific set of operating systems. You can also mark APIs as unsupported for specific operating systems. This is useful for features that are generally cross-platform but can't be supported on some operating system due to some constraint. An example of this is Blazor WebAssembly. Because WebAssembly runs inside the browser's sandbox, you generally can't interact with the operating system or other processes. This means that some of the otherwise cross-platform APIs will throw a PlatformNotSupportedException
when you try to call them from Blazor WebAssembly.
For instance, let's say I paste the GetLoggingDirectory()
method into my Blazor app. Of course, the registry won't work, so let's delete that. This leaves us with just this:
private static string GetLoggingDirectory()
{
var exePath = Process.GetCurrentProcess().MainModule.FileName;
var folder = Path.GetDirectoryName(exePath);
return Path.Combine(folder, "Logging");
}
This makes sense, given that you can't enumerate processes when running in the browser sandbox.
You may wonder why these methods weren't flagged earlier. The logging library targets net5.0, which can be consumed from a Blazor WebAssembly app as well. Shouldn't this warn you when you use APIs that won't work there?
Yes and no. On the one hand, net5.0 is indeed for code that's meant to run everywhere. But on the other hand, very few libraries need to run inside a browser sandbox and there are quite a few very widely used APIs, that can't be used there. If we flagged APIs that are unsupported by Blazor WebAssembly by default, a lot of developers would get warnings that never apply to their scenarios, and for them these warnings are just noise. However, when you build class libraries that are meant to be used by Blazor WebAssembly, you can enable this validation by adding a
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<SupportedPlatform Include="browser" />
</ItemGroup>
</Project>
Blazor WebAssembly applications include this item by default. The hosted Blazor WebAssembly project template adds this line to the project that's shared between client and server as well.
Like the platform-specific APIs, you can also mark your own code as being unsupported by the browser sandbox by applying an attribute:
[UnsupportedOSPlatform("browser")]
private static string GetLoggingDirectory()
{
// ...
}
Better Obsoletions
A problem that's existed in the BCL for a long time is that it's not easy to obsolete APIs. One reason was that people compiled in production (for example, the ASP.NET websites compile on the Web server). When such a site compiles with warnings as errors, a Windows update can bring a new .NET Framework version with new obsoletions that might break the app.
The larger issue was that obsoletions can't be grouped; all obsoletions share the same diagnostic ID. This means you can either turn them all off or suppress every occurrence individually using #pragma warning disable
. That means that obsoletions were usually only viable for methods; as soon as you obsolete a type (or worse, a set of types), you might quickly cause hundreds of warnings in your code base. At this point, most developers simply disable the warning for obsoleted APIs, which means that next time you obsolete an API, they won't notice anymore.
In .NET 5, we've addressed this via a simple trick: we added DiagnosticId
property to ObsoleteAttribute
, which the compilers use when reporting warnings. This allows you to give each obsoleted feature a separate ID. Table 1 shows the list of features we've obsoleted in .NET 5. One of those features is Code Access Security (CAS). Although the attributes exist in .NET 5 to make porting easier, they aren't doing anything at runtime. This obsoletion applies to 144 APIs. If you happen to use CAS and port to .NET 5, you might get hundreds of warnings. To see the forest for the trees again, you might decide to suppress and ignore CAS-related obsoletion and file a bug to get rid of them later. You'd do this via the normal suppression mechanism, for example, by adding a <NoWarn>
entry to your project:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<!-- Ignore CAS obsoletions -->
<NoWarn>SYSLIB0003</NoWarn>
</PropertyGroup>
</Project>
The benefit of this is that other obsoletions for other features will still be raised. This allows you to declare bankruptcy in one area without accruing more debt in other areas.
Analyzers
The platform compatibility analyzer is only one of about 250 analyzers that we included with the .NET 5 SDK (about 60 of them are on by default). These analyzers cover the use of the language (C#, VB) as well as the BCL APIs.
There are about 250 analyzers in the .NET 5 SDK.
Moving forward, the idea is that as when we add new features to .NET, we're also adding corresponding analyzers and code fixers to help you use them correctly, right out of the gate.
Let's look at a simple example. I wrote this little Person
class for one of my projects. Because equality is tricky, I made a mistake in the implementation of the Equals
method. Can you spot it?
class Person : IEquatable<Person>
{
public Person(string name, DateTime birthday)
{
Name = name;
Birthday = birthday;
}
private string Name { get; }
private DateTime Birthday { get; }
public bool Equals(Person other)
{
return ReferenceEquals(Name, other.Name) && ReferenceEquals(Birthday, other.Birthday);
}
}
Not all analyzers are on by default because not every project has the same requirements. For example, not every project needs to be localized and not every project needs to operate in a Web service with high throughput demands.
For example, let's consider this code:
char[] chars = "Hello".ToArray();
Span<char> span = chars[0..2];
This creates an .editorconfig
with an entry for CA1833
. Alternatively, you could also add a line to enable all rules in the performance category:
[*.cs]
# Setting severity of a specific rule:
dotnet_diagnostic.CA1833.severity = warning
# Bulk enable all performance rules:
dotnet_analyzer_diagnostic.category-performance.severity = warning
The fixer changes the code to slice the array as a span, which doesn't need to copy the underlying array:
char[] chars = "Hello".ToArray();
Span<char> span = chars.AsSpan(0..5);
Sweet!
Closing
With .NET 5, we have heavily improved our support for static code analysis. This includes an analyzer for platform-specific code and a better mechanism to deal with obsoletions. The .NET 5 SDK includes over 230 analyzers!
.NET 5 is the successor of .NET Core and .NET Standard. As a result, the net5.0
name unifies and replaces the netcoreapp
and netstandard
framework names. If you still need to target .NET Framework, you should continue to use netstandard2.0
. Starting with .NET 5, we'll provide a unified implementation of .NET that can support all workloads, include console apps, Windows desktop apps, websites, and cloud services. And, with .NET 6, this will also include the iOS and Android platforms.
Table 1: List of obsoletions in .NET 5
ID | Message | #APIs |
SYSLIB0001 | The UTF-7 encoding is insecure and should not be used. Consider using UTF-8 instead. | 3 |
SYSLIB0002 | PrincipalPermissionAttribute is not honored by the runtime and must not be used. | 1 |
SYSLIB0003 | Code Access Security is not supported or honored by the runtime. | 144 |
SYSLIB0004 | The Constrained Execution Region (CER) feature is not supported. | 9 |
SYSLIB0005 | The Global Assembly Cache is not supported. | 2 |
SYSLIB0006 | Thread.Abort is not supported and throws PlatformNotSupportedException. | 2 |
SYSLIB0007 | The default implementation of this cryptography algorithm is not supported | 5 |
SYSLIB0008 | The CreatePdbGenerator API is not supported and throws PlatformNotSupportedException. | 1 |
SYSLIB0009 | The AuthenticationManager Authenticate and `PreAuthenticate` methods are not supported and throw PlatformNotSupportedException. | 2 |
SYSLIB0010 | This Remoting API is not supported and throws PlatformNotSupportedException. | 2 |
SYSLIB0011 | BinaryFormatter serialization is obsolete and should not be used. See https://aka.ms/binaryformatter for more information. | 6 |
SYSLIB0012 | Assembly.CodeBase and Assembly.EscapedCodeBase are only included for .NET Framework compatibility. Use Assembly.Location instead. | 3 |
SYSLIB0013 | Uri.EscapeUriString can corrupt the URI string in some cases. Consider using Uri.EscapeDataString for query string components instead. | 1 |
SYSLIB0014 | Use HttpClient instead. | 12 |