Change rules for compatibility
Throughout its history, .NET has attempted to maintain a high level of compatibility from version to version and across implementations of .NET. Although .NET 5 (and .NET Core) and later versions can be considered as a new technology compared to .NET Framework, two major factors limit the ability of this implementation of .NET to diverge from .NET Framework:
A large number of developers either originally developed or continue to develop .NET Framework applications. They expect consistent behavior across .NET implementations.
.NET Standard library projects allow developers to create libraries that target common APIs shared by .NET Framework and .NET 5 (and .NET Core) and later versions. Developers expect that a library used in a .NET 5 application should behave identically to the same library used in a .NET Framework application.
Along with compatibility across .NET implementations, developers expect a high level of compatibility across versions of a given implementation of .NET. In particular, code written for an earlier version of .NET Core should run seamlessly on .NET 5 or a later version. In fact, many developers expect that the new APIs found in newly released versions of .NET should also be compatible with the pre-release versions in which those APIs were introduced.
This article outlines changes that affect compatibility and the way in which the .NET team evaluates each type of change. Understanding how the .NET team approaches possible breaking changes is particularly helpful for developers who open pull requests that modify the behavior of existing .NET APIs.
The following sections describe the categories of changes made to .NET APIs and their impact on application compatibility. Changes are either allowed (✔️), disallowed (❌), or require judgment and an evaluation of how predictable, obvious, and consistent the previous behavior was (❓).
- In addition to serving as a guide to how changes to .NET libraries are evaluated, library developers can also use these criteria to evaluate changes to their libraries that target multiple .NET implementations and versions.
- For information about the compatibility categories, for example, forward and backwards compatibility, see How code changes can affect compatibility.
Modifications to the public contract
Changes in this category modify the public surface area of a type. Most of the changes in this category are disallowed since they violate backwards compatibility (the ability of an application that was developed with a previous version of an API to execute without recompilation on a later version).
✔️ ALLOWED: Removing an interface implementation from a type when the interface is already implemented by a base type
❓ REQUIRES JUDGMENT: Adding a new interface implementation to a type
This is an acceptable change because it does not adversely affect existing clients. Any changes to the type must work within the boundaries of acceptable changes defined here for the new implementation to remain acceptable. Extreme caution is necessary when adding interfaces that directly affect the ability of a designer or serializer to generate code or data that cannot be consumed down-level. An example is the ISerializable interface.
❓ REQUIRES JUDGMENT: Introducing a new base class
A type can be introduced into a hierarchy between two existing types if it doesn't introduce any new abstract members or change the semantics or behavior of existing types. For example, in .NET Framework 2.0, the DbConnection class became a new base class for SqlConnection, which had previously derived directly from Component.
✔️ ALLOWED: Moving a type from one assembly to another
The old assembly must be marked with the TypeForwardedToAttribute that points to the new assembly.
✔️ ALLOWED: Changing a struct type to a
readonly structtype to a
structtype is not allowed.
✔️ ALLOWED: Expanding the visibility of a type
❌ DISALLOWED: Changing the namespace or name of a type
❌ DISALLOWED: Renaming or removing a public type
This breaks all code that uses the renamed or removed type.
❌ DISALLOWED: Changing the underlying type of an enumeration
This is a compile-time and behavioral breaking change as well as a binary breaking change that can make attribute arguments unparsable.
❌ DISALLOWED: Sealing a type that was previously unsealed
❌ DISALLOWED: Adding an interface to the set of base types of an interface
If an interface implements an interface that it previously did not implement, all types that implemented the original version of the interface are broken.
❓ REQUIRES JUDGMENT: Removing a class from the set of base classes or an interface from the set of implemented interfaces
There is one exception to the rule for interface removal: you can add the implementation of an interface that derives from the removed interface. For example, you can remove IDisposable if the type or interface now implements IComponent, which implements IDisposable.
❌ DISALLOWED: Changing a
readonly structtype to a struct type
The change of a
structtype to a
readonly structtype is allowed, however.
❌ DISALLOWED: Changing a struct type to a
ref structtype, and vice versa
❌ DISALLOWED: Reducing the visibility of a type
However, increasing the visibility of a type is allowed.
✔️ ALLOWED: Expanding the visibility of a member that is not virtual
✔️ ALLOWED: Adding an abstract member to a public type that has no accessible (public or protected) constructors, or the type is sealed
However, adding an abstract member to a type that has accessible (public or protected) constructors and is not
sealedis not allowed.
✔️ ALLOWED: Moving a member into a class higher in the hierarchy than the type from which it was removed
✔️ ALLOWED: Adding or removing an override
Introducing an override might cause previous consumers to skip over the override when calling base.
✔️ ALLOWED: Adding a constructor to a class, along with a parameterless constructor if the class previously had no constructors
However, adding a constructor to a class that previously had no constructors without adding the parameterless constructor is not allowed.
✔️ ALLOWED: Changing from a
ref readonlyto a
refreturn value (except for virtual methods or interfaces)
✔️ ALLOWED: Removing readonly from a field, unless the static type of the field is a mutable value type
✔️ ALLOWED: Calling a new event that wasn't previously defined
❓ REQUIRES JUDGMENT: Adding a new instance field to a type
This change impacts serialization.
❌ DISALLOWED: Renaming or removing a public member or parameter
This breaks all code that uses the renamed or removed member, or parameter.
This includes removing or renaming a getter or setter from a property, as well as renaming or removing enumeration members.
❌ DISALLOWED: Adding a member to an interface
If you provide an implementation, adding a new member to an existing interface won't necessarily result in compile failures in downstream assemblies. However, not all languages support default interface members (DIMs). Also, in some scenarios, the runtime can't decide which default interface member to invoke. For these reasons, adding a member to an existing interface is considered a breaking change.
❌ DISALLOWED: Changing the value of a public constant or enumeration member
❌ DISALLOWED: Changing the type of a property, field, parameter, or return value
❌ DISALLOWED: Adding, removing, or changing the order of parameters
❌ DISALLOWED: Renaming a parameter (including changing its case)
This is considered breaking for two reasons:
❌ DISALLOWED: Changing from a
refreturn value to a
ref readonlyreturn value
❌️ DISALLOWED: Changing from a
ref readonlyto a
refreturn value on a virtual method or interface
❌ DISALLOWED: Adding or removing abstract from a member
❌ DISALLOWED: Removing the virtual keyword from a member
❌ DISALLOWED: Adding the virtual keyword to a member
While this often is not a breaking change because the C# compiler tends to emit callvirt Intermediate Language (IL) instructions to call non-virtual methods (
callvirtperforms a null check, while a normal call doesn't), this behavior is not invariable for several reasons:
C# is not the only language that .NET targets.
The C# compiler increasingly tries to optimize
callvirtto a normal call whenever the target method is non-virtual and is probably not null (such as a method accessed through the ?. null propagation operator).
Making a method virtual means that the consumer code would often end up calling it non-virtually.
❌ DISALLOWED: Making a virtual member abstract
❌ DISALLOWED: Adding the sealed keyword to an interface member
sealedto a default interface member will make it non-virtual, preventing a derived type's implementation of that member from being called.
❌ DISALLOWED: Adding an abstract member to a public type that has accessible (public or protected) constructors and that is not sealed
❌ DISALLOWED: Adding or removing the static keyword from a member
❌ DISALLOWED: Adding an overload that precludes an existing overload and defines a different behavior
This breaks existing clients that were bound to the previous overload. For example, if a class has a single version of a method that accepts a UInt32, an existing consumer will successfully bind to that overload when passing a Int32 value. However, if you add an overload that accepts an Int32, when recompiling or using late-binding, the compiler now binds to the new overload. If different behavior results, this is a breaking change.
❌ DISALLOWED: Adding a constructor to a class that previously had no constructor without adding the parameterless constructor
❌️ DISALLOWED: Adding readonly to a field
❌ DISALLOWED: Reducing the visibility of a member
This includes reducing the visibility of a protected member when there are accessible (
protected) constructors and the type is not sealed. If this is not the case, reducing the visibility of a protected member is allowed.
Increasing the visibility of a member is allowed.
❌ DISALLOWED: Changing the type of a member
❌ DISALLOWED: Adding an instance field to a struct that has no nonpublic fields
If a struct has only public fields or has no fields at all, callers can declare locals of that struct type without calling the struct's constructor or first initializing the local to
default(T), so long as all public fields are set on the struct before first use. Adding any new fields - public or nonpublic - to such a struct is a source breaking change for these callers, as the compiler will now require the additional fields to be initialized.
Additionally, adding any new fields - public or nonpublic - to a struct with no fields or only public fields is a binary breaking change to callers that have applied
[SkipLocalsInit]to their code. Since the compiler wasn't aware of these fields at compile time, it could emit IL that doesn't fully initialize the struct, leading to the struct being created from uninitialized stack data.
If a struct has any nonpublic fields, the compiler already enforces initialization via the constructor or
default(T), and adding new instance fields is not a breaking change.
❌ DISALLOWED: Firing an existing event when it was never fired before
✔️ ALLOWED: Making an assembly portable when the same platforms are still supported
❌ DISALLOWED: Changing the name of an assembly
❌ DISALLOWED: Changing the public key of an assembly
Properties, fields, parameters, and return values
✔️ ALLOWED: Changing the value of a property, field, return value, or out parameter to a more derived type
✔️ ALLOWED: Increasing the range of accepted values for a property or parameter if the member is not virtual
While the range of values that can be passed to the method or are returned by the member can expand, the parameter or member type cannot. For example, while the values passed to a method can expand from 0-124 to 0-255, the parameter type cannot change from Byte to Int32.
❌ DISALLOWED: Increasing the range of accepted values for a property or parameter if the member is virtual
This change breaks existing overridden members, which will not function correctly for the extended range of values.
❌ DISALLOWED: Decreasing the range of accepted values for a property or parameter
❌ DISALLOWED: Increasing the range of returned values for a property, field, return value, or out parameter
❌ DISALLOWED: Changing the returned values for a property, field, method return value, or out parameter
❌ DISALLOWED: Changing the default value of a property, field, or parameter
Changing or removing a parameter default value is not a binary break. Removing a parameter default value is a source break, and changing a parameter default value could result in a behavioral break after recompilation.
For this reason, removing parameter default values is acceptable in the specific case of "moving" those default values to a new method overload to eliminate ambiguity. For example, consider an existing method
MyMethod(int a = 1). If you introduce an overload of
MyMethodwith two optional parameters
b, you can preserve compatibility by moving the default value of
ato the new overload. Now the two overloads are
MyMethod(int a = 1, int b = 2). This pattern allows
❌ DISALLOWED: Changing the precision of a numeric return value
❓ REQUIRES JUDGMENT: A change in the parsing of input and throwing new exceptions (even if parsing behavior is not specified in the documentation
✔️ ALLOWED: Throwing a more derived exception than an existing exception
Because the new exception is a subclass of an existing exception, previous exception handling code continues to handle the exception. For example, in .NET Framework 4, culture creation and retrieval methods began to throw a CultureNotFoundException instead of an ArgumentException if the culture could not be found. Because CultureNotFoundException derives from ArgumentException, this is an acceptable change.
✔️ ALLOWED: Throwing an exception that is considered unrecoverable
Unrecoverable exceptions should not be caught but instead should be handled by a high-level catch-all handler. Therefore, users are not expected to have code that catches these explicit exceptions. The unrecoverable exceptions are:
✔️ ALLOWED: Throwing a new exception in a new code path
The exception must apply only to a new code-path that's executed with new parameter values or state and that can't be executed by existing code that targets the previous version.
✔️ ALLOWED: Removing an exception to enable more robust behavior or new scenarios
For example, a
Dividemethod that previously only handled positive values and threw an ArgumentOutOfRangeException otherwise can be changed to support both negative and positive values without throwing an exception.
✔️ ALLOWED: Changing the text of an error message
Developers should not rely on the text of error messages, which also change based on the user's culture.
❌ DISALLOWED: Throwing an exception in any other case not listed above
❌ DISALLOWED: Removing an exception in any other case not listed above
✔️ ALLOWED: Changing the value of an attribute that is not observable
❌ DISALLOWED: Changing the value of an attribute that is observable
❓ REQUIRES JUDGMENT: Removing an attribute
In most cases, removing an attribute (such as NonSerializedAttribute) is a breaking change.
✔️ ALLOWED: Supporting an operation on a platform that was previously not supported
❌ DISALLOWED: Not supporting or now requiring a specific service pack for an operation that was previously supported on a platform
Internal implementation changes
❓ REQUIRES JUDGMENT: Changing the surface area of an internal type
Such changes are generally allowed, although they break private reflection. In some cases, where popular third-party libraries or a large number of developers depend on the internal APIs, such changes may not be allowed.
❓ REQUIRES JUDGMENT: Changing the internal implementation of a member
These changes are generally allowed, although they break private reflection. In some cases, where customer code frequently depends on private reflection or where the change introduces unintended side effects, these changes may not be allowed.
✔️ ALLOWED: Improving the performance of an operation
The ability to modify the performance of an operation is essential, but such changes can break code that relies upon the current speed of an operation. This is particularly true of code that depends on the timing of asynchronous operations. The performance change should have no effect on other behavior of the API in question; otherwise, the change will be breaking.
✔️ ALLOWED: Indirectly (and often adversely) changing the performance of an operation
If the change in question is not categorized as breaking for some other reason, this is acceptable. Often, actions need to be taken that may include extra operations or that add new functionality. This will almost always affect performance but may be essential to make the API in question function as expected.
❌ DISALLOWED: Changing a synchronous API to asynchronous (and vice versa)
✔️ ALLOWED: Adding params to a parameter
❌ DISALLOWED: Adding the checked keyword to a code block
This change may cause code that previously executed to throw an OverflowException and is unacceptable.
❌ DISALLOWED: Removing params from a parameter
❌ DISALLOWED: Changing the order in which events are fired
Developers can reasonably expect events to fire in the same order, and developer code frequently depends on the order in which events are fired.
❌ DISALLOWED: Removing the raising of an event on a given action
❌ DISALLOWED: Changing the number of times given events are called
❌ DISALLOWED: Adding the FlagsAttribute to an enumeration type