Generate and review the public API of a .NET library

 
 
  • Gérald Barré

When maintaining a library, one of the easiest ways to introduce a breaking change is to update a public type without noticing its impact on consumers.

You can use the Microsoft.CodeAnalysis.PublicApiAnalyzers analyzer to generate a file with the list of members. However, I don't like it as the members are not in C# syntax. Also, if you don't have the same public API for all target frameworks, you get multiple files that are hard to review. Also, it may not keep all metadata such as attributes, which can be important for consumers.

This post shows how to use Meziantou.Framework.PublicApiGenerator to generate a public API file from DLLs or loaded assemblies.

#What's the public API generator?

Meziantou.Framework.PublicApiGenerator generates compilable C# files that represent the public surface of a .NET assembly. The output is not meant to run. It is meant to be reviewed.

That distinction matters. Instead of inspecting metadata in a decompiler or guessing from a release note, you get a normalized snapshot of the API in familiar C# syntax. A pull request diff immediately shows when a method signature changes, when a nullable annotation is added or removed, or when a consumer-facing attribute such as ObsoleteAttribute appears.

The generator is also useful for multi-targeted packages. It can read multiple assemblies and merge them into a single reviewable output, so framework-specific differences stay visible instead of being hidden in separate build artifacts.

In practice, the generated file becomes a small contract that lives next to your source code. If the file changes, reviewers know the public API changed too, and they can decide whether that change is intentional and whether it should affect versioning.

The generated file looks like this:

C#
// <auto-generated/>
#nullable enable

namespace Meziantou.Framework
{
    public readonly struct RelativeDate : System.IComparable, System.IComparable<Meziantou.Framework.RelativeDate>, System.IEquatable<Meziantou.Framework.RelativeDate>, System.IFormattable
    {
        public RelativeDate(System.DateTime dateTime, System.TimeProvider? timeProvider) { }
        public RelativeDate(System.DateTime dateTime) { }
        public static Meziantou.Framework.RelativeDate Get(System.DateTime dateTime) => throw null;
        public static Meziantou.Framework.RelativeDate Get(System.DateTimeOffset dateTime) => throw null;
        public static Meziantou.Framework.RelativeDate Get(System.DateTime dateTime, System.TimeProvider? timeProvider) => throw null;
        public static Meziantou.Framework.RelativeDate Get(System.DateTimeOffset dateTime, System.TimeProvider? timeProvider) => throw null;
        public override string ToString() => throw null;
        public string ToString(string? format, System.IFormatProvider? formatProvider) => throw null;
        int System.IComparable.CompareTo(object? obj) => throw null;
        public int CompareTo(Meziantou.Framework.RelativeDate other) => throw null;
        public override bool Equals(object? obj) => throw null;
        public bool Equals(Meziantou.Framework.RelativeDate other) => throw null;
        public override int GetHashCode() => throw null;

        // code omitted for brevity
    }
}

#How to generate the public API file

##Method 1: Use the .NET tool to generate an API file

Install the tool:

Shell
dotnet tool install --global Meziantou.Framework.PublicApiGenerator.Tool

Generate the API from a compiled assembly:

Shell
Meziantou.Framework.PublicApiGenerator.Tool \
    --input "net8.0/Meziantou.Framework.RelativeDate.dll" \
    --input "net10.0/Meziantou.Framework.RelativeDate.dll" \
    --output ref/

You can commit it to your repository. In later pull requests, regenerate it and review the diff like any other code change.

##Method 2: Use the MSBuild task to generate an API file

If you want API generation to run automatically during your build, you can use the Meziantou.Framework.PublicApiGenerator.MSBuild package.

Install the package in the project:

Shell
dotnet package add Meziantou.Framework.PublicApiGenerator.MSBuild

Then configure the package in the project file. PublicApiGeneratorOutputPath is required.

XML
<ItemGroup>
    <PackageReference Include="Meziantou.Framework.PublicApiGenerator.MSBuild" Version="x.y.z" PrivateAssets="all" />
</ItemGroup>

<PropertyGroup>
    <PublicApiGeneratorOutputPath>ref/PublicApi.g.cs</PublicApiGeneratorOutputPath>

    <!-- Optional -->
    <PublicApiGeneratorGenerateOnBuild>true</PublicApiGeneratorGenerateOnBuild>
    <PublicApiGeneratorVerifyNoChangeOnBuild>false</PublicApiGeneratorVerifyNoChangeOnBuild>
    <PublicApiGeneratorFileLayout>SingleFile</PublicApiGeneratorFileLayout>
</PropertyGroup>

In CI, you can validate that the generated files are up to date without writing them:

Shell
dotnet build -p:PublicApiGeneratorVerifyNoChangeOnBuild=true

