blog post image
Andrew Lock avatar

Andrew Lock

~14 min read

Updates to the StronglyTypedId library - simplification, templating, and CodeFixes

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

Several years ago I wrote a series about using strongly typed IDs to avoid a whole class of bugs in C# applications. As part of that series I introduced a NuGet package, StronglyTypedId, which simplifies generating the boilerplate code needed to make working with strongly typed IDs simpler.

In this post, I start by describing the motivation for the StronglyTypedId package by giving a brief background on strongly typed IDs and primitive obsession. I then demonstrate the basic usage of the package, which hasn't changed, before describing some of the fundamental challenges I struggled with in maintaining the package.

For the remainder of the post, I describe how the package works going forwards, why I think this is an improvement (for both me as a maintainer and you as a consumer), and how to try it out.

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)
    => _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 help 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 harder work in C#. For example, the following shows some of what you might need for a simple Guid-backed ID:

public readonly struct 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 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);
}

This is all actually a lot easier these days since C# has record and struct record. Thomas Levesque describes such an approach using record types in this excellent series.

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

using StronglyTypedIds;

[StronglyTypedId]
public partial struct OrderId { }

The StronglyTypedId package is powered by an incremental source generator, so the additional code is generated automatically as you type, and can be viewed and browsed in your IDE. The following example shows JetBrains Rider, but the same flow is available in Visual Studio too.

So that's the background. The package has been available for several years now, and has almost a million downloads!🤯 It went through a major revision when I converted it from a custom Roslyn-based generator to a source generator, but over the last couple of months, I completely changed the StronglyTypedId package and how you use it. The question is, why?

Balancing features with maintainability and developer-experience

As shown in the previous section, the StronglyTypedId generator is driven by the [StronglyTypedId] attribute. After several iterations of package I had settled on an attribute that looked something like this:

namespace StronglyTypedIds;

public sealed class StronglyTypedIdAttribute : Attribute
{
    public StronglyTypedIdAttribute(
        StronglyTypedIdBackingType backingType = StronglyTypedIdBackingType.Default,
        StronglyTypedIdConverter converters = StronglyTypedIdConverter.Default,
        StronglyTypedIdImplementations implementations = StronglyTypedIdImplementations.Default)
    {
        BackingType = backingType;
        Converters = converters;
        Implementations = implementations;
    }
}

This attribute has three different enums to provide:

  • StronglyTypedIdBackingType - defines the basic "backing type" of the ID, for example Guid, int, string etc.
  • StronglyTypedIdConverter - a set of flags that define which converters to generate for the ID, for example a System.Text.Json converter, an EF Core ValueConverter, or a TypeConverter.
  • StronglyTypedIdImplementations - a set of flags that define what the ID should implement, for example IEquatable and IComparable.

The intention with this design was that it was highly extensible. To add a new feature (for example to support a new backing type or converter), I could add a new enum flag, ensuring the changes were always backwards compatible. That seemed ideal, in theory.

Unfortunately, there were several things I didn't anticipate.

  • People had a lot of feature requests.
  • Implementing a new feature meant updating thousands of snapshots and verifying it worked with all the combinations of the other enum values.
  • I didn't necessarily agree with all of the requests.

The first point I probably should have seen coming. My initial implementation included everything I initially wanted. It had a few converters, IEquatable, and basic primitive backing types.

Then some people wanted new backing types, such as support for Mass Transit's NewId. That wasn't a big problem as long as users sent a PR. But they also had to ensure they had tests that covered all the different permutations of converters and implementations, which wasn't for the feint of heart.

On top of that, adding a new converter or implementation meant you then had to implement it in all the different backing types. If you're not familiar with NewId (for example), then having to add a new converter for it could be daunting.

Finally, I didn't necessarily want all the feature requests. For example, I didn't want to add implicit or explicit cast operators. On the one hand that's fine—it's "my" project after all. But on the other hand it felt a shame that my dislike of a certain feature potentially meant that users couldn't use it at all or had to fork the library.

