blog post image
Andrew Lock avatar

Andrew Lock

~11 min read

Rebuilding StronglyTypedId as a source generator - 1.0.0-beta release

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

A couple of years ago wrote a series about using strongly typed IDs to avoid a whole class of bugs in C# applications. In this post I describe how (and why) I completely rewrote the StronglyTypedId NuGet package to use source generators.

Background

If you don't know what strongly typed IDs are about, I suggest reading the previous posts in this series. In summary, strongly-typed IDs help avoid a class of bugs introduced by using primitive types for entity identifiers. For example, imagine you have a method signature like the following:

public Order GetOrderForUser(Guid orderId, Guid userId);

Can you spot the bug in the following method call?

public Order GetOrder(Guid orderId, Guid userId)
{
    return _service.GetOrderForUser(userId, orderId);
}

The call above accidentally inverts the order of orderId and userId when calling the method. Unfortunately, the type system doesn't help us here because both IDs are using the same type, Guid.

If you want to read more about primitive obsession in C#, I can suggest these resources by Jimmy Bogard, Mark Seemann, Steve Smith, and Vladimir Khorikov, as well as Martin Fowler's Refactoring book

Strongly Typed IDs allow you to avoid these types of bugs entirely, by using different types for the entity IDs, and using the type system to best effect. This is something that's easy to achieve in some languages (e.g. F#), but is a bit of a mess in C#. For example, the following shows what you might need for a simple Guid-backed ID:

public readonly struct OrderId : IComparable<OrderId>, IEquatable<OrderId>
{
    public Guid Value { get; }

    public OrderId(Guid value)
    {
        Value = value;
    }

    public static OrderId New() => new OrderId(Guid.NewGuid());

    public bool Equals(OrderId other) => this.Value.Equals(other.Value);
    public int CompareTo(OrderId other) => Value.CompareTo(other.Value);

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj)) return false;
        return obj is OrderId other && Equals(other);
    }

    public override int GetHashCode() => Value.GetHashCode();
    public override string ToString() => Value.ToString();

    public static bool operator ==(OrderId a, OrderId b) => a.CompareTo(b) == 0;
    public static bool operator !=(OrderId a, OrderId b) => !(a == b);
}

The StronglyTypedId NuGet package massively simplifies the amount of code you need to write to the following:

using StronglyTypedIds;

[StronglyTypedId]
public partial struct OrderId { }

On top of that, the StronglyTypedId package uses build-time code generation to auto generate the additional code whenever you save a file. No need for snippets, full IntelliSense, but all the benefits of strongly-typed IDs!

Generating a strongly-typed ID using the StronglyTypedId packages

Thomas Levesque describes an alternative approach to solving the same problem by using record types in this excellent series.

So that's the background. Over the last couple of weeks, I completely rewrote the StronglyTypedId package's implementation. The question is, why?

Converting to source generators from CodeGeneration.Roslyn

The original version of the StronglyTypedId NuGet package (version 0.x), used the CodeGeneration.Roslyn library to provide compile-time code generation. This tool helped performing Roslyn-based code generation during a build, including design-time support.

However, .NET 5 introduced source generators, which effectively provides the same facilities, but with a simpler API and IDE integration, making the CodeGeneration.Roslyn library obsolete. I decided that if I was going to continue to support the library, I'd need to convert to source generators.

In theory, it's possible to refactor a CodeGeneration.Roslyn project to use source generators, but I decided against that for a couple of reasons:

  • The original StronglyTypedId implementation used multiple separate NuGet packages to provide the functionality. The metapackage abstracts this, but it's still unnecessary complexity with source generators that I'd want to remove.
  • I wanted to make some breaking changes to the API anyway, to make future updates easier, and to easily support new features
  • The syntax-tree generating code I was using was complex. Instead of creating the syntax tree directly, I was using https://roslynquoter.azurewebsites.net/ to paste in the final code I wanted, and then copy-pasting the syntax tree it provided into my project. With source generators this intermediate step isn't necessary, I can generate the source code directly, without having to use the Roslyn syntax tree.

RoslynQuoter website that generates a Roslyn syntax tree given C#

I'm planning on a separate series about creating the source generator itself, so in this post I'll just go over some of the breaking changes I made, some of the new features, and some of the bug fixes.

Breaking changes

If you've been using version 0.x of the StronglyTypedId library, there are some important breaking changes to be aware of in the next version (currently released as a beta).

Moving out of the global namespace

The previous version of the library placed the [StronglyTypedId] attribute in the global namespace. That was very convenient, as it meant the attribute was always available in IntelliSense, but it was a bit presumptuous. Adding things to the global namespace is bad form generally, so in version 1.x, I've moved the attribute to the StronglyTypedIds namespace:

using StronglyTypedIds;

[StronglyTypedId]
public partial struct OrderId { }

If you're upgrading from the earlier version, you'll need to add the extra using statements. In .NET 6, if you don't want to add this everywhere you could use a global using statement instead:

