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.
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.
Explain model CLR type versus provider CLR type and configure expression-based HasConversion mappings.
Map enums and strong IDs without leaking provider storage types through the domain.
Explain converter null behavior, query translation boundaries, and generated-value considerations.
Demonstrate why mutable serialized values can be missed without a deep ValueComparer snapshot.
Implement equality, hashing, and snapshot expressions consistently for a mutable collection.
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
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
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();}
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.
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.
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
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.
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
-
Add the enum/status conversion and strong
WorkOrderPublicIdconverter. - Generate/review the migration and inspect the resulting SQLite column types.
- Query by strong ID and capture generated SQL/parameters.
-
Add a scratch mutable
EscalationCodesserialization converter without a comparer. -
Load one row, mutate the existing list instance, call
DetectChanges, and inspectEntry.Property(...).IsModified. -
Add the deep
ValueComparer, reset the context/database, repeat, and confirm the mutation is detected. - 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
- What are model CLR type and provider CLR type?
- Why can storing an enum as text create a migration concern later?
- Why can a mutable list need ValueComparer?
- What three operations does ValueComparer define?
- Why must snapshot depth match comparison depth?
- 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
- Value Conversions - EF Core — converter semantics, examples, and limitations
- Value Comparers - EF Core — snapshot/equality/hash semantics for mutable/custom values
- Complex Types - EF Core — first-class alternative for multi-property value objects
- What's New in EF Core 8 — primitive collection JSON support on relational providers
- SQLite Provider Limitations — provider type/query limitations that may motivate conversions