Even if I only added the proposals I was happy with, with 40+ open issue, the additional combinatorial explosion of options would have made for a sub-par developer experience. Having to pick through 15 different backing types, 10 different converters, and 20 different implementations?! That's not fun.

Eventually, the feature requests and PRs got on top of me. I got stuck trying to pick which features were the most "worthy" to be implemented, and I basically got burned-out with the project. I wasn't actively using it in any projects personally, and the weight of the various feature requests led me to leave it neglected.

All of this led to some people to fork the project, which is the beauty of open source - you don't have to be blocked just because I don't want to work on the project. But on the other hand, it's sad when people have to take this route.

Eventually, I decided something had to be done, so I came up with a proposal that I hoped would give the best of both worlds: a simple getting-started experience, but complete customisation if you wanted it.

Redesigning the StronglyTypedId library

In March of 2023 I created an issue in the project, promulgating a design for the future direction of the library. The idea was to support only a small subset of "fixed" templates, but allow users to provide their own templates if they need it.

After 6 months of mulling this over, I finally set about implementing my proposal. And boy, it was a big one…

The pull request that implemented the change.

Instead of an ever-increasing set of options for generating IDs, this change moves the library to providing a small, minimal implementation of the core backing types, but gives the option for users to customise their generated IDs completely.

My hope is that this strikes a balance between a simple "getting-started" solution without any required configuration, but with an option to provide your own templates if you don't agree with my choices, or you want to add extra functionality.

The templating approach was partially inspired by Peter Morris's Moxy library, which provides a generalised version of source-generator templating. My goal is to provide a simple onboarding approach that doesn't require any templating, but which can be enhanced by a very simple templating approach when you need it, as shown later in this post. If you need even more control over your templates, then Moxy may be a good option!

For the rest of the post I'll walk through the new design of the library and some of the features that I hope will make it both easy to use and future-proof!

Using the built-in templates

The quickest way to get started with the library is to use one of the built-in Template definitions. Template is an enum that's added to your project along with the [StronglyTypedId] attribute. For example:

using StronglyTypedIds;

// set the default template (optional).
// defaults to Guid if you don't add this assembly attribute
[assembly:StronglyTypedIdDefaults(Template.Int)] 

[StronglyTypedId] // use the default template
public partial struct DefaultId {}

[StronglyTypedId(Template.Long)] // use the Long template instead
public partial struct GuidId {}

Currently the library includes just four core templates:

  • Guid
  • int
  • long
  • string

Each of these templates implements the following features:

  • IComparable<T>
  • IEquatable<T>
  • IFormattable
  • ISpanFormattable (.NET 6+)
  • IParsable<T> (.NET 7+)
  • ISpanParsable<T> (.NET 7+)
  • IUtf8SpanFormattable (.NET 8+)
  • IUtf8SpanParsable<T> (.NET 8+, int and long only)
  • System.ComponentModel.TypeConverter
  • System.Text.Json.Serialization.JsonConverter

The intention with these templates is to be as widely applicable as possible. You should be able to use the templates in any of your projects without issue.

Within reason. If you're using .NET Framework .NET Core 2x, or .NET Standard 2.0, then you'll need to add the System.Text.Json NuGet package as the IDs automatically include a System.Text.Json converter.

However, depending on your project, you may need additional converters, such as EF Core, Newtonsoft.Json or Dapper converters. These aren't included in the built-in templates; for those you'll need to use a custom template.

Using custom templates

In addition to the built-in templates, you can provide your own templates for use with strongly typed IDs. To create a custom template:

  • Add a file to your project with the name TEMPLATE.typedid, where TEMPLATE is the name of the template
  • Update the template with your desired ID content. Use PLACEHOLDERID for the name of the ID inside the template. This will be replaced with the ID's real name when generating the template.
  • Update the "build action" for the template to AdditionalFiles or C# analyzer additional file (depending on your IDE).

For example, you could create an EF Core ValueConverter template called guid-efcore.typedid like this:

partial struct PLACEHOLDERID
{
    public class EfCoreValueConverter : global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<PLACEHOLDERID, global::System.Guid>
    {
        public EfCoreValueConverter() : this(null) { }
        public EfCoreValueConverter(global::Microsoft.EntityFrameworkCore.Storage.ValueConversion.ConverterMappingHints? mappingHints = null)
            : base(
                id => id.Value,
                value => new PLACEHOLDERID(value),
                mappingHints
            ) { }
    }
}

Note that a nice feature here is that the content of the guid-efcore.typedid file is valid C#. One good way to author these templates is to create a .cs file containing the code you want for your ID, then rename your ID to PLACEHOLDERID, change the file extension from .cs to .typedid, and then set the build action.

After creating a template in your project you can apply it to your IDs like this:

[StronglyTypedId(Template.Guid, "guid-efcore")] // Use the built-in Guid template and also the custom template
public partial struct GuidId {}

This shows another important feature: you can specify multiple templates to use when generating the ID.

Using multiple templates

When specifying the templates for an ID, you can specify

  • 0 or 1 built-in templates (using Template.Guid etc)
  • 0 or more custom templates

For example:

[StronglyTypedId] // Use the default templates
public partial struct MyDefaultId {}

[StronglyTypedId(Template.Guid)] // Use a built-in template only
public partial struct MyId1 {}

[StronglyTypedId("my-guid")] // Use a custom template only
public partial struct MyId2 {}

[StronglyTypedId("my-guid", "guid-efcore")] // Use multiple custom templates
public partial struct MyId2 {}

[StronglyTypedId(Template.Guid, "guid-efcore")] // Use a built-in template _and_ a custom template
public partial struct MyId3 {}

// Use a built-in template and _multiple_ custom templates
[StronglyTypedId(Template.Guid, "guid-efcore", "guid-dapper")]
public partial struct MyId4 {}

Similarly, for the optional [StronglyTypedIdDefaults] assembly attribute, which defines the default templates to use when you use the [StronglyTypedId] attribute without arguments, you can use a combination of built-in and/or custom templates:

//âš  You can only use _one_ of these in your project, I'm just showing them all here for comparison

[assembly:StronglyTypedIdDefaults(Template.Guid)] // Use a built-in template only

[assembly:StronglyTypedIdDefaults("my-guid")] // Use a custom template only

[assembly:StronglyTypedIdDefaults("my-guid", "guid-efcore")] // Use multiple custom templates

[assembly:StronglyTypedIdDefaults(Template.Guid, "guid-efcore")] // Use a built-in template _and_ a custom template

// Use a built-in template and _multiple_ custom templates
[assembly:StronglyTypedIdDefaults(Template.Guid, "guid-efcore", "guid-dapper")]

[StronglyTypedId] // Uses whatever templates were specified!
public partial struct MyDefaultId {}

This design, using a combination of built-in and custom templates has several advantages:

  • Very simple set of built-in templates to choose from. No combination of multiple flags to try to decipher.
  • Easily extend the built-in templates with custom templates.
  • If you don't like my choices with the built-in templates, you can create entirely custom templates that introduce extra functionality or alternative backing types.

The bad news is that this design requires a bit more effort from you to create your own templates. Luckily StronglyTypedId provides you some help with that!

Roslyn CodeFix provider for creating a template

As well as the source generator, the StronglyTypedId NuGet package now includes a CodeFix provider that looks for cases where you have specified a custom template that the source generator cannot find. For example, in the following code,the "some-int" template does not yet exist:

[StronglyTypedId("some-int")] // does not exist
public partial struct MyStruct
{
}

In the IDE, you can see the generator has marked this as an error:

An error is shown when the template does not exist

The image above also shows that there's a CodeFix action available. Clicking the action reveals the possible fix: Add some-int.typedid template to the project, and shows a preview of the file that will be added:

Showing the CodeFix in action, suggesting you can add a project

Choosing this option adds the template to your project.