global using StronglyTypedIds;

Attribute property names have changed

In the 0.x version of the library, a TypeConverter and Newtonsoft.Json converter were generated by default for each ID. You could disable the generation of the JSON converter by setting generateJsonConverter: false, e.g.:

// Won't generate a JSON converter
[StronglyTypedId(generateJsonConverter: false)]
public partial struct OrderIdWithoutConverters { }

You could also change the JSON converter to a System.Text.Json converter (or generate both) by setting the jsonConverter property appropriately:

// System.Text.Json.Serialization.JsonConverter
[StronglyTypedId(jsonConverter: StronglyTypedIdJsonConverter.SystemTextJson)]
public partial struct OrderIdWithSystemTextJson { }

// System.Text.Json.Serialization.JsonConverter and Newtonsoft.Json.JsonConverter
[StronglyTypedId(jsonConverter: StronglyTypedIdJsonConverter.NewtonsoftJson | StronglyTypedIdJsonConverter.SystemTextJson)]
public partial struct OrderIdWithBothConverters { }

In 1.x, the API has changed. Instead of having two separate properties (jsonConverter and generateJsonConverter) to control converter generation, there's now a single property, converters. This single property allows you to disable the JSON converters entirely, or to provide additional converters. The equivalent of the previous 3 IDs would look similar to this:

[StronglyTypedId(converters: StronglyTypedIdConverter.None)]
public partial struct OrderIdWithoutConverters { }

[StronglyTypedId(converters: StronglyTypedIdJsonConverter.SystemTextJson)]
public partial struct OrderIdWithSystemTextJson { }

[StronglyTypedId(converters: StronglyTypedIdConverter.NewtonsoftJson | StronglyTypedIdConverter.SystemTextJson)]
public partial struct OrderIdWithBothConverters { }

Note that this isn't strictly identical, as a TypeConverter won't be generated in the above examples. TypeConverter generation is now configurable, along with other converters, as I'll cover later.

An ID using the String backing type should not be null

Most of the backing types for the IDs are value types (Guid, int, long etc) with the one exception being string. Unfortunately, by its nature, IDs that use the String backing type may have a null value. This is generally not going to be desirable, but is unfortunately unavoidable due to the fact you can't provide a default constructor for struct types.

Nevertheless, I wanted to reduce the ability to pass in null as the backing type for a string. Consequently, the non-default constructor of a String ID will now throw if you pass in null:

[StronglyTypedId(backingType: StronglyTypedIdBackingType.String)]
partial struct OrderId { }

// Generates code similar to the following:
readonly partial struct OrderId
{
    public string Value { get; }

    public OrderId(string value)
    {
        Value = value ?? throw new System.ArgumentNullException(nameof(value));
    }

    //... other members
}

As already mentioned, this isn't foolproof, but I think it's still advantageous.

One alternative I was considering was requiring that you can only use [StronglyTypedId] with String backing types on a class not a struct (currently the attribute is only valid on structs). With that change, we could properly enforce the value is never null, by removing the default constructor entirely. We'd lose the value-based semantics of the ID, but for the string backing type, it's not clear to me that's an advantage anyway. This is an area I'm open to suggestions!

Requires .NET 5 SDK

One of the biggest changes in 1.x is the simple fact the NuGet package is a source generator. That means you have to be using the .NET 5+ SDK, whereas the 0.x package used CodeGeneration.Roslyn. Hopefully that won't be a deal-breaker for too many people, but as that library is already deprecated, this is the only real path forward for the library anyway.

New features

In this section I'll run through some of the main features added in version 1.x of the library.

New converters

As I mentioned in the previous section, generating a TypeConverter is optional in version 1.x of the library. You can also now generate some new converters in addition to a TypeConverter and JsonConveters. Currently you can generate an EF Core ValueConverter and/or a Dapper TypeHandler, as I described in a previous post:

[StronglyTypedId(converters: StronglyTypedIdConverter.EfCoreValueConverter)]
public partial struct EfCoreId { }

[StronglyTypedId(converters: StronglyTypedIdConverter.DapperTypeHandler)]
public partial struct DapperId { }

The StronglyTypedIdConverter type is a [HasFlags] enum, so you can bitwise-OR multiple converters to generate multiple types, e.g.:

[StronglyTypedId(converters: StronglyTypedIdConverter.NewtonsoftJson | StronglyTypedIdConverter.EfCoreValueConverter)]
public partial struct MultipleConvertersId { }

If that's starting to feel a bit too verbose, don't worry, there's a new feature to help with that!

Allow controlling interfaces

In version 0.x of the library, all IDs implemented IEquatable<T> and IComparable<T>. In version 1.x of the library, you can optionally not implement these interfaces by specifying the implementations property. For example:

// Only implements IEquatable<>
[StronglyTypedId(implementations: StronglyTypedIdImplementations.IEquatable)]
public partial struct EquatableStringId { }

