blog post image
Andrew Lock avatar

Andrew Lock

~6 min read

Generating strongly-typed IDs at build-time with Roslyn

Using strongly-typed entity IDs to avoid primitive obsession - Part 5

This is another post in my series on strongly-typed IDs. In the first and second posts, I looked at the reasons for using strongly-typed IDs, and how to add converters to interface nicely with ASP.NET Core. In part 3 and part 4 I looked at ways of using strongly-typed IDs with EF Core. This post deals with the most common argument against strongly-typed IDs - the sheer amount of boilerplate code required for each strongly-typed ID.

In this post I introduce the StronglyTypedId NuGet package I've created. It uses build-time code generation to create all the strongly-typed ID boilerplate code for you automatically, simply by decorating your type with an attribute:

[StronglyTypedId]
partial struct MyTypeId { }

When you save this file, a separate partial struct is created that contains all the boilerplate code I included in the snippet in part 2 of this series. No need for snippets, full IntelliSense, but all the benefits of strongly-typed IDs!

Generating a strongly-typed ID using the StronglyTypedId packages

Getting started

If you want to give the strongly-typed ID code generators a try in your application, you need to install the StronglyTypedId package, and also add the .NET Core code generation tool dotnet-codegen, as shown below. If you want to generate a JsonConverter for your ID and aren't already referencing Newtonsoft.Json (you probably are!) then you'll need to add an extra reference.

To add these packages, edit your csproj file so that it looks something like the following. The example below is for a .NET core Console app:

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.2</TargetFramework>
  </PropertyGroup>
  
  <ItemGroup>
    <PackageReference Include="Newtonsoft.Json" Version="12.0.2" />
    <PackageReference Include="StronglyTypedId" Version="0.1.2" />
    <DotNetCliToolReference Include="dotnet-codegen" Version="0.5.13" />
  </ItemGroup>
</Project>

Note that DotNetCliToolReference tools are being phased out in .NET Core 3, so I suspect this process will be changing soon!

After these tools are restored with dotnet restore, you'll have the [StronglyTypedId] attribute available in the global namespace. You can add it to any struct, and the code shown in previous posts is automatically generated for you.

[StronglyTypedId] // Add this attribute to auto-generate the rest of the type
public partial struct FooId { }

Note that the class must be marked partial, due to the way the boilerplate is generated (shown later).

That's pretty much all there is to it! After adding the attribute and saving the file, Visual Studio will automatically generate the backing code. JetBrains Rider and VS Code aren't quite as nice, as you seem to need to do an explicit build before the IntelliSense catches up, but I don't think that's a very big deal.

One of the really nice things about this approach is that the generated class really is exactly the same as if it was written by hand. There's no extra runtime dependencies in your build output, and even the [StronglyTypedId] attribute is removed as part of the build!

Output dlls showing that there's nothing specific to the code-generation

Extra configuration options

As a little bonus, I added some extra configuration options to the [StronglyTypedId] attribute. By passing in additional arguments to the attribute constructor, you can control whether a custom JsonConverter is generated, and even change the backing Type of the strongly-typed ID from a Guid to an int or a string:

// don't generate the JsonConverter, and use an int as the backing value
[StronglyTypedId(generateJsonConverter: false, backingType: StronglyTypedIdBackingType.Int)] 
partial struct NoJsonIntId { }

The code generated by the above attribute looks similar to the following:

[TypeConverter(typeof(NoJsonIntIdTypeConverter))]
readonly partial struct NoJsonIntId : IComparable<NoJsonIntId>, .IEquatable<NoJsonIntId>
{
    public int Value { get; }

    public NoJsonIntId(int value)
    {
        Value = value;
    }

    public static readonly NoJsonIntId Empty = new NoJsonIntId(0);
    public bool Equals(NoJsonIntId other) => this.Value.Equals(other.Value);
    public int CompareTo(NoJsonIntId other) => Value.CompareTo(other.Value);
    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj)){ return false; }
        return obj is NoJsonIntId other && Equals(other);
    }

    public override int GetHashCode() => Value.GetHashCode();
    public override string ToString() => Value.ToString();
    public static bool operator ==(NoJsonIntId a, NoJsonIntId b) => a.CompareTo(b) == 0;
    public static bool operator !=(NoJsonIntId a, NoJsonIntId b) => !(a == b);
    
    class NoJsonIntIdTypeConverter : TypeConverter
    {
        public override bool CanConvertFrom(ITypeDescriptorContext context, System.Type sourceType)
        {
            return sourceType == typeof(int) || base.CanConvertFrom(context, sourceType);
        }

        public override object ConvertFrom(ITypeDescriptorContext context, CultureInfo culture, object value)
        {
            if (value is int intValue)
            {
                return new NoJsonIntId(intValue);
            }

            return base.ConvertFrom(context, culture, value);
        }
    }
}

By removing the JsonConverter, you no longer need to take a dependency on Newtonsoft.Json, but obviously JSON serialization is no longer handled directly by the type.

I feel like these configuration options will cover most cases, but if you have other ideas, feel free to raise an issue on the GitHub repository. I think the main thing missing is support for class based IDs, as well as struct based.

How it works: build-time code generation with Roslyn

I'm not going to go into depth about how it all works in this post, but I'll provide an overview, and likely expand on it in later posts.

The StronglyTypedId library relies on the work in AArnott's CodeGeneration.Roslyn library. This provides all the pieces you need to create your own code generators and for them to work at build/design time so you get full IntelliSense. The GitHub README for CodeGeneration.Roslyn describes how to create a code generator in your own project, including all the dependencies you need and requirements on target frameworks and such.

Getting started with the example wasn't too difficult, but you have to make sure to follow along carefully - if you miss a step it can be difficult to understand what's going on when things don't work. I had the most difficulty wrapping up the library in an appropriate NuGet package, as you have to be careful about the format of the NuGet. For that, I followed the example of a very similar project, RecordGenerator.

RecordGenerator works in a very similar way to the StronglyTypedId package, but with even more functionality. It allows you to create immutable Record types, along with appropriate builders, deconstructors, and other helpers

Behind the scenes, the dotnet-codgen tool and the CodeGeneration.Roslyn library add extra MSBuild targets that run generators as part of your app's build. These generators can only add files to the build, and can't change existing code. You can find them in the obj folder for your project.

That's not a problem for StronglyTypedId as long as you mark the type as partial - the generated code is also a partial (in the same namespace) which keeps the compiler happy. You can actually navigate directly to the generated code by pressing F12 on your type:

// ------------------------------------------------------------------------------
// <auto-generated>
//     This code was generated by a tool.
//
//     Changes to this file may cause incorrect behavior and will be lost if
//     the code is regenerated.
// </auto-generated>
// ------------------------------------------------------------------------------

using System;

namespace ConsoleApp1
{
    [System.ComponentModel.TypeConverter(typeof(FooIdTypeConverter))]
    [Newtonsoft.Json.JsonConverter(typeof(FooIdJsonConverter))]
    readonly partial struct FooId : System.IComparable<FooId>, System.IEquatable<FooId>
    {
        public System.Guid Value
        {
            get;
        }

        public FooId(System.Guid value)
        {
            Value = value;
        }

        /// ...
    }
    
}

That's pretty much all I want to cover in this post, other than a quick shout out to Kirill Osenkov for the excellent https://roslynquoter.azurewebsites.net/ - this site lets you paste in C#, and it spits out the Roslyn syntax for you, super useful!

RoslynQuoter website that generates a Roslyn syntax tree given C#

If you've been holding off on strongly-typed IDs because of the boilerplate code, why not give the attributes a try and see what you think!

Summary

In this post I introduced the StronglyTypedId package. This uses a Roslyn-powered build-time code generation to create the boilerplate required for the strongly-typed IDs I've been describing in this series. By referencing the package (and the required dependencies), you can generate strongly-typed IDs by decorating your struct with a [StronglyTypedId] attribute. You can also configure the code-generation somewhat, by changing the Type of the backing-value, and choosing whether or not to generate a custom JsonConverter.

Andrew Lock | .Net Escapades
Want an email when
there's new posts?