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.

C#
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:

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

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

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

This way, you'll get compiler errors when you don't use the expected type. You can also make the code clearer by using more types:

C#
// Is the path absolute? Is the path normalized?
string ReadFromFile(string path);
string ReadFromFile(FullPath path);

// Is the mime type valid?
void Process(string mimeType);
void Process(MimeType mimeType);

Note that the .NET Framework already provides multiple wrappers for primitive types. For example, System.Uri is a wrapper for string that provides validation and additional methods. TimeSpan is a wrapper for long that provides additional methods.

#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 validation, constants, methods, properties related to the type. No more helpers classes!
  • IDE can find all usages

➖ Cons:

  • Need to create a wrapper for each id type
  • 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

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

struct or record 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 invalid values. On the other hand, class or record don'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!

csproj (MSBuild project file)
<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>

    <!-- 👇 Optional: 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:

C#
// 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 implementations are omitted for brevity):

C#
[System.ComponentModel.TypeConverterAttribute(typeof(ProjectIdTypeConverter))]
[System.Text.Json.Serialization.JsonConverterAttribute(typeof(ProjectIdJsonConverter))]
[Newtonsoft.Json.JsonConverterAttribute(typeof(ProjectIdNewtonsoftJsonConverter))]
[MongoDB.Bson.Serialization.Attributes.BsonSerializerAttribute(typeof(ProjectIdMongoDBBsonSerializer))]
public partial struct ProjectId :
    System.IEquatable<ProjectId>,
    System.IParsable<ProjectId>,        // .NET 7+
    System.ISpanParsable<ProjectId>,    // .NET 7+
    IStronglyTypedId,                   // When Meziantou.Framework.StronglyTypedId.Interfaces is referenced
    IStronglyTypedId<ProjectId>,        // When Meziantou.Framework.StronglyTypedId.Interfaces is referenced
    IComparable, IComparable<ProjectId> // When at least one of the interface is explicitly defined by the user
{
    public int Value { get; }
    public string ValueAsString { get; } // Value formatted using InvariantCulture

    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);
    }

    // Generated only when MongoDB.Bson.Serialization.Serializers.SerializerBase is accessible
    private partial class ProjectIdMongoDBBsonSerializer : MongoDB.Bson.Serialization.Serializers.SerializerBase<ProjectId>
    {
        public override ProjectId Deserialize(MongoDB.Bson.Serialization.BsonDeserializationContext context, MongoDB.Bson.Serialization.BsonDeserializationArgs args);
        public override void Serialize(MongoDB.Bson.Serialization.BsonSerializationContext context, MongoDB.Bson.Serialization.BsonSerializationArgs args, ProjectId value);
    }
}

You can find the complete implementation in the solution explorer:

You can configure the code generation using the [StronglyTypedIdAttribute] attribute:

C#
[StronglyTypedId(idType: typeof(long),
                 generateSystemTextJsonConverter: true,
                 generateNewtonsoftJsonConverter: true,
                 generateSystemComponentModelTypeConverter: true,
                 generateMongoDBBsonSerialization: true,
                 addCodeGeneratedAttribute: true
                 )]
public partial struct ProjectId { }

You can generate IComparable, IComparable<T> and comparison operators by adding one interface:

C#
[StronglyTypedId<int>]
public partial struct ProjectId : IComparable { }

// Generated by the source generator
public partial struct ProjectId : IComparable<ProjectId>
{
    public int CompareTo(object? other);
    public int CompareTo(ProjectId? other);
    public static bool operator <(ProjectId? left, ProjectId? right);
    public static bool operator <=(IdInt32Comparable? left, IdInt32Comparable? right);
    public static bool operator >(IdInt32Comparable? left, IdInt32Comparable? right);
    public static bool operator >=(IdInt32Comparable? left, IdInt32Comparable? right);
}

#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

C#
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

C#
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:

C#
[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:

C#
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:

C#
    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💖 Sponsor on GitHub