Preventing breaking changes in .NET class libraries
Tags: programming, dotnet, csharp
Published: 2023-09-26 12:48:54

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](https://www.nuget.org/packages/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](https://github.com/DapperLib/Dapper) and [Polly](https://github.com/App-vNext/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](https://workleap.com/), 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](https://github.com/gsoft-inc/?q=&type=public&language=c%23). 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:

- [Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries](https://amazon.com/dp/0135896460) - a book endorsed by Microsoft.
- [Framework Design Guidelines - Digest](https://github.com/dotnet/runtime/blob/v7.0.11/docs/coding-guidelines/framework-design-guidelines-digest.md) - hosted on GitHub.
- [.NET runtime and extensions libraries](https://github.com/dotnet/runtime/tree/main/src/libraries) - a look into .NET's source code.
- [Options pattern guidance for .NET library authors](https://learn.microsoft.com/en-us/dotnet/core/extensions/options-library-authors) - Microsoft's documentation.
- [.NET library guidance](https://learn.microsoft.com/en-us/dotnet/standard/library-guidance/) - Microsoft's documentation.

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](https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) 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:

```xml
<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:

```txt
#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](https://github.com/gsoft-inc/wl-authentication-clientcredentialsgrant/blob/1.1.1/src/Workleap.Extensions.Http.Authentication.ClientCredentialsGrant/PublicAPI.Shipped.txt).

Consider the following representative C# public class:

```csharp
namespace Demo;

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

![Public API analyzer warnings](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/m81tnflny6sdpmdn4dln.png)

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:

```txt
#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](https://youtrack.jetbrains.com/issue/RIDER-18372/Roslyn-quick-fix-does-not-provide-an-option-to-fix-the-issue-in-file-solution). The workaround is to execute the following command at the project root:

```bash
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:

- [Microsoft.CodeAnalysis.PublicApiAnalyzers documentation](https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/PublicApiAnalyzers.Help.md).
- [Microsoft.CodeAnalysis.PublicApiAnalyzers list of analysis rules](https://github.com/dotnet/roslyn-analyzers/blob/main/src/PublicApiAnalyzers/Microsoft.CodeAnalysis.PublicApiAnalyzers.md).