Unfortunately, due to limitations with the Roslyn APIs, it's not possible to add the new template with the required AdditionalFiles/C# analyzer additional file build action already set. Until you change the build-action, the error will remain on your [StronglyTypedId] attribute.

Right-click the newly-added template, choose Properties, and change the Build Action to either C# analyzer additional file (Visual Studio 2022) or AdditionalFiles (JetBrains Rider). The source generator will then detect your template and the error will disappear.

You can see the complete CodeFix flow in the following video:

The CodeFix provider does a basic check against the name of the template you're trying to create. If it includes int, long, or string, the template it creates will be based on one of those backing types. Otherwise, the template is based on a Guid backing type.

Once the template is created, you're free to edit it as you see fit. Remove things, add things, change things; the source generator updates the generated code as you type!

Optional new StronglyTypedId.Templates packages

The ability to provide custom templates will (I hope) mean that everyone can get the most out of the StronglyTypedId library without being blocked by my ability to merge features, or by my feelings on which features should be included.

If you need a new converter, no need to wait for it to be implemented in the project and for a new release. You can simply add your own custom template, optionally set it as the default for your ids, and carry on.

That said, one of the original advantages of this project was that you didn't have to write any of the converters yourself. With custom templates, you lose that benefit to an extent. To counteract that, I've decided to also publish a collection of "community templates" as a NuGet package, StronglyTypedId.Templates.

This package is entirely optional, but if you add it to your project, the templates it contains are automatically available as custom templates. Currently the package includes the "full" versions of all the ID implementations that were available in the previous version of the StronglyTypedId package:

  • guid-full
  • int-full
  • long-full
  • string-full
  • nullablestring-full
  • newid-full

These templates contain all the converters and implementations available in the previous version of the library (plus some extra updates I made to support additional interfaces).

Additionally, specific "standalone" EF Core, Dapper, and Newtonsoft JSON converter templates are available to enhance the Guid/int/long/string built-in templates. The initial version of the package includes the following templates:

  • Templates for use with Template.Guid
    • guid-dapper
    • guid-efcore
    • guid-newtonsoftjson
  • Templates for use with Template.Int
    • int-dapper
    • int-efcore
    • int-newtonsoftjson
  • Templates for use with Template.Long
    • long-dapper
    • long-efcore
    • long-newtonsoftjson
  • Templates for use with Template.String
    • string-dapper
    • string-efcore
    • string-newtonsoftjson

As shown above, these are designed to be used in conjunction with the built-in templates. For example, if the built-in Guid template works for you, but you need an EF Core converter, you can use the following:

[StronglyTypedId(Template.Guid, "guid-efcore")]
public partial struct MyStruct { }

I've spent quite some time working on this re-architecture and I think it will solve the main problems I currently see with the library, namely that people want different things from it, and it's frankly become a burden on me!

My hope is that these changes give the library a path forward. I'm not totally against adding new backing types to the built-in templates, but in most cases I think the StronglyTypedId.Templates package will be the best place for them. I'm personally feeling much better about the project now, and I hope consumers of the package will be too in the long run!

Summary

In this post I described the recent changes to my StronglyTypedId NuGet package. This package helps create the boilerplate necessary for creating strongly typed IDs that avoid a whole class of bugs caused by primitive obsession. I recently decided to move the design of the library away from a built in set of enums in an attempt to both make the library more maintainable, and to give users the ability to completely customise their generated IDs.

Users of the library can still generate IDs using the [StronglyTypedId] attribute, but now you only need to choose the backing type to use. If you need additional converters or implementations, you must use custom templates. The StronglyTypedId.Templates package includes many templates that augment or replace the built-in templates, but you can also create your own templates from scratch. To help with the authoring of templates, the StronglyTypedId package also includes a CodeFix provider that generates a skeleton template for you.

I hope these changes don't inconvenience too many people, and rather users will be happy to see they now can completely customise their strongly typed IDs to their needs, without having to go through me as a gatekeeper!

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