Skip to content

Entity ID Generator

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.

  1. 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
  2. Implementing a Ulid-based Entity Id generator
    • The complete pipeline from marker attribute to metadata extraction to code generation
  3. Learning partial record struct handling
    • Syntax node filtering and partial keyword verification in symbol analysis

// ❌ Using primitive types - no compile-time type safety
public class OrderService
{
public Order GetOrder(Guid orderId) { /* ... */ }
public User GetUser(Guid userId) { /* ... */ }
}
// Accidentally passing userId in place of orderId -> compiles successfully, runtime bug
var user = orderService.GetUser(orderId); // Compiles OK, bug!
// ✅ Strong-typed Entity Id - compile-time type safety
public 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.


using MyCompany.SourceGenerator;
namespace MyApp.Domain;
[EntityId]
public readonly partial record struct ProductId;
// <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.


CharacteristicGuidUlid
Size16 bytes16 bytes
Generation speed73 ns65 ns
Sortable❌ Random✅ Timestamp-based
String length36 chars26 chars
DB indexInefficientEfficient (sequential inserts)
01AN4Z07BY79KA1307SR9X4MV3
|----------|---------------|
Timestamp Random
(48 bits) (80 bits)
10 chars 16 chars
- Timestamp: millisecond precision, sortable by time
- Random: random data for collision prevention
<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.


MyCompany.SourceGenerator/
+-- EntityIdGenerator.cs # Main generator
+-- EntityIdAttribute.cs # Attribute source code constant
+-- EntityIdInfo.cs # Metadata record
+-- MyCompany.SourceGenerator.csproj
EntityIdInfo.cs
namespace MyCompany.SourceGenerator;
/// <summary>
/// Metadata needed for Entity Id generation
/// </summary>
public sealed record EntityIdInfo(
string TypeName,
string Namespace,
bool IsReadOnly);
EntityIdAttribute.cs
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";
}
IEntityIdInterface.cs
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; }
}
""";
}
EntityIdGenerator.cs
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.


[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);
}
[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();
}
[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.


Domain/Aggregates/Orders/OrderId.cs
using MyCompany.SourceGenerator;
namespace MyApp.Domain.Aggregates.Orders;
[EntityId]
public readonly partial record struct OrderId;
// Domain/Aggregates/Orders/Order.cs
public 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
};
}
}
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();
// ...
}
}
// Timestamp-based sorting with Ulid
var orders = await repository.GetAllAsync();
var sortedByCreation = orders.OrderBy(o => o.Id); // Sort by creation time
// Using comparison operators
if (order1.Id > order2.Id)
{
Console.WriteLine("order1 was created later");
}

// 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;
[EntityId(IdType = EntityIdType.Long)]
public readonly partial record struct SequentialId;
// Generated code
public 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
}

Here is a summary of the key design of the Entity Id generator.

ItemDescription
PurposeAutomatic generation of type-safe Entity Ids
Base typeUlid (sortable, high-performance)
Targetpartial record struct with [EntityId] attribute
Generated itemsValue, New(), Create(), Empty, comparison operators
AdvantagesCompile-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.

-> 03. EF Core Value Converter Generator