Chapter 06 · Complex Types, Owned Types, JSON Mapping, Conversions, Comparers, and Encapsulation

Value Converters and Value Comparers for Domain Types, Enums, Strong IDs, and Collections

Bridge domain and provider representations with value converters, then add ValueComparer snapshot semantics where mutable/custom values require structural change detection.

Intermediate100–130 minutesconverter + mutable comparer change-tracking labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

Complex types model multi-property structures. Sometimes ServiceHub instead has one conceptual property whose CLR representation differs from the database representation: an enum stored as text, a strongly typed ID stored as a Guid, or a mutable collection serialized into one column. EF Core handles the read/write transformation with a value converter; change detection may additionally require a value comparer.

01

Explain model CLR type versus provider CLR type and configure expression-based HasConversion mappings.

02

Map enums and strong IDs without leaking provider storage types through the domain.

03

Explain converter null behavior, query translation boundaries, and generated-value considerations.

04

Demonstrate why mutable serialized values can be missed without a deep ValueComparer snapshot.

05

Implement equality, hashing, and snapshot expressions consistently for a mutable collection.

06

Recognize when EF built-in complex/primitive collection support is preferable to a manual serialization converter.

1. A converter changes representation, not meaning

A converter is a pair of expressions: model value → provider value when saving and provider value → model value when materializing. The database provider still needs to map the provider CLR type to a store type. A converter does not create extra columns, relationships, constraints, encryption policy, or query indexes by itself.

2. Store WorkOrderStatus as readable text

csharp · enum conversion
builder.Property(x => x.Status)    .HasColumnName("status")    .HasConversion<string>()    .HasMaxLength(30);

This makes migrations and data inspection easier to read but increases storage compared with an integer and makes enum renames a data-migration concern. Whether text is better is a product/operations decision, not an EF rule.

3. Strong IDs keep accidental mixing out of domain code

csharp · strongly typed public ID
public readonly record struct WorkOrderPublicId(Guid Value){    public static WorkOrderPublicId New() => new(Guid.NewGuid());    public override string ToString() => Value.ToString("D");}public sealed partial class WorkOrder{    public WorkOrderPublicId PublicId { get; private set; } = WorkOrderPublicId.New();}
csharp · strong ID converter
builder.Property(x => x.PublicId)    .HasColumnName("public_id")    .HasConversion(        id => id.Value,        value => new WorkOrderPublicId(value));

The converter lets the provider persist a Guid while domain code receives WorkOrderPublicId. Keep key/index support version-aware: EF Core 10 cannot use nested complex-property paths as keys, so a scalar strongly typed property with a converter is a different model from a multi-property complex ID.

4. Converter expressions become part of EF's materialization pipeline

EF uses expressions rather than arbitrary opaque delegates so conversion can be composed into compiled materialization logic. Converters are not guaranteed to translate every domain method in LINQ. Query against mapped properties/operators whose conversion/translation the provider supports, and verify ToQueryString.

csharp · translation check
var id = new WorkOrderPublicId(inputGuid);var query = db.WorkOrders.Where(x => x.PublicId == id);Console.WriteLine(query.ToQueryString());var order = await query.SingleAsync(ct);

5. Null values bypass converters

EF generally does not pass null through converters; null remains null. This makes one converter reusable for nullable/non-nullable properties, but it also means a converter is not the place to invent a “null means unknown sentinel” policy. Model nullability/defaults explicitly.

6. Deliberately broken serialized collection: conversion without comparison

Suppose legacy compatibility requires EscalationCodes to be serialized as JSON into one text column. A converter can serialize the list, but List<string> is mutable and uses reference equality. If EF snapshots the same list instance, an in-place Add can be invisible to ordinary comparison.

csharp · incomplete mutable-list converter
builder.Property(x => x.EscalationCodes)    .HasConversion(        value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null),        json => JsonSerializer.Deserialize<List<string>>(            json, (JsonSerializerOptions?)null) ?? new());// Later:order.EscalationCodes.Add("SAFETY");await db.SaveChangesAsync(ct); // change detection may not behave as intended