##Method 3: Use the library

You can also use the library, for example to validate API stability from tests. The library support both DLL files and in-memory assemblies, so you can generate the API from a compiled assembly or directly from your source code.

Shell
dotnet package add Meziantou.Framework.PublicApiGenerator
C#
using Meziantou.Framework.PublicApiGenerator;

var options = new PublicApiGeneratorOptions();

var result = PublicApiGenerator.GeneratePublicApi(typeof(MyLibrary.EntryPoint).Assembly, options);

// You can use Snapshot testing to compare with your approved baseline (snapshot, file, or inline expected text)
Snapshot.Validate(result);

This pattern works well with snapshot testing because every intentional API change is explicit in the test diff.

#Multi-targeting

Most libraries target multiple frameworks, and the public API is rarely identical for all of them. Instead of generating one file per target framework, the generator merges all the assemblies into a single output and uses #if directives to describe the differences. So you have a single file to review, and the framework-specific parts are explicit.

You provide one assembly per target framework. The target framework is inferred from the TargetFrameworkAttribute of the assembly, or you can set it explicitly using the tfm=path syntax (e.g., --input netstandard2.0=./bin/Release/netstandard2.0/MyLibrary.dll).

Shell
Meziantou.Framework.PublicApiGenerator.Tool \
    --input ./bin/Release/netstandard2.0/MyLibrary.dll \
    --input ./bin/Release/net10.0/MyLibrary.dll \
    --output ref/

With the MSBuild package, there is nothing to configure: for a multi-targeted project, the generation runs once after all target frameworks are built and merges all output assemblies.

Everything that is common to all target frameworks is emitted once, without any directive. Only the differences are wrapped in #if blocks, using the well-known preprocessor symbols (NET10_0, NET8_0, NETSTANDARD2_0, NETCOREAPP3_1, NET462, …). For instance, if a member only exists for one target framework:

C#
// Target Frameworks: net8.0, netstandard2.0
#nullable enable

public class Sample
{
    public void A() { }
    #if NET8_0
    public void B() { }
    #endif
}

When a member exists everywhere but with a different signature, the generator groups the target frameworks that share the same declaration and emits #if/#elif blocks:

C#
// Target Frameworks: net10.0, net8.0, netstandard2.0
#nullable enable

public class Sample
{
    #if NET10_0
    public short B() => throw null;
    #elif NET8_0
    public long B() => throw null;
    #elif NETSTANDARD2_0
    public int B() => throw null;
    #endif
}

The merge is not limited to members. Types, attributes, and even assembly-level attributes are merged the same way. For example, if a type is obsolete only for the most recent target framework, only the attribute is conditional:

C#
// Target Frameworks: net10.0, net8.0
#nullable enable

#if NET10_0
[System.Obsolete("Use TextWriter.CreateBroadcasting", true)]
#endif
public sealed class TeeTextWriter
{
    public System.Text.Encoding Encoding { get => throw null; }
    public void Flush() { }
}

Note the // Target Frameworks: header comment at the top of the file. It lists the target frameworks that were merged, so a reviewer immediately knows which frameworks the file covers, and adding or removing a target framework is visible in the diff.

#.NET 11 / C# 15 support

The generator reads metadata, so it also handles the new .NET 11 / C# 15 features. There is nothing to configure.

Closed hierarchies are reconstructed from ClosedAttribute / IsClosedTypeAttribute, and the closed modifier is emitted instead of the raw attributes:

C#
public closed class Sample
{
}

Union types are reconstructed from UnionAttribute and the IUnion interface. The generator emits the union declaration with its case types, and hides the compiler-generated members (the case constructors, the Value property, and the IUnion interface implementation), so the diff shows the actual union declaration:

C#
public union Pet(Cat, Dog)
{
}

The new unsafe model is supported too. When an assembly is compiled with the updated memory safety rules (<Features>$(Features);updated-memory-safety-rules</Features>), the unsafe modifier reflects the members the compiler marked as requiring an unsafe context, whatever their signature:

C#
public class Sample
{
    public unsafe void UnsafeMethod() { }
}

#CI validation

You can fail the CI build when the public API file is out of date.

If you use the CLI tool, run it in verification mode:

Shell
Meziantou.Framework.PublicApiGenerator.Tool \
    --input "net8.0/Meziantou.Framework.RelativeDate.dll" \
    --input "net10.0/Meziantou.Framework.RelativeDate.dll" \
    --output ref/ \
    --verify-no-change

If you use the MSBuild package, enable verification during build:

Shell
dotnet build -p:PublicApiGeneratorVerifyNoChangeOnBuild=true

Both options are useful in pull request workflows. Developers regenerate the API file locally when the change is intentional, and CI ensures unintentional API changes are detected before merge.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?