Entity ID Generator
Overview
Section titled “Overview”In Domain-Driven Design, the mistake of confusing Guid orderId with Guid userId is not caught by the compiler. Strong-typed Entity Ids solve this problem through the type system, but the same boilerplate must be manually written each time. This section implements a generator that automatically produces Ulid-based strong-typed Ids with a single [EntityId] attribute, leveraging the source generator patterns learned in Part 2.
Learning Objectives
Section titled “Learning Objectives”Core Learning Objectives
Section titled “Core Learning Objectives”- Understanding the need for strong-typed Entity Ids in Domain-Driven Design
- Runtime bugs caused by primitive type Ids and strategies for achieving type safety
- Implementing a Ulid-based Entity Id generator
- The complete pipeline from marker attribute to metadata extraction to code generation
- Learning partial record struct handling
- Syntax node filtering and partial keyword verification in symbol analysis
Why Strong-Typed Entity Ids?
Section titled “Why Strong-Typed Entity Ids?”The Problem with Primitive Types
Section titled “The Problem with Primitive Types”// ❌ Using primitive types - no compile-time type safetypublic class OrderService{ public Order GetOrder(Guid orderId) { /* ... */ } public User GetUser(Guid userId) { /* ... */ }}
// Accidentally passing userId in place of orderId -> compiles successfully, runtime bugvar user = orderService.GetUser(orderId); // Compiles OK, bug!Advantages of Strong-Typed Ids
Section titled “Advantages of Strong-Typed Ids”// ✅ Strong-typed Entity Id - compile-time type safetypublic class OrderService{ public Order GetOrder(OrderId orderId) { /* ... */ } public User GetUser(UserId userId) { /* ... */ }}
// Compilation error!var user = orderService.GetUser(orderId); // CS1503: Cannot convert 'OrderId' to 'UserId'Now that we have confirmed why strong-typed Ids are needed, let us define the shape of the code the source generator must produce.
Goal: Code to Auto-Generate
Section titled “Goal: Code to Auto-Generate”using MyCompany.SourceGenerator;
namespace MyApp.Domain;
[EntityId]public readonly partial record struct ProductId;Generated Result
Section titled “Generated Result”// <auto-generated/>#nullable enable
using System;using System.Diagnostics;using Cysharp.Serialization;
namespace MyApp.Domain;
[DebuggerDisplay("{ToString()}")]public readonly partial record struct ProductId : IEntityId<ProductId>, IComparable<ProductId>{ public Ulid Value { get; }
private ProductId(Ulid value) => Value = value;
/// <summary>Creates a new ProductId.</summary> public static ProductId New() => new(Ulid.NewUlid());
/// <summary>Creates a ProductId from an existing Ulid value.</summary> public static ProductId Create(Ulid value) => new(value);
/// <summary>An empty ProductId.</summary> public static ProductId Empty => new(Ulid.Empty);
public int CompareTo(ProductId other) => Value.CompareTo(other.Value);
public static bool operator >(ProductId left, ProductId right) => left.CompareTo(right) > 0;
public static bool operator <(ProductId left, ProductId right) => left.CompareTo(right) < 0;
public static bool operator >=(ProductId left, ProductId right) => left.CompareTo(right) >= 0;
public static bool operator <=(ProductId left, ProductId right) => left.CompareTo(right) <= 0;
public override string ToString() => Value.ToString();}Let us examine why Ulid was chosen over Guid as the internal type for Entity Ids.
Advantages of Ulid
Section titled “Advantages of Ulid”Guid vs Ulid Comparison
Section titled “Guid vs Ulid Comparison”| Characteristic | Guid | Ulid |
|---|---|---|
| Size | 16 bytes | 16 bytes |
| Generation speed | 73 ns | 65 ns |
| Sortable | ❌ Random | ✅ Timestamp-based |
| String length | 36 chars | 26 chars |
| DB index | Inefficient | Efficient (sequential inserts) |
Ulid Structure
Section titled “Ulid Structure” 01AN4Z07BY79KA1307SR9X4MV3|----------|---------------| Timestamp Random (48 bits) (80 bits) 10 chars 16 chars
- Timestamp: millisecond precision, sortable by time- Random: random data for collision preventionNuGet Package
Section titled “NuGet Package”<PackageReference Include="Ulid" Version="1.3.4" />With the target code and Ulid-based design finalized, we implement the generator following the 7-step workflow organized in the previous section.
Implementation
Section titled “Implementation”1. Project Structure
Section titled “1. Project Structure”MyCompany.SourceGenerator/+-- EntityIdGenerator.cs # Main generator+-- EntityIdAttribute.cs # Attribute source code constant+-- EntityIdInfo.cs # Metadata record+-- MyCompany.SourceGenerator.csproj2. Metadata Class
Section titled “2. Metadata Class”namespace MyCompany.SourceGenerator;
/// <summary>/// Metadata needed for Entity Id generation/// </summary>public sealed record EntityIdInfo( string TypeName, string Namespace, bool IsReadOnly);3. Marker Attribute Definition
Section titled “3. Marker Attribute Definition”namespace MyCompany.SourceGenerator;
internal static class EntityIdAttribute{ public const string Source = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// Apply to types to be generated as Entity Ids. /// Only applicable to record structs. /// </summary> /// <example> /// <code> /// [EntityId] /// public readonly partial record struct ProductId; /// </code> /// </example> [global::System.AttributeUsage( global::System.AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")] public sealed class EntityIdAttribute : global::System.Attribute; """;
public const string FullyQualifiedName = "MyCompany.SourceGenerator.EntityIdAttribute";}4. IEntityId Interface Definition
Section titled “4. IEntityId Interface Definition”namespace MyCompany.SourceGenerator;
internal static class IEntityIdInterface{ public const string Source = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// Interface that all Entity Ids must implement /// </summary> /// <typeparam name="TSelf">The Entity Id type itself</typeparam> public interface IEntityId<TSelf> where TSelf : struct, IEntityId<TSelf> { /// <summary>Internal Ulid value</summary> Ulid Value { get; }
/// <summary>Create a new Id</summary> static abstract TSelf New();
/// <summary>Create from an existing Ulid</summary> static abstract TSelf Create(Ulid value);
/// <summary>Empty Id</summary> static abstract TSelf Empty { get; } } """;}5. Main Generator
Section titled “5. Main Generator”using System.Text;using Microsoft.CodeAnalysis;using Microsoft.CodeAnalysis.CSharp.Syntax;using Microsoft.CodeAnalysis.Text;
namespace MyCompany.SourceGenerator;
[Generator(LanguageNames.CSharp)]public sealed class EntityIdGenerator : IIncrementalGenerator{ public void Initialize(IncrementalGeneratorInitializationContext context) { // Step 1: Generate fixed code (attribute, interface) context.RegisterPostInitializationOutput(ctx => { ctx.AddSource( hintName: "EntityIdAttribute.g.cs", sourceText: SourceText.From(EntityIdAttribute.Source, Encoding.UTF8));
ctx.AddSource( hintName: "IEntityId.g.cs", sourceText: SourceText.From(IEntityIdInterface.Source, Encoding.UTF8)); });
// Step 2: Collect types with [EntityId] attribute var provider = RegisterSourceProvider(context);
// Step 3: Generate code context.RegisterSourceOutput(provider, Execute); }
private static IncrementalValuesProvider<EntityIdInfo> RegisterSourceProvider( IncrementalGeneratorInitializationContext context) { return context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: EntityIdAttribute.FullyQualifiedName, predicate: IsRecordStruct, transform: MapToEntityIdInfo) .Where(static x => x is not null)!; }
private static bool IsRecordStruct(SyntaxNode node, CancellationToken _) { // Target only record structs return node is RecordDeclarationSyntax record && record.ClassOrStructKeyword.IsKind( Microsoft.CodeAnalysis.CSharp.SyntaxKind.StructKeyword); }
private static EntityIdInfo? MapToEntityIdInfo( GeneratorAttributeSyntaxContext context, CancellationToken _) { if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) return null;
// Verify partial keyword if (!typeSymbol.DeclaringSyntaxReferences .Any(r => r.GetSyntax() is TypeDeclarationSyntax t && t.Modifiers .Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PartialKeyword)))) { return null; // Cannot generate without partial }
return new EntityIdInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), IsReadOnly: typeSymbol.IsReadOnly); }
private static void Execute( SourceProductionContext context, EntityIdInfo info) { var source = GenerateEntityIdSource(info); var fileName = $"{info.Namespace.Replace(".", "")}{info.TypeName}.g.cs";
context.AddSource(fileName, SourceText.From(source, Encoding.UTF8)); }
private static string GenerateEntityIdSource(EntityIdInfo info) { var sb = new StringBuilder();
// Header sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine();
// Using statements sb.AppendLine("using System;"); sb.AppendLine("using System.Diagnostics;"); sb.AppendLine("using Cysharp.Serialization;"); sb.AppendLine();
// Namespace sb.AppendLine($"namespace {info.Namespace};"); sb.AppendLine();
// Type declaration sb.AppendLine($"[DebuggerDisplay(\"{{ToString()}}\")]"); sb.Append("public "); if (info.IsReadOnly) sb.Append("readonly "); sb.AppendLine($"partial record struct {info.TypeName} : global::MyCompany.SourceGenerator.IEntityId<{info.TypeName}>, global::System.IComparable<{info.TypeName}>"); sb.AppendLine("{");
// Value property sb.AppendLine(" public global::Cysharp.Serialization.Ulid Value { get; }"); sb.AppendLine();
// Private constructor sb.AppendLine($" private {info.TypeName}(global::Cysharp.Serialization.Ulid value) => Value = value;"); sb.AppendLine();
// Factory methods sb.AppendLine(" /// <summary>Creates a new Id.</summary>"); sb.AppendLine($" public static {info.TypeName} New() => new(global::Cysharp.Serialization.Ulid.NewUlid());"); sb.AppendLine(); sb.AppendLine(" /// <summary>Creates an Id from an existing Ulid value.</summary>"); sb.AppendLine($" public static {info.TypeName} Create(global::Cysharp.Serialization.Ulid value) => new(value);"); sb.AppendLine(); sb.AppendLine(" /// <summary>An empty Id.</summary>"); sb.AppendLine($" public static {info.TypeName} Empty => new(global::Cysharp.Serialization.Ulid.Empty);"); sb.AppendLine();
// IComparable implementation sb.AppendLine(" public int CompareTo(" + info.TypeName + " other) => Value.CompareTo(other.Value);"); sb.AppendLine();
// Comparison operators sb.AppendLine($" public static bool operator >({info.TypeName} left, {info.TypeName} right)"); sb.AppendLine(" => left.CompareTo(right) > 0;"); sb.AppendLine(); sb.AppendLine($" public static bool operator <({info.TypeName} left, {info.TypeName} right)"); sb.AppendLine(" => left.CompareTo(right) < 0;"); sb.AppendLine(); sb.AppendLine($" public static bool operator >=({info.TypeName} left, {info.TypeName} right)"); sb.AppendLine(" => left.CompareTo(right) >= 0;"); sb.AppendLine(); sb.AppendLine($" public static bool operator <=({info.TypeName} left, {info.TypeName} right)"); sb.AppendLine(" => left.CompareTo(right) <= 0;"); sb.AppendLine();
// ToString sb.AppendLine(" public override string ToString() => Value.ToString();");
sb.AppendLine("}");
return sb.ToString(); }}With the generator implementation complete, we verify the generation results with Verify snapshot tests.
Basic Generation Test
Section titled “Basic Generation Test”[Fact]public Task EntityIdGenerator_ShouldGenerate_BasicEntityId(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[EntityId] public readonly partial record struct ProductId; """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Non-Partial Type Test (Negative Case)
Section titled “Non-Partial Type Test (Negative Case)”[Fact]public void EntityIdGenerator_ShouldNotGenerate_WhenNotPartial(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[EntityId] public readonly record struct ProductId; // no partial """;
// Act string? actual = _sut.Generate(input);
// Assert actual.ShouldBeNull();}Namespace Test
Section titled “Namespace Test”[Fact]public Task EntityIdGenerator_ShouldGenerate_WithDeepNamespace(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace MyApp.Domain.Aggregates.Orders;
[EntityId] public readonly partial record struct OrderId; """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Once tests pass, we try using the generated Entity Id in an actual domain model.
Usage Examples
Section titled “Usage Examples”Using in Domain Models
Section titled “Using in Domain Models”using MyCompany.SourceGenerator;
namespace MyApp.Domain.Aggregates.Orders;
[EntityId]public readonly partial record struct OrderId;
// Domain/Aggregates/Orders/Order.cspublic sealed class Order{ public OrderId Id { get; private set; } public UserId CustomerId { get; private set; } public DateTime CreatedAt { get; private set; }
public static Order Create(UserId customerId) { return new Order { Id = OrderId.New(), // Create new Id CustomerId = customerId, CreatedAt = DateTime.UtcNow }; }}Using in Repositories
Section titled “Using in Repositories”public interface IOrderRepository{ Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct); Task<IReadOnlyList<Order>> GetByCustomerIdAsync(UserId customerId, CancellationToken ct);}
public sealed class OrderRepository : IOrderRepository{ public async Task<Order?> GetByIdAsync(OrderId id, CancellationToken ct) { // Convert Ulid to byte[] for DB query var bytes = id.Value.ToByteArray(); // ... }}Using Sorting
Section titled “Using Sorting”// Timestamp-based sorting with Ulidvar orders = await repository.GetAllAsync();var sortedByCreation = orders.OrderBy(o => o.Id); // Sort by creation time
// Using comparison operatorsif (order1.Id > order2.Id){ Console.WriteLine("order1 was created later");}Extension: Supporting Various Id Types
Section titled “Extension: Supporting Various Id Types”Guid-Based Entity Id
Section titled “Guid-Based Entity Id”// Specify type in attribute[EntityId(IdType = EntityIdType.Guid)]public readonly partial record struct LegacyOrderId;
// Or a separate attribute[GuidEntityId]public readonly partial record struct LegacyOrderId;Long-Based Entity Id (For Auto-Increment)
Section titled “Long-Based Entity Id (For Auto-Increment)”[EntityId(IdType = EntityIdType.Long)]public readonly partial record struct SequentialId;
// Generated codepublic readonly partial record struct SequentialId : IEntityId<SequentialId>{ public long Value { get; }
private SequentialId(long value) => Value = value;
public static SequentialId Create(long value) => new(value); // New() not provided as it relies on DB auto-increment}Summary at a Glance
Section titled “Summary at a Glance”Here is a summary of the key design of the Entity Id generator.
| Item | Description |
|---|---|
| Purpose | Automatic generation of type-safe Entity Ids |
| Base type | Ulid (sortable, high-performance) |
| Target | partial record struct with [EntityId] attribute |
| Generated items | Value, New(), Create(), Empty, comparison operators |
| Advantages | Compile-time type safety, DB index optimization |
The Entity Id generator follows the same pipeline structure as ObservablePortGenerator while adding the new syntax analysis technique of partial record struct filtering.
Q1: When should Guid be used instead of Ulid?
Section titled “Q1: When should Guid be used instead of Ulid?”A: Guid is appropriate when the existing system is Guid-based and migration costs are high, or when the database provides a native UUID type making Guid more efficient. For new projects, Ulid is recommended as it supports chronological sorting and offers superior DB index performance.
Q2: Why can [EntityId] only be applied to partial record struct?
Section titled “Q2: Why can [EntityId] only be applied to partial record struct?”A: Source generators work by adding code to existing types, so the partial keyword is required. record struct is used to secure both value equality (record) and stack allocation (struct). When [EntityId] is applied to a type defined without partial, MapToEntityIdInfo() returns null and no code is generated.
Q3: Can custom validation be added to generated Entity Ids?
Section titled “Q3: Can custom validation be added to generated Entity Ids?”A: Since it is a partial record struct, users can add methods in a separate file. For example, defining a helper method like public bool IsEmpty => Value == Ulid.Empty; directly will merge it with the generated code into a single type. To avoid conflicts with generated code, names like Value, New(), Create(), and Empty should be avoided.
We have created strong-typed Entity Ids, but this alone does not tell EF Core how to store them in a database. The next section implements a ValueConverter generator for transparently storing Entity Ids and Value Objects in the DB.