Strongly-typed Ids using C# Source Generators

  • Gérald Barré

It's common to use int, Guid, or string to represent entity ids because these types are well-supported by databases. The problems come when you have methods with multiple parameters of the same type. In this case, it's easy to mix up parameters when calling these methods.

Issue GetIssue(int projectId, int issueId) { /* todo */ }

int projectId = 1;
int issueId = 1;
Get(issueId, projectId); // wrong argument order...

A solution is to replace the int type with a specific type:

Issue GetIssue(int projectId, int issueId) { /* todo */ }
Issue GetIssue(ProjectId projectId, IssueId issueId) { /* todo */ }

public class ProjectId
{
    public int Id { get; }
}

public class ProjectId
{
    public int Id { get; }
}

This way, you'll get compiler errors when you don't use the expected type.

#Why should I use strongly-typed ids?

Pros:

  • Code is self-documented
  • Leverage the compiler to avoid sneaky errors
  • It gives a location to add constant, methods, properties related to the type

Cons:

  • Needs to create a wrapper for each id type
  • You need to write more code (but Source Generators do it for you!)
  • Doesn't work with serializer / Entity Framework Core / ASP.NET Core (but there are easy workaround as explained here-after)

Note: If you use a struct to wrap the primitive type, you'll get the same performance as using the primitive type directly.

#Should I use a class or a struct for strongly-typed ids?

struct are lightweight objects. Strongly-typed ids are value-types, so they match the definition of a struct. However, structs always have a parameterless constructor. This means you cannot prevent the instantiation of wrong values. On the other hand, class doesn't need a parameterless constructor. But, using classes mean allocations. In some cases, this can be problematic for performance as it would contribute to triggering the GC more often.

So, the choice is up to you!

#Generating strongly-typed Ids with a Source Generator

Source generators is a new feature introduced in C# 9.0. Source Generators can generate new files based on your project and additional files during the compilation. In our case, they can generate all the boilerplate needed for the strongly-typed ids automatically!

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

  <PropertyGroup>
    <TargetFramework>net5.0</TargetFramework>

    <!-- 👇 Ensure you are using C# 9.0, the minimum version for Source Generators -->
    <LangVersion>9.0</LangVersion>

    <!-- 👇 Output the generated files to the disk, so you can see the result of the Source Generators under the "obj" folder. This is useful for debugging purpose -->
    <EmitCompilerGeneratedFiles>True</EmitCompilerGeneratedFiles>
    <CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\GeneratedFiles</CompilerGeneratedFilesOutputPath>
  </PropertyGroup>

  <ItemGroup>
    <!-- 👇 Reference the Source Generator -->
    <PackageReference Include="Meziantou.Framework.StronglyTypedId" Version="1.0.8">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
  </ItemGroup>
</Project>

Now, you can write the following code:

// Support partial struct
[StronglyTypedId(typeof(int))]
public partial struct ProjectId { }

// Support partial class
[StronglyTypedId(typeof(int))]
public partial class IssueId { }

The source generator generates lots of properties and methods automatically (method implementation is omitted for brevity):

[System.ComponentModel.TypeConverterAttribute(typeof(ProjectIdTypeConverter))]
[System.Text.Json.Serialization.JsonConverterAttribute(typeof(ProjectIdJsonConverter))]
[Newtonsoft.Json.JsonConverterAttribute(typeof(ProjectIdNewtonsoftJsonConverter))]
public partial struct ProjectId : System.IEquatable<ProjectId>
{
    public int Value { get; }
    public string ValueAsString { get; }

    private ProjectId(int value);

    public static ProjectId FromInt32(int value);
    public static ProjectId Parse(string value);
    public static ProjectId Parse(ReadOnlySpan<char> value);
    public static bool TryParse(string value, out ProjectId result);
    public static bool TryParse(ReadOnlySpan<char> value, out ProjectId result);
    public override int GetHashCode();
    public override bool Equals(object? other);
    public bool Equals(ProjectId other);
    public static bool operator ==(ProjectId a, ProjectId b);
    public static bool operator !=(ProjectId a, ProjectId b);
    public override string ToString();

    private partial class CustomerIdTypeConverter : System.ComponentModel.TypeConverter
    {
        public override bool CanConvertFrom(System.ComponentModel.ITypeDescriptorContext context, System.Type sourceType);
        public override object? ConvertFrom(System.ComponentModel.ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value);
        public override bool CanConvertTo(System.ComponentModel.ITypeDescriptorContext context, System.Type destinationType);
        public override object ConvertTo(System.ComponentModel.ITypeDescriptorContext context, System.Globalization.CultureInfo culture, object value, System.Type destinationType);
    }