7. Add a deep ValueComparer with matching snapshot semantics

csharp · converter plus comparer
var comparer = new ValueComparer<List<string>>(    (left, right) => left.SequenceEqual(right),    value => value.Aggregate(0, (hash, item) => HashCode.Combine(hash, item)),    value => value.ToList());builder.Property(x => x.EscalationCodes)    .HasConversion(        value => JsonSerializer.Serialize(value, (JsonSerializerOptions?)null),        json => JsonSerializer.Deserialize<List<string>>(            json, (JsonSerializerOptions?)null) ?? new())    .Metadata.SetValueComparer(comparer);

The three comparer expressions must agree: equality is sequence-based, hash uses that sequence, and snapshot clones the sequence. Deep comparison without a deep snapshot still fails because the “original” would mutate with the current list.

8. Prefer immutable values when possible

An immutable record/readonly struct with meaningful equality often needs no special comparer. That reduces snapshot cost and mutation ambiguity. Also re-evaluate whether manual JSON serialization is needed: modern EF supports primitive collections and complex JSON mapping directly on supported relational providers, which can preserve queryability better than an opaque converter string.

9. “Encrypt with a converter” is not a complete security design

A converter can transform plaintext to ciphertext, but secure encryption also requires authenticated algorithms, key storage/rotation, nonce/IV management, deterministic-vs-randomized tradeoffs, migration/re-encryption strategy, logging redaction, and query limitations. Do not paste a homemade reversible-string converter into production and call it encryption.

Security boundary

If encrypted fields must be searchable, threat model and cryptographic design come first. EF conversion is only one persistence integration point.

10. Hands-on lab: prove snapshot behavior

  1. Add the enum/status conversion and strong WorkOrderPublicId converter.
  2. Generate/review the migration and inspect the resulting SQLite column types.
  3. Query by strong ID and capture generated SQL/parameters.
  4. Add a scratch mutable EscalationCodes serialization converter without a comparer.
  5. Load one row, mutate the existing list instance, call DetectChanges, and inspect Entry.Property(...).IsModified.
  6. Add the deep ValueComparer, reset the context/database, repeat, and confirm the mutation is detected.
  7. Compare the manual serialized column with an EF-native primitive collection/JSON alternative and document which workload needs queryable elements.

Verification checklist

  • Converters have explicit model/provider representations.
  • Strong IDs remain type-safe in C# while using provider-friendly storage.
  • Mutable values use coherent deep equality/snapshot semantics or are redesigned as immutable.
  • No converter is assumed to make arbitrary domain methods SQL-translatable.
  • No “encryption converter” is presented as a full security solution.

Check your understanding

  1. What are model CLR type and provider CLR type?
  2. Why can storing an enum as text create a migration concern later?
  3. Why can a mutable list need ValueComparer?
  4. What three operations does ValueComparer define?
  5. Why must snapshot depth match comparison depth?
  6. When might EF-native JSON/primitive collection mapping be better than serialization conversion?
Review the answers

The model CLR type is exposed in the entity; the provider CLR type is what the database provider maps to storage after conversion.

Renaming enum members changes persisted text semantics and may require data migration/backward compatibility.

Default reference snapshot/equality may not detect in-place mutation of a mutable collection.

Equality, hash-code calculation, and snapshot creation.

If the original snapshot points at the same mutable object, both original/current mutate together and deep comparison sees no difference.

When the application needs provider-translated element queries/updates rather than treating the whole value as an opaque text blob.

11. Production judgment and bridge

Use converters to bridge representations intentionally. Measure storage/query/index implications, preserve database constraints where possible, and test migrations/queries on every provider you support. Prefer immutable domain values. The final lesson combines these mapping tools with constructors, private setters, fields, private collections, and invariant-preserving methods so persistence does not dictate an anemic domain model.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n