Skip to content

Value Converter Generator

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.

  1. Understanding the role of EF Core ValueConverter
    • The conversion mechanism between domain types and database storage types
  2. Learning how to store Value Objects and Entity Ids in the DB
    • Storage strategies for single parameter, multiple parameters, and Ulid-based Entity Ids
  3. Automatic conversion logic generation through constructor parameter analysis
    • Techniques for analyzing constructors with the Roslyn symbol API and inferring conversion expressions

// Domain Model
public 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
}
// EF Core ValueConverter
public class EmailValueConverter : ValueConverter<Email, string>
{
public EmailValueConverter()
: base(
v => v.Value, // Email -> string (store)
v => new Email(v)) // string -> Email (retrieve)
{ }
}
// DbContext configuration
protected 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.


using MyCompany.SourceGenerator;
namespace MyApp.Domain;
[ValueConverter]
public record Email(string Value);
// <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))
{ }
}
[EntityId]
[ValueConverter] // Also generate ValueConverter
public readonly partial record struct ProductId;
// <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)))
{ }
}

FormatSizeAdvantagesDisadvantages
byte[]16 bytesSpace efficient, fast comparisonHard to read
string26 charsReadableSpace 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.


ValueConverterInfo.cs
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 EntityId
ValueConverterAttribute.cs
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";
}
ValueConverterGenerator.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 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.


// Input: project with multiple ValueConverters
// Auto-generated
public 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>();
}
}
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.


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

Domain/ValueObjects/Email.cs
[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);
}
}
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; }
}
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);
});
}
}

// Also generate TypeHandler for Dapper
public 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;
}
}

Here is a summary of the key design of the EF Core ValueConverter generator.

ItemDescription
PurposeAuto-generate ValueConverters for storing Value Objects and Entity Ids in DB
Targetrecord/class with [ValueConverter] attribute
AnalysisInfers conversion logic from constructor parameters
Storage formatSingle parameter -> that type, Multiple -> JSON
Entity IdUlid -> 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.

-> 04. Validation Generator