DEV Community

Andrew Lock "Sock"
Andrew Lock "Sock"

Posted on • Originally published at andrewlock.net on

Strongly-typed ID update 0.2.1: Using strongly-typed entity IDs to avoid primitive obsession - Part 6

Strongly-typed ID update 0.2.1

Last year I wrote a series about using strongly typed IDs to avoid a whole class of bugs in C# applications. In this post I describe some recent updates to a NuGet package that drastically reduces the amount of boilerplate you have to write by auto-generating it for you at compile-time.

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 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 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.

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# (at least until we get record types in C# 9!):

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:

[StronglyTypedId]
public partial struct OrderId { }

On top of that, the StronglyTypedId package uses Roslyn 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

So that's the background, now lets look at some of the updates

Recent updates

These updates are primarily courtesy of Bartłomiej Oryszak who did great work! There are primarily three updates:

  • Update to the latest version of CodeGeneration.Roslyn to support for .NET Core 3.x
  • Support creating JSON converters for System.Text.Json
  • Support for using long as a backing type for the strongly typed ID

Support for .NET Core 3.x

StronglyTypedId has now been updated to the latest version of CodeGeneration.Roslyn to support for .NET Core 3.x. This brings updates to the Roslyn build tooling, which makes the library much easier to consume. You can add a single <PackageReference> in your project.

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

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp3.1</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="StronglyTypedId" Version="0.2.1" PrivateAssets="all"/>
  </ItemGroup>
</Project>

Setting PrivateAssets=all prevents the CodeGeneration.Roslyn.Attributes and StronglyTypedId.Attributes from being published to the output. There's no harm in them being there, but they're only used at compile time!

With the package added, you can now add the [StronglyTypedId] to your IDs:

[StronglyTypedId(generateJsonConverter: false)]
public partial struct OrderId {}

class Program
{
    static void Main(string[] args)
    {
        Console.WriteLine("Value = " + new OrderId().Value);
    }
}

This will generate a Guid-backed ID, with a TypeConverter, without any JSON converters. If you do want explicit JSON converters, you have another option—System.Text.Json converters.

Support for System.Text.Json converters

StronglyTypedId has always supported the Newtonsoft.Json JsonConverter but now you have another option, System.Text.Json. You can generate this converter by passing an appropriate StronglyTypedIdJsonConverter value:

[StronglyTypedId(jsonConverter: StronglyTypedIdJsonConverter.SystemTextJson)]
public partial struct OrderId {}

This generates a converter similar to the following:

using System;
using System.Text.Json;
using System.Text.Json.Serialization;

[JsonConverter(typeof(OrderIdSystemTextJsonConverter))]
readonly partial struct OrderId : IComparable<OrderId>, IEquatable<OrderId>
{
    // other implementation
    public class OrderIdSystemTextJsonConverter : JsonConverter<OrderId>
    {
        public override OrderId Read(ref Utf8JsonReader reader, System.Type typeToConvert, JsonSerializerOptions options)
        {
            return new OrderId(System.Guid.Parse(reader.GetString()));
        }

        public override void Write(Utf8JsonWriter writer, OrderId value, JsonSerializerOptions options)
        {
            writer.WriteStringValue(value.Value);
        }
    }
}

If you want to generate both a System.Text.Json converter and a Newtonsoft.Json converter, you can use flags:

[StronglyTypedId(jsonConverter: StronglyTypedIdJsonConverter.SystemTextJson | StronglyTypedIdJsonConverter.NewtonsoftJson)
public partial struct OrderId {}

Remember, if you generate a Newtonsoft.Json converter, you'll need to add a reference to the project file.

Support for long as a backing type

The final update is adding support for using long as the backing field for your strongly typed IDs. To use long, use the StronglyTypedIdBackingType.Long option:

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

The currently supported backing fields are:

  • Guid
  • int
  • long
  • string

Future work

C# 9 is bringing some interesting features, most notably source generators and record types. Both of these features have the potential to impact the StronglyTypedId package in different ways.

Source generators are designed for exactly the sort of functionality StronglyTypedId provides - build time enhancement of existing types. From a usage point of view, as far as I can tell, converting to using source generators would provide essentially the exact same experience as you can get now with CodeGeneration.Roslyn. For that reason, it doesn't really seem worth the effort looking into at this point, unless I've missed something!

Record types on the other hand are much more interesting. Records provide exactly the experience we're looking for here! With the exception of the built-in TypeConverter and JsonConverters, records seem like they would give an overall better experience out of the box. So when C#9 drops, I think this library can probably be safely retired 🙂

Summary

In this post I described some recent enhancements to the StronglyTypedId NuGet package, which lets you generate strongly-typed IDs at compile time. The updates simplify using the StronglyTypedId package in your app by supporting .NET Core 3.x, added support for System.Text.Json as a JsonConverter, and using long as a backing field. If you have any issues using the package, let me know in the issues on GitHub, or in the comments below.

Top comments (0)