Advanced MSBuild: multi-API-version compatibility without code duplication

This post is part of the Advanced MSBuild series.

This post will show an approach to managing code that needs to be compatible with multiple different versions of an API.

The final result can be checked out on my github.

When implementing functionality that uses a 3rd party API and needs to be compatible to multiple versions of that API, one standard approach is to define an abstraction over all API versions and then to write an adapter for each version.
This will work of course.
The downside is the volume of boilerplate adapter code.
Especially for APIs that receive or return deeply nested data structures, the mapping code alone can be hundreds of lines for each adapter.
And all of this mapping code should be unit-tested as well, which makes for even more duplication on the testing side.
Maintenance of such duplicated code is cumbersome and error-prone.

In my case, between four and six versions of the API needed to be supported at any given time.
The specific supported versions changed regularly in a rolling fashion.
There were small, but breaking changes between some API versions.

Under these circumstances, especially when differences between versions are small, we can employ a more direct approach.

Reuse the same code for source-compatible API versions

What we really want from the compiler is to ensure that runtime method binding of our assembly into the API assembly will work.
That is why we compile our code against a specific version of the API assembly.

When we need to ensure compatibility of the same code to multiple different versions of that API, all we really want is to compile our code against multiple different versions of the API assembly.
So, let’s do just that.

Take this sample API, that has one breaking change between version 1.0 and 1.1, but no changes between 1.1 and 1.2:

// API version 1.0
public interface IAwesomeQuery
{
    IEnumerable<int> GetNumbers();
}

// API version 1.1
public interface IAwesomeQuery
{
    IEnumerable<int> GetIntegers();
}

// API version 1.2
public interface IAwesomeQuery
{
    IEnumerable<int> GetIntegers();
}

Should the API have breaking changes between minor versions?
Maybe not, but the world ain’t perfect.

Let’s first create the code for an API consumer of versions 1.1 and 1.2, which happen to be source-compatible.

public class ApiConsumer
{
    readonly IAwesomeQuery _query;

    public ApiConsumer(IAwesomeQuery query)
    {
        _query = query;
    }

    public int GetSum() => _query.GetIntegers().Sum();
}

Source-compatibility means the same code will compile against both versions.
Because we want to easily check if our code is compatible with all currently supported versions, we want to be able to easily compile it against all supported versions at once
We thus create two projects that use the same source code.
(if we were content with compiling the code against different API versions one after the other, we could use the same project and some MSBuild flag to define against which API version to build).

Since we are already using the same source code, it makes sense to keep the projects as consistent as possible in general.
We extract all project settings into a common props file and import that into each project.
The only difference between the two projects now is the reference to the API project.
Assuming future API versions will follow the same project naming convention, we can even extract the project reference and just leave the declaration “V110” and “V120” respectively in the project file.
The project file for V110 could look like this:

<Project Sdk="Microsoft.NET.Sdk">
  
  <PropertyGroup>
    <__ApiVersion>V110</__ApiVersion>
  </PropertyGroup>

  <Import Project="../ApiConsumer.Common/ApiConsumer.props" />

</Project>

with the props file containing everything else:

<Project>
  <PropertyGroup>
    <TargetFramework>net6.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="$(MSBuildThisFileDirectory)**/*.cs" LinkBase="Code" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Api.$(__ApiVersion)\Api.$(__ApiVersion).csproj" />
  </ItemGroup>

</Project>

This can of course be done with PackageReferences in the same way.

So far, so good.

Use preprocessor directives for non-source-compatible API versions

We can now easily add another consumer project for API V100 by copying one of the existing consumer project files.
We only need to change the version declaration to “V100”.

Compilation for the V100 consumer will fail at first because of the breaking API change between 1.0 and 1.1.
We can no longer use the same code for all versions.

Visual Studio is very helpful here.
It lets us see in which projects the method we want to use is available and in which it is not.

VS shows you the available methods for each API version

We may not be able to use the exact same code, but we could use almost the same code.
As long as it is “almost” the same code, we do not need to go down the adapter road.
We can instead use conditional preprocessor directives to use some pieces of code for V100 and some other pieces for V110 and V120.
We will define a constant V110_AND_GREATER in our consumer projects to use in the preprocessor directives (following the convention for predefined TFM constants like NET5_AND_GREATER).

Constants can be defined in the project file.
We take care to only amend the semicolon-separated constants list (in contrast to resetting the entire property).

<PropertyGroup>
  <DefineConstants>$(DefineConstants);V110_OR_GREATER</DefineConstants>
</PropertyGroup>

In the common code we can now use a preprocessor conditional to call GetNumbers when using API V100 and GetIntegers when using API V110 or greater.

    public int GetSum() => _query
#if V110_OR_GREATER
        .GetIntegers()
#else
        .GetNumbers()
#endif
        .Sum();

Note how well Visual Studio supports this development workflow.
We can easily change the project from which we view the common code file.

VS lets you easily switch between different views of the same code

Had we employed the usual adapter technique, to see how our API usage differs between API versions, we would have to diff the adapter code.
With the preprocessor approach we can see everything in-line.

Yes, it really is upside only

The preprocessor approach has only advantages for these “almost the same code” use-cases:

  • it is more maintainable because there is no code duplication
  • it is more readable because we immediately see the differences between the APIs
  • the development workflow is better because of Visual Studio’s good support for preprocessor directives

Another advantage that can be relevant for e. g. plugins is that we only create a single assembly per version, whereas with an adapter solution we would have at least two.

All of the above make this technique superior to any adapter-based approach as long as the differences between API versions are small compared to the amount of API surface used.

As soon as preprocessor directives become too numerous even after appropriate refactoring, consider combining this technique with others.
It may also be worth trying to group versions according to similarity and then use one set of source code files for each group.
The possibilities are endless.

Again, check the full solution on my github.