Value Converter Generator
Overview
Section titled “Overview”Introducing strong-typed Value Objects and Entity Ids into domain models increases type safety, but creates a new problem: EF Core does not know how to store them in the database. Manually writing ValueConverter<TModel, TProvider> each time is repetitive and error-prone. This section implements a source generator that analyzes constructor parameters to automatically infer conversion logic and generate ValueConverter code.
Learning Objectives
Section titled “Learning Objectives”Core Learning Objectives
Section titled “Core Learning Objectives”- Understanding the role of EF Core ValueConverter
- The conversion mechanism between domain types and database storage types
- Learning how to store Value Objects and Entity Ids in the DB
- Storage strategies for single parameter, multiple parameters, and Ulid-based Entity Ids
- Automatic conversion logic generation through constructor parameter analysis
- Techniques for analyzing constructors with the Roslyn symbol API and inferring conversion expressions
What Is an EF Core ValueConverter?
Section titled “What Is an EF Core ValueConverter?”Problem: Storing Value Objects in the DB
Section titled “Problem: Storing Value Objects in the DB”// Domain Modelpublic record Email(string Value);public record Money(decimal Amount, string Currency);
public class Customer{ public CustomerId Id { get; set; } public Email Email { get; set; } // ❌ EF Core doesn't know how to store this public Money Balance { get; set; } // ❌ Cannot store complex types}Solution: ValueConverter
Section titled “Solution: ValueConverter”// EF Core ValueConverterpublic class EmailValueConverter : ValueConverter<Email, string>{ public EmailValueConverter() : base( v => v.Value, // Email -> string (store) v => new Email(v)) // string -> Email (retrieve) { }}
// DbContext configurationprotected override void OnModelCreating(ModelBuilder modelBuilder){ modelBuilder.Entity<Customer>() .Property(c => c.Email) .HasConversion<EmailValueConverter>();}Now that we understand the problem ValueConverter solves, let us define the shape of the code the generator must produce, divided into Value Object and Entity Id cases.
Goal: Code to Auto-Generate
Section titled “Goal: Code to Auto-Generate”Input: Value Object
Section titled “Input: Value Object”using MyCompany.SourceGenerator;
namespace MyApp.Domain;
[ValueConverter]public record Email(string Value);Generated Result
Section titled “Generated Result”// <auto-generated/>#nullable enable
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace MyApp.Domain;
/// <summary>/// EF Core ValueConverter that converts Email to string/// </summary>[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")]public sealed class EmailValueConverter : ValueConverter<Email, string>{ public EmailValueConverter() : base( v => v.Value, v => new Email(v)) { }}Input: Entity Id (Ulid-based)
Section titled “Input: Entity Id (Ulid-based)”[EntityId][ValueConverter] // Also generate ValueConverterpublic readonly partial record struct ProductId;Generated Result
Section titled “Generated Result”// <auto-generated/>#nullable enable
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace MyApp.Domain;
/// <summary>/// EF Core ValueConverter that converts ProductId to byte[]/// </summary>[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")]public sealed class ProductIdValueConverter : ValueConverter<ProductId, byte[]>{ public ProductIdValueConverter() : base( v => v.Value.ToByteArray(), v => ProductId.Create(new global::Cysharp.Serialization.Ulid(v))) { }}Storage Format Selection
Section titled “Storage Format Selection”Ulid Storage Options
Section titled “Ulid Storage Options”| Format | Size | Advantages | Disadvantages |
|---|---|---|---|
byte[] | 16 bytes | Space efficient, fast comparison | Hard to read |
string | 26 chars | Readable | Space inefficient |
// byte[] storage (recommended)public class ProductIdValueConverter : ValueConverter<ProductId, byte[]>{ public ProductIdValueConverter() : base( v => v.Value.ToByteArray(), v => ProductId.Create(new Ulid(v))) { }}
// string storage (for debugging)public class ProductIdStringValueConverter : ValueConverter<ProductId, string>{ public ProductIdStringValueConverter() : base( v => v.Value.ToString(), v => ProductId.Create(Ulid.Parse(v))) { }}With the storage format decided, we implement the ValueConverter generator following the same 7-step workflow as the Entity Id generator in the previous section. The key to this generator is analyzing constructor parameters to automatically infer conversion expressions.
Implementation
Section titled “Implementation”1. Metadata Class
Section titled “1. Metadata Class”namespace MyCompany.SourceGenerator;
/// <summary>/// Metadata needed for ValueConverter generation/// </summary>public sealed record ValueConverterInfo( string TypeName, string Namespace, string ProviderType, // DB storage type (string, byte[], int, etc.) string ToProviderExpression, // Model -> Provider conversion expression string FromProviderExpression, // Provider -> Model conversion expression bool IsEntityId); // Whether it is an EntityId2. Marker Attribute Definition
Section titled “2. Marker Attribute Definition”namespace MyCompany.SourceGenerator;
internal static class ValueConverterAttribute{ public const string Source = """ // <auto-generated/> #nullable enable
namespace MyCompany.SourceGenerator;
/// <summary> /// Apply to types that need an EF Core ValueConverter generated. /// </summary> /// <example> /// <code> /// [ValueConverter] /// public record Email(string Value); /// </code> /// </example> [global::System.AttributeUsage( global::System.AttributeTargets.Class | global::System.AttributeTargets.Struct, AllowMultiple = false, Inherited = false)] [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage( Justification = "Generated by source generator.")] public sealed class ValueConverterAttribute : global::System.Attribute { /// <summary> /// The type to store in the DB. If not specified, it is automatically inferred. /// </summary> public global::System.Type? ProviderType { get; set; } } """;
public const string FullyQualifiedName = "MyCompany.SourceGenerator.ValueConverterAttribute";}3. Main Generator
Section titled “3. 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 ValueConverterGenerator : IIncrementalGenerator{ public void Initialize(IncrementalGeneratorInitializationContext context) { // Step 1: Generate attribute definition context.RegisterPostInitializationOutput(ctx => ctx.AddSource( hintName: "ValueConverterAttribute.g.cs", sourceText: SourceText.From(ValueConverterAttribute.Source, Encoding.UTF8)));
// Step 2: Collect types with [ValueConverter] attribute var provider = RegisterSourceProvider(context);
// Step 3: Generate code context.RegisterSourceOutput(provider, Execute); }
private static IncrementalValuesProvider<ValueConverterInfo> RegisterSourceProvider( IncrementalGeneratorInitializationContext context) { return context.SyntaxProvider .ForAttributeWithMetadataName( fullyQualifiedMetadataName: ValueConverterAttribute.FullyQualifiedName, predicate: IsTypeDeclaration, transform: MapToValueConverterInfo) .Where(static x => x is not null)!; }
private static bool IsTypeDeclaration(SyntaxNode node, CancellationToken _) { return node is TypeDeclarationSyntax; }
private static ValueConverterInfo? MapToValueConverterInfo( GeneratorAttributeSyntaxContext context, CancellationToken _) { if (context.TargetSymbol is not INamedTypeSymbol typeSymbol) return null;
// Check if EntityId bool isEntityId = typeSymbol.GetAttributes() .Any(a => a.AttributeClass?.Name == "EntityIdAttribute");
// Analyze constructor parameters var constructor = typeSymbol.Constructors .Where(c => c.DeclaredAccessibility == Accessibility.Public) .OrderByDescending(c => c.Parameters.Length) .FirstOrDefault();
if (constructor is null || constructor.Parameters.Length == 0) { // Ulid-based handling for EntityId if (isEntityId) { return new ValueConverterInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), ProviderType: "byte[]", ToProviderExpression: "v => v.Value.ToByteArray()", FromProviderExpression: $"v => {typeSymbol.Name}.Create(new global::Cysharp.Serialization.Ulid(v))", IsEntityId: true); } return null; }
// Single parameter case (Value Object) if (constructor.Parameters.Length == 1) { var param = constructor.Parameters[0]; var providerType = GetProviderType(param.Type);
return new ValueConverterInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), ProviderType: providerType, ToProviderExpression: $"v => v.{ToPascalCase(param.Name)}", FromProviderExpression: $"v => new {typeSymbol.Name}(v)", IsEntityId: false); }
// Multiple parameter case (handled via JSON serialization) return new ValueConverterInfo( TypeName: typeSymbol.Name, Namespace: typeSymbol.ContainingNamespace.ToDisplayString(), ProviderType: "string", ToProviderExpression: "v => global::System.Text.Json.JsonSerializer.Serialize(v)", FromProviderExpression: $"v => global::System.Text.Json.JsonSerializer.Deserialize<{typeSymbol.Name}>(v)!", IsEntityId: false); }
private static string GetProviderType(ITypeSymbol type) { return type.SpecialType switch { SpecialType.System_String => "string", SpecialType.System_Int32 => "int", SpecialType.System_Int64 => "long", SpecialType.System_Decimal => "decimal", SpecialType.System_Double => "double", SpecialType.System_Boolean => "bool", SpecialType.System_DateTime => "global::System.DateTime", _ => type.ToDisplayString() }; }
private static string ToPascalCase(string name) { if (string.IsNullOrEmpty(name)) return name; return char.ToUpper(name[0]) + name.Substring(1); }
private static void Execute( SourceProductionContext context, ValueConverterInfo info) { var source = GenerateValueConverterSource(info); var fileName = $"{info.Namespace.Replace(".", "")}{info.TypeName}ValueConverter.g.cs";
context.AddSource(fileName, SourceText.From(source, Encoding.UTF8)); }
private static string GenerateValueConverterSource(ValueConverterInfo info) { var sb = new StringBuilder();
// Header sb.AppendLine("// <auto-generated/>"); sb.AppendLine("#nullable enable"); sb.AppendLine();
// Using statements sb.AppendLine("using Microsoft.EntityFrameworkCore.Storage.ValueConversion;"); sb.AppendLine();
// Namespace sb.AppendLine($"namespace {info.Namespace};"); sb.AppendLine();
// Class description sb.AppendLine($"/// <summary>"); sb.AppendLine($"/// EF Core ValueConverter that converts {info.TypeName} to {info.ProviderType}"); sb.AppendLine($"/// </summary>"); sb.AppendLine("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage("); sb.AppendLine(" Justification = \"Generated by source generator.\")]");
// Class declaration sb.AppendLine($"public sealed class {info.TypeName}ValueConverter : ValueConverter<{info.TypeName}, {info.ProviderType}>"); sb.AppendLine("{");
// Constructor sb.AppendLine($" public {info.TypeName}ValueConverter()"); sb.AppendLine(" : base("); sb.AppendLine($" {info.ToProviderExpression},"); sb.AppendLine($" {info.FromProviderExpression})"); sb.AppendLine(" { }");
sb.AppendLine("}");
return sb.ToString(); }}Generating individual ValueConverters alone still leaves the inconvenience of registering each one in DbContext. Generating an extension method that automatically registers all ValueConverters in the project solves this problem too.
DbContext Configuration Helper Generation
Section titled “DbContext Configuration Helper Generation”Auto-Registration Extension Method
Section titled “Auto-Registration Extension Method”// Input: project with multiple ValueConverters
// Auto-generatedpublic static class ValueConverterExtensions{ public static void ApplyValueConverters(this ModelBuilder modelBuilder) { // Email modelBuilder.Properties<Email>() .HaveConversion<EmailValueConverter>();
// ProductId modelBuilder.Properties<ProductId>() .HaveConversion<ProductIdValueConverter>();
// CustomerId modelBuilder.Properties<CustomerId>() .HaveConversion<CustomerIdValueConverter>(); }}Using in DbContext
Section titled “Using in DbContext”public class AppDbContext : DbContext{ protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder);
// Register all ValueConverters with a single line modelBuilder.ApplyValueConverters(); }}With the generator implementation complete, we verify the generation results for three cases: Value Object, Entity Id, and complex type.
Value Object Conversion Test
Section titled “Value Object Conversion Test”[Fact]public Task ValueConverterGenerator_ShouldGenerate_ForSimpleValueObject(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[ValueConverter] public record Email(string Value); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Entity Id Conversion Test
Section titled “Entity Id Conversion Test”[Fact]public Task ValueConverterGenerator_ShouldGenerate_ForEntityId(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[EntityId] [ValueConverter] public readonly partial record struct ProductId; """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Complex Value Object Test
Section titled “Complex Value Object Test”[Fact]public Task ValueConverterGenerator_ShouldGenerate_ForComplexValueObject(){ // Arrange string input = """ using MyCompany.SourceGenerator;
namespace TestNamespace;
[ValueConverter] public record Money(decimal Amount, string Currency); """;
// Act string? actual = _sut.Generate(input);
// Assert return Verify(actual);}Usage Examples
Section titled “Usage Examples”Domain Model
Section titled “Domain Model”[ValueConverter]public record Email(string Value){ public static Email Create(string value) { if (string.IsNullOrWhiteSpace(value)) throw new ArgumentException("Email cannot be empty");
if (!value.Contains('@')) throw new ArgumentException("Invalid email format");
return new Email(value.ToLowerInvariant()); }}
// Domain/ValueObjects/Money.cs[ValueConverter]public record Money(decimal Amount, string Currency){ public static Money Zero(string currency) => new(0m, currency);
public Money Add(Money other) { if (Currency != other.Currency) throw new InvalidOperationException("Cannot add money with different currencies");
return new Money(Amount + other.Amount, Currency); }}Entity
Section titled “Entity”public class Order{ public OrderId Id { get; private set; } public CustomerId CustomerId { get; private set; } public Email CustomerEmail { get; private set; } public Money TotalAmount { get; private set; }}DbContext Configuration
Section titled “DbContext Configuration”public class AppDbContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder) { // Use auto-generated extension method modelBuilder.ApplyValueConverters();
modelBuilder.Entity<Order>(entity => { entity.HasKey(e => e.Id);
// ValueConverter is automatically applied entity.Property(e => e.Id); entity.Property(e => e.CustomerId); entity.Property(e => e.CustomerEmail); entity.Property(e => e.TotalAmount); }); }}Advanced: Dapper Support
Section titled “Advanced: Dapper Support”TypeHandler Generation
Section titled “TypeHandler Generation”// Also generate TypeHandler for Dapperpublic sealed class ProductIdTypeHandler : SqlMapper.TypeHandler<ProductId>{ public override ProductId Parse(object value) { return value switch { byte[] bytes => ProductId.Create(new Ulid(bytes)), string str => ProductId.Create(Ulid.Parse(str)), _ => throw new InvalidCastException($"Cannot convert {value.GetType()} to ProductId") }; }
public override void SetValue(IDbDataParameter parameter, ProductId value) { parameter.Value = value.Value.ToByteArray(); parameter.DbType = DbType.Binary; }}Summary at a Glance
Section titled “Summary at a Glance”Here is a summary of the key design of the EF Core ValueConverter generator.
| Item | Description |
|---|---|
| Purpose | Auto-generate ValueConverters for storing Value Objects and Entity Ids in DB |
| Target | record/class with [ValueConverter] attribute |
| Analysis | Infers conversion logic from constructor parameters |
| Storage format | Single parameter -> that type, Multiple -> JSON |
| Entity Id | Ulid -> byte[] (16 bytes) |
The ValueConverter generator performs symbol analysis one level more complex than the Entity Id generator. The pattern of analyzing the number and types of constructor parameters to automatically determine conversion strategy can be applied to various generators.
Q1: Are there performance issues with JSON serialization for multi-parameter Value Objects?
Section titled “Q1: Are there performance issues with JSON serialization for multi-parameter Value Objects?”A: For cases like Money(decimal Amount, string Currency) with few fields, the JSON serialization cost is negligible. However, for queries retrieving large numbers of rows, deserialization overhead can accumulate. When performance is critical, consider the OwnsOne approach that separates Amount and Currency into separate columns.
Q2: Isn’t it difficult to query directly in the DB when storing Entity Id’s Ulid as byte[]?
Section titled “Q2: Isn’t it difficult to query directly in the DB when storing Entity Id’s Ulid as byte[]?”A: byte[] storage offers high space efficiency at 16 bytes and excellent index performance, but is difficult for humans to read. A strategy of using string storage (26 chars) for convenience in development environments and byte[] in production environments is also possible. This can be switched via the ProviderType option of the [ValueConverter] attribute.
Q3: How does the ApplyValueConverters() extension method collect all ValueConverters?
Section titled “Q3: How does the ApplyValueConverters() extension method collect all ValueConverters?”A: This extension method is also auto-generated by the source generator. It gathers all types with the [ValueConverter] attribute via Collect() and sequentially registers modelBuilder.Properties<T>().HaveConversion<TConverter>() in a single extension method. When new Value Objects are added, they are automatically reflected in the next build.
With the domain model storage problem solved, we now move to the validation problem in the application layer. The next section implements a Validation generator that analyzes DataAnnotations and automatically generates FluentValidation rules.