This blog post will focus on binary incompatibilities in .NET libraries.
This is the third post in the .NET libraries and the art of backward compatibility series:
Part 1 and 2 of this series discuss how to update your library’s code in a way that doesn’t break your customer application either by changing behavior (behavioral incompatibility) or by causing compilation errors (source incompatibility). Behavioral incompatibilities are sneaky and must be avoided at all costs, source incompatibilities are a pain for customers to address and should be minimized.
Binary Incompatibilities
The third type of incompatibilities happen when a user updates your library by dropping the .dll files into the application folder without recompiling the application itself. This “update” can be performed by either the author of the application or even by the end user.
Fear of Commitment
The first thing you need to do is to decide whether this form of update should be allowed. There are pros and cons to both allowing it and forbidding it. Your choice will affect how you write the library and how you document it, so you should decide this early.
Pros
- Especially for security patches, the end-user could update your library without having to wait for the author of the application to publish a new version.
- If your library is widely used, someone could write an application having two dependencies which use different versions of your library. This is a problem if you don’t allow the newer version to be transparently used by both dependencies.
Cons
- Guaranteeing binary compatibility is hard so you are likely to break your promise if you are not careful.
- Forcing customers to rebuild their application when they update your library will result in them running tests which could catch behavioral incompatibilities. You could even willingly introduce a source incompatibility to force a customer to address a change in the behavior of your library.
- Not guaranteeing binary compatibility will give you more freedom in designing new versions of your library and may result in a better user experience over time.
The Strong Naming Conundrum
Binary compatibility is only useful when your library’s .dll files can be replaced with a newer version, otherwise behavioral and source compatibility are all you need to worry about! It is worth knowing that strong naming your library may prevent users from replacing it with a newer version.
Strong Naming
A confusing feature of .NET which consists of signing an assembly with a cryptographic key assigning it a unique identity based on its name and version.
Weirdly enough, even if cryptography is involved, strong naming is not supposed to be relied on for security.
See Microsoft’s guidance here.
Picture by XxDBZCancucksFanxX, used under Creative Commons license
If you are not strong naming your library, you are in the clear. Just know that potential customers won’t be able to use your library if they want to strong name their assemblies.
If you want to strong name your library, the common approach is to keep the assembly version unchanged unless you are indeed making breaking changes.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<Version>1.0.1</Version>
<FileVersion>1.0.1</FileVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>SNKey.pfx</AssemblyOriginatorKeyFile>
</PropertyGroup>
</Project>
This allows all your different versions to be interchangeable even if the assemblies are strong named. Microsoft itself realized that this is confusing and cumbersome and changed strict assembly version loading in .NET Core making it more relaxed. If your library targets .NET Standard, will use the assembly loading rules for .NET Framework or .NET Core depending on which application uses it.
Types of Binary Incompatibilities
There are two types of binary incompatibilities: those that result in an exception and those that result in a behavioral change.
Typical exceptions caused by binary incompatibilities are TypeLoadException or MissingMethodException. They are particularly difficult to catch because they are thrown when the CLR first attempts to access the affected type or member from your library, which is earlier than the actual code line where the type or member is first referenced.
Behavioral changes related to binary incompatibilities are different from “normal” behavioral incompatibilities because they would be solved by recompiling the code that uses your library. This may be very confusing for users because they would likely try to reproduce the issue on a freshly compiled debug version of their application and that would not be affected.
An interesting example is reordering the entries of an enum
. Because .NET automatically assigns a numerical value to enum
entries and this value is embedded in the consuming assembly when compiled, reordering an enum
introduces both a behavioral change, as a result of a binary incompatibility, and a different behavioral change that happens when recompiling the application!
The following code:
static void Main(string[] args)
{
Console.WriteLine(
$"This is Enum1.a: '{Enum1.a}'. It's value is 0: '{(int)Enum1.a}'.");
}
public enum Enum1
{
a, b
}
...would normally print:
This is Enum1.a: 'a'. It's value is 0: '0'.
If we change the enum
definition in the library to:
public enum Enum1
{
b, a
}
...the application would now print:
This is Enum1.a: 'b'. It's value is 0: '0'.
This is because Enum1.a
is compiled into 0
in the application’s assembly. So, when we switch to the new library without recompiling, the 0
value is retained, but it now corresponds to Enum1.b
.
If we recompile the application, we now have a third different behavior!
This is Enum1.a: 'a'. It's value is 0: '1'.
Binary Compatibility and Source Compatibility
One could think that all binary incompatibilities, at least those resulting in a TypeLoadException or MissingMethodException are also source incompatibilities. This is not true.
The following is a list of code changes that are source compatible but binary incompatible.
Before
public class Class1
{
public static void F()
{
Console.WriteLine("1");
}
}
After
public class Class1
{
public static void F(int n = 1)
{
Console.WriteLine(n);
}
}
Before
public class Class1
{
public int Number = 0;
}
After
public class Class1
{
public int Number { get; set; } = 0;
}
Before
public interface IFoo
{
void F();
}
After
public interface IFooBase
{
void F();
}
public interface IFoo : IFooBase
{
}
Most source incompatibilities are also binary incompatible. There are few exceptions.
Before
public class Class1
{
public static void F(int n)
{
Console.WriteLine(n);
}
}
After
public class Class1
{
public static void F(int x)
{
Console.WriteLine(x);
}
}
Some behavioral changes only take effect upon recompilation.
Before
public class Class1
{
public static void F(int n = 1)
{
Console.WriteLine(n);
}
}
After
public class Class1
{
public static void F(int n = 2)
{
Console.WriteLine(n);
}
}
Before
public class Class1
{
public static void Print(object o)
{
Console.WriteLine(o);
}
}
After
public class Class1
{
public static void Print(object o)
{
Console.WriteLine(o);
}
public static void Print(object[] o)
{
Console.WriteLine(
string.Join("; ", o));
}
}
How Not to Go Crazy
Because the relation between binary compatibility and source compatibility is so complex, I strongly recommend to:
- either not guarantee binary compatibility at all
- or guarantee both binary and source compatibility
Decision Time
Now it is a good time to go back and re-read the pros/cons section at the beginning of the post.
Photo by Nick Youngson, used under Creative Commons license
The good news is that, while source compatibility cannot ever be fully guaranteed (see Part 2), binary compatibility is actually fully achievable. The bad news is that it is not evident at all what is binary compatible and what is not!
Fortunately, it is very easy to test whether a type of change is backward compatible or not:
- Create a solution with two projects: an application and a class library.
- Add a reference to the class library in the application project.
- Implement the minimal amount of code possible to reproduce the use case in both library and application.
- Build the solution, test that the program is working as expected and backup the bin/Debug folder for the application.
- Make the change you want to test the compatibility of.
- Build the solution, test that the program is working.
- Copy the class library .dll file only, not the application’s .exe, into the backup folder created during Step 4.
- Run the application from the backup folder and verify that it still behaves correctly.
For example, by testing the following, we can easily verify that moving a method to a base class is binary compatible (I would not have guessed that).
Before
class Program
{
static void Main(string[] args)
{
new Foo().DoSomething();
Console.WriteLine("Press ENTER");
Console.ReadLine();
}
}
public class Foo
{
public void DoSomething()
{
Console.WriteLine("Something");
}
}
After
class Program
{
static void Main(string[] args)
{
new Foo().DoSomething();
Console.WriteLine("Press ENTER");
Console.ReadLine();
}
}
public class Foo: FooBase
{
}
public class FooBase
{
public void DoSomething()
{
Console.WriteLine("Something");
}
}
All Good Things Must Come to an End
Well, this is the end of this overly long series.
Preserving backward compatibility of libraries with hundreds of thousands of users have been one of my primary concerns in the last couple of years. I am sure I haven’t yet learned all that could be known on this topic, but I sincerely hope that this proves useful to other .NET developers.
Good luck to you and thanks for reading.