When we maintain a library that's used by others, we want to shield them from breaking changes and use SemVer as a way of indicating breaking changes when they do occur. If our public API changes in a way that is considered a breaking change, wouldn't it be great if we were notified so that we can bump that major version number?

Well there is a solution; a package called Microsoft.DotNet.ApiCompat. It lives in the dotnet/arcade repo which is the shared engineering infrastructure used by the dotnet team, and is a trove of cool interesting utilities.

Checking for Breaking Changes

Ok, so here's a class in our library named ApiCompatExample:

public class MyClass
{
public Task MyMethod()
{
return Task.CompletedTask;
}
}

Now we release a 1.0 and want to ensure we don't break this API without knowing it. We'll add a reference to Microsoft.DotNet.ApiCompat, and tell it about our 1.0 assembly:

<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
<ResolvedMatchingContract Include=".\LastMajorVersionBinary\$(AssemblyName).dll" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.DotNet.ApiCompat" Version="5.0.0-beta.19557.20" PrivateAssets="All" />
</ItemGroup>

</Project>

Where .\LastMajorVersionBinary\$(AssemblyName).dll (ApiCompatExample.dll here) is a checked in copy of our 1.0 assembly.

Microsoft.DotNet.ApiCompat is not on nuget.org, so we'll need to add the following NuGet.config:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<add key=".Net Core Tools" value="https://dotnetfeed.blob.core.windows.net/dotnet-core/index.json" />
</packageSources>
</configuration>

Cool, now let's introduce a change into our API:

public class MyClass
{
public Task MyMethod(CancellationToken ct = default)
{
return Task.CompletedTask;
}
}

And now do a dotnet build:

error : MembersMustExist : Member 'ApiCompatExample.MyClass.MyMethod()' does not exist in the implementation but it does exist in the contract.

Excellent, we now have a build error. This package has caught a common oversight when adding optional parameters, they are source compatible, but not binary compatible. This change should really require a bump in the major version.

We can quickly fix this by changing this into a method overload instead:

public class MyClass
{
public Task MyMethod() => MyMethod(default);

public Task MyMethod(CancellationToken ct)
{
return Task.CompletedTask;
}
}

Now when we build, there are no build errors.

Making Breaking Changes

Now let's say we decide that the previous change was what we wanted, and we decide we want to start committing breaking changes, you might be thinking this package could get in our way.

With the code reverted to the optional CancellationToken parameter, we will now build again with an additional MSBuild parameter set:

> dotnet build /p:BaselineAllAPICompatError=true

Now the build succeeds and we can see it has created a new file for us named ApiCompatBaseline.txt. Here are the contents:

Compat issues with assembly ApiCompatExample:
MembersMustExist : Member 'ApiCompatExample.MyClass.MyMethod()' does not exist in the implementation but it does exist in the contract.
Total Issues: 1

Now we have our breaking changes baselined in this file, so when we do another plain dotnet build the build will succeed provided there are no additional breaking changes from what is recorded in this baseline. We can check this in and it will be a handy record for when we make our major version release notes.

Making a Major Release

Once we have released a 2.0, we just replace .\LastMajorVersionBinary\ApiCompatExample.dll with our 2.0 assembly, delete ApiCompatBaseline.txt, and off we go again.

Further Reading

You can read more about ApiCompat here, including the ApiCompatEnforceOptionalRules setting, which enforces additional checks such as edge case scenarios that can break source compatibility but still be binary compatible, for example changing the name of a parameter.

Wrapping Up

There are other solutions to this problem (I might touch on them in upcoming posts), but hopefully I've demonstrated that this is a simple and elegant solution to keep yourself sticking to SemVer.

While the code in this post is incredibly simple, this tool is being used by the dotnet team's libraries, and I've just discovered that AutoMapper uses this tool too, so check that out for a real-world example.

You can find the code for this post here. Thanks for reading.