A definitive guide to API-breaking changes in .NET

Viewed 35952

I would like to gather as much information as possible regarding API versioning in .NET/CLR, and specifically how API changes do or do not break client applications. First, let's define some terms:

API change - a change in the publicly visible definition of a type, including any of its public members. This includes changing type and member names, changing base type of a type, adding/removing interfaces from list of implemented interfaces of a type, adding/removing members (including overloads), changing member visibility, renaming method and type parameters, adding default values for method parameters, adding/removing attributes on types and members, and adding/removing generic type parameters on types and members (did I miss anything?). This does not include any changes in member bodies, or any changes to private members (i.e. we do not take into account Reflection).

Binary-level break - an API change that results in client assemblies compiled against older version of the API potentially not loading with the new version. Example: changing method signature, even if it allows to be called in the same way as before (ie: void to return type / parameter default values overloads).

Source-level break - an API change that results in existing code written to compile against older version of the API potentially not compiling with the new version. Already compiled client assemblies work as before, however. Example: adding a new overload that can result in ambiguity in method calls that were unambiguous previous.

Source-level quiet semantics change - an API change that results in existing code written to compile against older version of the API quietly change its semantics, e.g. by calling a different method. The code should however continue to compile with no warnings/errors, and previously compiled assemblies should work as before. Example: implementing a new interface on an existing class that results in a different overload being chosen during overload resolution.

The ultimate goal is to catalogize as many breaking and quiet semantics API changes as possible, and describe exact effect of breakage, and which languages are and are not affected by it. To expand on the latter: while some changes affect all languages universally (e.g. adding a new member to an interface will break implementations of that interface in any language), some require very specific language semantics to enter into play to get a break. This most typically involves method overloading, and, in general, anything having to do with implicit type conversions. There doesn't seem to be any way to define the "least common denominator" here even for CLS-conformant languages (i.e. those conforming at least to rules of "CLS consumer" as defined in CLI spec) - though I'll appreciate if someone corrects me as being wrong here - so this will have to go language by language. Those of most interest are naturally the ones that come with .NET out of the box: C#, VB and F#; but others, such as IronPython, IronRuby, Delphi Prism etc are also relevant. The more of a corner case it is, the more interesting it will be - things like removing members are pretty self-evident, but subtle interactions between e.g. method overloading, optional/default parameters, lambda type inference, and conversion operators can be very surprising at times.

A few examples to kickstart this:

Adding new method overloads

Kind: source-level break

Languages affected: C#, VB, F#

API before change:

public class Foo
{
    public void Bar(IEnumerable x);
}

API after change:

public class Foo
{
    public void Bar(IEnumerable x);
    public void Bar(ICloneable x);
}

Sample client code working before change and broken after it:

new Foo().Bar(new int[0]);

Adding new implicit conversion operator overloads

Kind: source-level break.

Languages affected: C#, VB

Languages not affected: F#

API before change:

public class Foo
{
    public static implicit operator int ();
}

API after change:

public class Foo
{
    public static implicit operator int ();
    public static implicit operator float ();
}

Sample client code working before change and broken after it:

void Bar(int x);
void Bar(float x);
Bar(new Foo());

Notes: F# is not broken, because it does not have any language level support for overloaded operators, neither explicit nor implicit - both have to be called directly as op_Explicit and op_Implicit methods.

Adding new instance methods

Kind: source-level quiet semantics change.

Languages affected: C#, VB

Languages not affected: F#

API before change:

public class Foo
{
}

API after change:

public class Foo
{
    public void Bar();
}

Sample client code that suffers a quiet semantics change:

public static class FooExtensions
{
    public void Bar(this Foo foo);
}

new Foo().Bar();

Notes: F# is not broken, because it does not have language level support for ExtensionMethodAttribute, and requires CLS extension methods to be called as static methods.

17 Answers

Changing a field to a property

Kind of Break: API

Languages Affected: Visual Basic and C#*

Info: When you change a normal field or variable into a property in visual basic, any outside code referencing that member in any way will need to be recompiled.

API Before Change:

Public Class Foo    
    Public Shared Bar As String = ""    
End Class

API After Change:

Public Class Foo
    Private Shared _Bar As String = ""
    Public Shared Property Bar As String
        Get
            Return _Bar
        End Get
        Set(value As String)
            _Bar = value
        End Set
    End Property
End Class    

Sample client code that works but is broken afterwards :

Foo.Bar = "foobar"

Promotion to an Extension Method

Kind: source-level break

Languages affected: C# v6 and higher (maybe others?)

API before change:

public static class Foo
{
    public static void Bar(string x);
}

API after change:

public static class Foo
{
    public void Bar(this string x);
}

Sample client code working before change and broken after it:

using static Foo;

class Program
{
    static void Main() => Bar("hello");
}

More Info: https://github.com/dotnet/csharplang/issues/665

Overloading method with a parameter of nullable type

Kind: Source-level break

Languages affected: C#, VB

API before a change:

public class Foo
{
    public void Bar(string param);
}

API after the change:

public class Foo
{
    public void Bar(string param);
    public void Bar(int? param);
}

Sample client code working before the change and broken after it:

new Foo().Bar(null);

Exception: The call is ambiguous between the following methods or properties.

The Visual Studio Extension NDepend provides several rules in the category API Breaking Changes to detect binary level break. These rules are executed only if the NDepend baseline is defined.

  • API Breaking Changes: Types: This rule warns if a type publicly visible in the baseline, is not publicly visible anymore or if it has been removed. Clients code using such type will be broken.
  • API Breaking Changes: Methods: This rule warns if a method publicly visible in the baseline, is not publicly visible anymore or if it has been removed. Clients code using such method will be broken. Note that if a method signature gets changed the old method version is seen as removed and the new method version is seen as added, so a breaking change will be detected on the old method version.
  • API Breaking Changes: Fields: This rule warns if a field publicly visible in the baseline, is not publicly visible anymore or if it has been removed. Clients code using such field will be broken.
  • API Breaking Changes: Interfaces and Abstract Classes: This rule warns if a publicly visible interface or abstract class has been changed and contains new abstract methods or if some abstract methods have been removed. Clients code that implement such interface or derive from such abstract class will be broken.
  • Broken serializable types: This rule warns about breaking changes in types tagged with SerializableAttribute. To do so, this rule searches for serializable type with serializable instance fields added or removed since the baseline. Notice that it doesn't take account of fields tagged with NonSerializedAttribute.
  • Avoid changing enumerations Flags status: This rule matches enumeration types that used to be tagged with FlagsAttribute in the baseline, and not anymore. It also matches the opposite, enumeration types that are now tagged with FlagsAttribute, and were not tagged in the baseline. Being tagged with FlagsAttribute is a strong property for an enumeration. Not so much in terms of behavior (only the enum.ToString() method behavior changes when an enumeration is tagged with FlagsAttribute) but in terms of meaning: is the enumeration a range of values or a range of flags?

Also 3 code queries are proposed to let the user browse new public API elements:

Static readonly conversion to const

Kind: Binary-level Break

Languages affected: C#, VB, and F#

API before change:

public static class Foo
{
    public static readonly string Bar = "Value";
}

API after change:

public static class Foo
{
    public const string Bar = "Value";
}

All clients need to be recompiled to target the new change, otherwise a MissingFieldException is thrown.

Related