Preventing breaking changes in .NET class libraries

Preventing breaking changes in .NET class libraries

Have you ever felt frustrated when updating a NuGet package, only to have your build fail because the new version of the package introduced a breaking change? Or perhaps you're the author of a NuGet package and you're determined to avoid introducing breaking changes? Ever wonder how Microsoft maintains backwards compatibility in ASP.NET Core for years? There's of course a lot of design involved, but one tool they use is their Microsoft.CodeAnalysis.PublicApiAnalyzers NuGet package. As the name suggests, it's a set of Roslyn analyzers to keep track of your public API. It's used by the .NET team, the Azure SDK team, various other Microsoft projects, and numerous open-source libraries such as Dapper and Polly.

In this post, I will guide you on designing .NET class libraries to prevent breaking changes and demonstrate how to leverage the Microsoft.CodeAnalysis.PublicApiAnalyzers package to enforce these principles.

Understanding public APIs and breaking changes

Before we dive into the details, it's essential to clarify two key terms: "public API" and "breaking change".

Public API: Simply put, a public API refers to the set of types and members you make accessible to your consumers. Only symbols marked with the public access modifier fall into this category. Anything marked as internal, protected, or private is outside the realm of your public API. Throughout this post, our focus remains squarely on public APIs.

Public API change: This pertains to any alterations in the structure or signature of public types and members. Examples include:

  • Adding or removing a public type or member.

  • Modifying a method by adding or removing parameters.

  • Changing the datatype of an existing method parameter.

Given these definitions, a breaking change is a modification in the public API that potentially mandates adjustments in consumer code. For instance, modifying a method's signature by introducing a new parameter would require changes wherever this method is invoked. A more appropriate strategy to prevent breaking changes in such scenarios would involve creating an overloaded method rather than altering the original one. Similarly, removing types and members usually results in breaking changes.

Introducing a new type doesn't qualify as a breaking change, since it doesn't directly disrupt your consumers' existing code. Nonetheless, it is still a variation in the public API. Be careful to communicate such changes transparently. One way to do this is by using the [Obsolete] attribute to label the older version as outdated, simultaneously guiding consumers towards the newer implementation.

In order to avoid breaking changes, you must be familiar with your library's public API. Try to minimize the types and members made accessible. Many developers often tend to expose too many types and members, and that's a common source of breaking changes. Leveraging the internal keyword judiciously and exposing only indispensable members is a wise approach.

How to design a good public API

At Workleap, I'm a member of the Internal Developer Platform (IDP) team. Among our many tasks, one important job is making .NET class libraries that other teams in our company rely on. A bunch of these libraries are open source. We aim to maintain a smooth experience for other teams, so we're cautious about avoiding breaking changes.

So, how do we approach our public API design? Our methodologies draw from various resources:

Repeated practice refines our proficiency, much like exercising a muscle. Listening to users' feedback is crucial, especially early on.

Keeping track of public API changes with Microsoft.CodeAnalysis.PublicApiAnalyzers

The Microsoft.CodeAnalysis.PublicApiAnalyzers package is a Roslyn analyzer that enforces you to declare your public APIs in two specific text files. If you change something, the analyzer will notice and tell you. This helps you and anyone reviewing your code spot changes quickly. Here's how you set it up:

Add the package to your project:

<PackageReference Include="Microsoft.CodeAnalysis.PublicApiAnalyzers" Version="3.3.4">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

Create these two files:

  • PublicAPI.Shipped.txt

  • PublicAPI.Unshipped.txt

If you're using nullable reference types, then add the following line at the top of each PublicAPI.*.txt file:

#nullable enable

In PublicAPI.Shipped.txt, list all the public APIs you've given out. In PublicAPI.Unshipped.txt, list any preview or soon-to-change APIs. For example, if an API will soon be removed (marked with [Obsolete]), add it here so you remember to take it out later.

For an example of a real-world PublicAPI.Shipped.txt file, see this one from Workleap's machine-to-machine authentication library.

Consider the following representative C# public class:

namespace Demo;

public class MyClass
{
    public Task DoSomethingAsync(Uri uri, CancellationToken cancellationToken)
    {
        return Task.CompletedTask;
    }
}

Analyzer warnings

Upon analysis, one can expect warnings indicating undeclared symbols for MyClass, its constructor, and the method DoSomethingAsync. By invoking the appropriate Roslyn analyzer fix, the resultant update to the PublicAPI.Unshipped.txt would be:

#nullable enable
Demo.MyClass
Demo.MyClass.DoSomethingAsync(System.Uri! uri, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!
Demo.MyClass.MyClass() -> void

It's the developer's responsibility to transition the contents from PublicAPI.Unshipped.txt to PublicAPI.Shipped.txt when marking an API as shipped. It should be observed that these files only contain the signature of the public types and members. Furthermore, the analyzer doesn't care about the order of the types and members in the text files.

Maintaining PublicAPI.Unshipped.txt and PublicAPI.Shipped.txt can be tedious. In the context of Visual Studio, the Roslyn analyzer fix can be applied at the project level. It's less convenient in JetBrains Rider, where the fix must be applied individually. An open Rider issue highlights this limitation. The workaround is to execute the following command at the project root:

dotnet format analyzers --diagnostics=RS0016

This command will update the PublicAPI.Unshipped.txt file in the project with any undeclared public APIs.

Conclusion

In the landscape of .NET library development, maintaining consistency and preventing breaking changes is recommended. By leveraging analyzers like Microsoft.CodeAnalysis.PublicApiAnalyzers and adhering to best practices, developers can foster trust and ensure backward compatibility.

Additional resources: