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

This is the second post in the .NET libraries and the art of backward compatibility series:

Part 1 was all about a simple, but hard to follow, rule: “don’t make changes to your library that alter its behavior”. That was about it, just don’t do it!

Part 2 and 3 are about additional types of backward compatibility that you may want to guarantee to your customers. You don’t have to, many products don’t, but you should decide in advance and set expectations accordingly.

Source Incompatibilities

This next type of incompatibility is the most straightforward: when your customers update your library, their projects don’t compile anymore.

Source incompatibilities never go unnoticed!

This is obviously annoying as your customers now have to scramble to update their code. On the other hand, this is way better than a silent behavioral change in your library: source incompatibilities never go unnoticed! We have already discussed in the previous post how you can even use the Obsolete attribute to force a source incompatibility and protect your users from a behavioral change:

[Obsolete("This class is obsolete, use SeverelyBuggedClassV2 instead.", error: true)]
public class SeverelyBuggedClass
{
}

Name conflicts

If your goal is to never break source compatibility, be advised that you won’t be able to completely guarantee that. At the very least, you don’t have a way to control which type names are already used in your customer project and you may end up with a conflict when adding new classes to your library.

This is not the end of the world as the resulting error is straightforward and easy to fix:

Error CS0104 'Foo' is an ambiguous reference between 'Namespace1.Foo' and 'Namespace2.Foo'

Addressing this error may be extremely tedious and time consuming though. At the very least, make sure not to use names conflicting widely used .NET types from Microsoft (e.g. don’t name your class Int32 or String).

An interesting corner case is when a new method signature (name and parameter types) conflicts with an extension method defined by your customer. This is actually a behavioral incompatibility because it won’t result in a compilation error but will silently switch your customer code to use the new method instead of the extension method!

Common source incompatibilities

Most types of source incompatibilities are pretty evident:

  • Renaming or removing a type, property or method
  • Removing virtual from a method
  • Adding final to a class
  • Changing a method, property or field to be static or non-static
  • Adding a constructor with parameters to a class without constructors
  • Making public types internal
  • Making public or protected members private or internal
  • Adding non-optional parameters to a method
  • Changing method parameter types (unless a implicit conversion is available, e.g. changing short to long is ok)
  • Changing property, field and method return types (unless a implicit conversion is available, e.g. long to short is ok)
  • Changing method parameters modifiers (in, out, ref or removing params)
  • Renaming a method parameter (this breaks the usage of named arguments)
  • Adding type constraints on generic types
  • etc.
Stop-Hammer Time sign.
Photo by Mollybob, used under Creative Commons license.

Stop!

Go back and read the list again, I am sure there are a couple of entries you have overlooked. 🙂

(I know I had to go back and add to the list multiple times while writing the post…)

Photo by Mollybob, used under Creative Commons license.

Making any of these changes is only an issue for public members of public types (or protected members of public non-final types): if your customer can’t use what you have changed, you won’t break them. There is an exception to this rule: reflection.

Reflection

Reflection allows to access types and members that would normally not be visible. This violates all encapsulation rules and, unless you have instructed your customers to use reflection on your library, is a very bad practice.

You may want to be explicit in your documentation and state that all private portions of your library can be changed without notice and without any backward compatibility guarantee. Except for that, I think most customers who use reflection on someone else’s library know that their code may break.

If you use reflection within your library (there are indeed some reasonable use cases for it), make sure you have unit tests for that code because you may easily break your own library when making changes.

Interfaces and abstract classes

While most source incompatibilities come from the removal of features that your customer is using, the addition of constraints is also a problem.

The most common source of this issue is the addition of methods or properties on public interfaces or the addition of abstract members to classes. This can easily be overlooked as “adding functionalities” but, if a customer is implementing the interface (or extending the abstract class), they will now have to change their code to implement the new members.

Implicit type conversions

Unfortunately there are very few tools to work around introducing source incompatibilities. For the most part, it is just a matter of making a good design in the first place and being careful when making code changes later.

One area where we have some language support is changing input and output types for methods, properties and fields. We can define implicit conversion operators to keep the change source compatible.

For example, let’s say that we have this method

public class Calendar
{
  public DateTime FindNextAppointment(DateTime start);
}

and we don’t like how the DateTime type in .NET can be Utc, Local or even Unspecified, making this method error-prone to use.

We can change its parameter and return type to a different one if we provide implicit conversions:

public class Calendar
{
  public UtcDateTime FindNextAppointment(UtcDateTime start);
}

public struct UtcDateTime
{
  private readonly DateTime Time;
  
  public UtcDateTime(DateTime time)
  {
    switch (time.Kind)
    {
      case DateTimeKind.Utc:
        Time = time;
        break;
      case DateTimeKind.Local:
        Time = time.ToUniversalTime();
        break;
      default:
        throw new NotSupportedException("UtcDateTime cannot be initialized with an Unspecified DateTime.");
    }
  }
  
  public static implicit operator UtcDateTime(DateTime t) => new UtcDateTime(t);
  public static implicit operator DateTime(UtcDateTime t) => t.Time;
}

This change is source compatible (not behaviorally compatible though because we are now throwing a NotSupportedException).

What next?

The next blog post will cover pros and cons of guaranteeing binary compatibility to your customers as well as how to actually maintain binary compatibility for your library.

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