.NET libraries and the art of backward compatibility – Part 3

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 cost, 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>
    <!-- Don't increase the AssemblyVersion unless you are making breaking changes-->
    <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}'.");
}

//This must be in a separate library
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
{
  //Adding a parameter with a default
  //results in MissingMethodException.
  //Create an overloaded method instead.
  public static void F(int n = 1)
  {
    Console.WriteLine(n);
  }
}

BEFORE

public class Class1
{
  public int Number = 0;
}

AFTER

public class Class1
{
  //Changing a field into a property will
  //results in MissingFieldException
  public int Number { get; set; }  = 0;
}

BEFORE

public interface IFoo
{
  void F();
}

AFTER

public interface IFooBase
{
  void F();
}

//Moving an interface member to a base
//interface results in
//MissingMethodException
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
{
  //Changing parameter names break
  //compilation if your customer uses
  //named arguments
  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
{
  //Default values are embedded in the
  //calling assembly so this change
  //require recompilation to show an
  //effect
  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);
  }
  //The new overload would be used only
  //by an application compiled against
  //the new library
  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:

  1. Either not guarantee binary compatibility at all
  2. 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:

  1. Create a solution with two projects: an application and a class library
  2. Add a reference to the class library in the application project
  3. Implement the minimal amount of code possible to reproduce the use case in both library and application
  4. Build the solution, test that the program is working as expected and backup the bin/Debug folder for the application
  5. Make the change you want to test the compatibility of
  6. Build the solution, test that the program is working
  7. Copy the class library .dll file only, not the application’s .exe, into the backup folder created during step 4.
  8. 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

/In the application
class Program
{
  static void Main(string[] args)
  {
    new Foo().DoSomething();
    Console.WriteLine("Press ENTER");
    Console.ReadLine();
  }
}
//In the class library
public class Foo
{
  public void DoSomething()
  {
    Console.WriteLine("Something");
  }
}

AFTER

//In the application
class Program
{
  static void Main(string[] args)
  {
    new Foo().DoSomething();
    Console.WriteLine("Press ENTER");
    Console.ReadLine();
  }
}
//In the class library
public class Foo: FooBase
{
}
public class FooBase
{
  public void DoSomething()
  {
    Console.WriteLine("Something");
  }
}

All good things must come to and 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.

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s