Strongly-typed Ids using C# Source Generators

 
 
  • Gérald Barré

It is common to use int, Guid, or string to represent entity IDs because these types are well-supported by databases. Problems arise when you have methods with multiple parameters of the same type, making it easy to mix up arguments when calling those 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 get compiler errors when you use the wrong type. You can also improve clarity by introducing more specific 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 .NET already provides several wrappers for primitive types. For example, System.Uri wraps string and adds validation and helper methods. TimeSpan wraps long and provides time-related operations.

#Why should I use strongly-typed ids?

➕ Pros:

  • Code is self-documented
  • Leverage the compiler to avoid sneaky errors
  • It gives a natural place to add validation, constants, methods, and properties related to the type. No more helper 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!)
  • Does not work out-of-the-box with serializers, Entity Framework Core, or ASP.NET Core, but there are easy workarounds explained later in this post

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

struct and record struct are lightweight value types. Strongly-typed IDs are value types, so they are a natural fit for a struct. However, structs always have a parameterless constructor, which means you cannot prevent the creation of invalid values. On the other hand, class and record do not require a parameterless constructor. However, using classes introduces heap allocations, which can hurt performance by increasing GC pressure in hot paths.

So, the choice is up to you!

#Generating strongly-typed Ids with a Source Generator

Source generators are a feature introduced in C# 9.0. They can generate new files based on your project during compilation. In our case, they generate all the boilerplate needed for 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 produces many properties and methods automatically (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 Newtonsoft.Json.JsonConvert

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 strongly-typed IDs work with any code that relies on TypeDescriptor, including ASP.NET Core model binding. So, you can use strongly-typed IDs directly 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 objects such as strongly-typed IDs to primitive types that can be stored in a database. However, EF Core does not auto-discover these converters, so you need to map properties manually when building 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 do not want to map every property individually, you can use reflection to discover and register them all at once:

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

Strongly-typed IDs are a practical way to leverage the .NET type system and catch subtle bugs at compile time. Writing them by hand is tedious and repetitive, but C# Source Generators can produce all the boilerplate automatically. 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?