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!