    // Generated only when System.Text.Json is accessible
    private partial class CustomerIdJsonConverter : System.Text.Json.Serialization.JsonConverter<ProjectId>
    {
        public override void Write(System.Text.Json.Utf8JsonWriter writer, ProjectId value, System.Text.Json.JsonSerializerOptions options);
        public override ProjectId Read(ref System.Text.Json.Utf8JsonReader reader, System.Type typeToConvert, System.Text.Json.JsonSerializerOptions options);
    }

    // Generated only when Newtonsoft.Json is accessible
    private partial class CustomerIdNewtonsoftJsonConverter : Newtonsoft.Json.JsonConverter
    {
        public override bool CanRead { get; }
        public override bool CanWrite { get; }
        public override bool CanConvert(System.Type type);
        public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object? value, Newtonsoft.Json.JsonSerializer serializer);
        public override object ReadJson(Newtonsoft.Json.JsonReader reader, System.Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer);
    }
}

You can find the complete implementation in the solution explorer:

#Integration

##System.Text.Json

The source generator generates a JsonConverter, so you can serialize and deserialize the strongly-typed id using System.Text.Json.JsonSerializer

var customer = new Customer()
{
    Id = CustomerId.FromInt32(1),
    DisplayName = "Gérald Barré",
};

_ = JsonSerializer.Serialize(customer); // {"Id":1,"DisplayName":"Gérald Barré"}
var deserialized = JsonSerializer.Deserialize<Customer>("{\"Id\":1,\"DisplayName\":\"Gérald Barré\"}");
Assert.Equals(customer.Id, deserialized.Id);

##Newtonsoft.Json

The source generator generates a JsonConverter, so you can serialize and deserialize the strongly-typed id using System.Text.Json.JsonSerializer

var customer = new Customer()
{
    Id = CustomerId.FromInt32(1),
    DisplayName = "Gérald Barré",
};

_ = JsonConvert.SerializeObject(customer); // {"Id":1,"DisplayName":"Gérald Barré"}
var deserialized = JsonConvert.DeserializeObject<Customer>("{\"Id\":1,\"DisplayName\":\"Gérald Barré\"}");
Assert.Equals(customer.Id, deserialized.Id);

##ASP.NET Core

The source generator generates a TypeConverter, so you can use it with any code relying on TypeDescriptor. This includes ASP.NET Core when doing model binding. So, you can use these types in your controllers:

[ApiController, Route("[controller]")]
public class CustomerController : ControllerBase
{
    // /api/customer/42
    [HttpGet("{id}")]
    public Customer Get(CustomerId id)
    {
        // TODO custom logic
    }
}

##Entity Framework Core

Entity Framework Core uses ValueConverter to convert an object such as a strongly-typed id to a primitive type that can be stored into a DB. However, EF Core doesn't auto-discover these converters, so you need to manually map the properties when creating the model:

public class SampleDbContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // Add a value converter for the Customer.Id property
        modelBuilder.Entity<Customer>()
            .Property(c => c.Id)
            .HasConversion(new ValueConverter<CustomerId, int>(c => c.Value, c => CustomerId.FromInt32(c)));

        base.OnModelCreating(modelBuilder);
    }
}

If you don't want to map all properties one by one, you can use reflection to find them all and assign the converter:

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        foreach (var entityType in modelBuilder.Model.GetEntityTypes())
        {
            foreach(var prop in entityType.ClrType.GetProperties(BindingFlags.Public | BindingFlags.Instance))
            {
                if(prop.PropertyType == typeof(CustomerId))
                {
                    modelBuilder.Entity(entityType.Name).Property(prop.Name).HasConversion(new ValueConverter<CustomerId, int>(c => c.Value, c => CustomerId.FromInt32(c)));
                }
                else if(prop.PropertyType == typeof(OrderId))
                {
                    // ...
                }
                // else if(...)
            }
        }

        base.OnModelCreating(modelBuilder);
    }

#Conclusion

Using strongly-typed id is a common way to take advantage of the .NET type-system to avoid sneaky errors in your code. Writing them by hand is tedious and very repetitive. By using C# Source Generators we can quickly generate lots of codes automatically. So, there is no reason not to use strongly-typed ids in your projects!

#Additional References

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

Follow me:
Enjoy this blog?Buy Me A Coffee