// Only implements IComparable<>
[StronglyTypedId(implementations: StronglyTypedIdImplementations.IComparable)]
public partial struct ComparableStringId { }

// Doesn't implement any interfaces
[StronglyTypedId(implementations: StronglyTypedIdImplementations.None)]
public partial struct NoInterfacesStringId { }

Those are the only two implementation options available currently, so I don't foresee people making significant use of this yet, but it sets the stage for implementing additional things later. In particular, I'm thinking about some features coming in .NET 7, such as static abstracts in interfaces and generic math operators.

Allow controlling default values globally

One of the downsides of all the extra features I've added, is that adding a "simple" attribute can suddenly look very complex. For example, if I wanted to generate all of the converters, explicitly specify implementations, and change the backing type for an ID, I'd have something like this:

[StronglyTypedId(
    backingType: StronglyTypedIdBackingType.Int,
    implementations: StronglyTypedIdImplementations.IEquatable | StronglyTypedIdImplementations.IComparable,
    converters: StronglyTypedIdConverter.TypeConverter 
        | StronglyTypedIdConverter.SystemTextJson
        | StronglyTypedIdConverter.NewtonsoftJson
        | StronglyTypedIdConverter.EfCoreValueConverter
        | StronglyTypedIdConverter.DapperTypeHandler)]
public partial struct OrderId { }


[StronglyTypedId(
    backingType: StronglyTypedIdBackingType.Int,
    implementations: StronglyTypedIdImplementations.IEquatable | StronglyTypedIdImplementations.IComparable,
    converters: StronglyTypedIdConverter.TypeConverter 
        | StronglyTypedIdConverter.SystemTextJson
        | StronglyTypedIdConverter.NewtonsoftJson
        | StronglyTypedIdConverter.EfCoreValueConverter
        | StronglyTypedIdConverter.DapperTypeHandler)]
public partial struct UserId { }

If you have many IDs, and they all need these values, suddenly the package is a lot less appealing!

To work around that, there's now an additional assembly attribute you can set, which controls the defaults for all [StronglyTypedId] attributes in your project. This attribute is [StronglyTypedIdDefaults], and it has an essentially identical API:

// Set the defaults for the project
[assembly:StronglyTypedIdDefaults(
    backingType: StronglyTypedIdBackingType.Int,
    implementations: StronglyTypedIdImplementations.IEquatable | StronglyTypedIdImplementations.IComparable,
    converters: StronglyTypedIdConverter.TypeConverter 
        | StronglyTypedIdConverter.SystemTextJson
        | StronglyTypedIdConverter.NewtonsoftJson
        | StronglyTypedIdConverter.EfCoreValueConverter
        | StronglyTypedIdConverter.DapperTypeHandler)]

The values you set in StronglyTypedIdDefaults are used for all of the [StronglyTypedId] attributes in your project, unless you explicitly override them. So if you set the assembly attribute as above, your ID definition become much simpler:

[StronglyTypedId]
public partial struct OrderId { }

[StronglyTypedId]
public partial struct UserId { }

And we can still override, for example, the backing type while still using the converters and implementations specified in the assembly attribute:

[StronglyTypedId(backingType: StronglyTypedIdBackingType.Int)]
public partial struct InvoiceId { }

I think this provides a nice balance between complexity and features.

NullableString backing type

As I mentioned earlier in the post, The String backing type has been updated to throw an exception if you pass null in the constructor. But what if you want to support null? To cater to this (rare, I expect) scenario, I added an additional backing type NullableString. This uses string? as a backing type, and explicitly enables nullable reference types in the file. I don't envision this type being used significantly, but I wanted to provide a path forward for people who were using version 0.x of the library and want to have null string values in their IDs

That covers most of the new features in the library currently. I may introduce some extra features before releasing a stable 1.0.0 version, but I don't have much specific in mind right now. If there's anything you'd especially like to see, feel free to log an issue

Bug Fixes

As well as new features and API changes, I took the opportunity to fix a few long-standing bugs. It's a bit embarrassing how long these bugs have been around, and is mostly due to how unwieldy it was to update the implementation. As a source generator, it's far easier to make these changes.

I won't go through them all, but some notable fixes include:

  • Some converters had incorrect implementations, such as in (issue #26). These have been addressed in version 1.x.
  • Better null handling has been added for the String backing type, handling issues such as #32. This applies to both the String and NullableString backing types.
  • The code is now marked as auto generated, which should avoid build warnings such as #CS1591 as described in issue #27

I'm currently dogfooding the beta version of the package. If you try out the beta version and run into any issues, do let me know, and I'll address them before releasing a stable 1.0.0!

Summary

In this post I described why I recently converted the StronglyTypedId NuGet package, from using CodeGeneration.Roslyn to being a source generator. I then covered some of the breaking changes, new features, and bug fixes in the beta version of the package. If you've used the package before, it would be great if you could give the beta version of the package a try, and send any feedback my way, thanks!

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