Chapter 06 · Complex Types, Owned Types, JSON Mapping, Conversions, Comparers, and Encapsulation
Complex Types vs Entity Types: Value-Object Semantics and EF Core 10 Modeling Choices
Model rich values with EF Core 10 complex types, separating structural value semantics from entity/owned identity while keeping table-split metadata and provider behavior observable.
Learning outcomes
ServiceHub now has explicit keys, generated values, concurrency protection, technicians, SLA snapshots, tags, and relationship lifecycle rules. Richer domain values are the next pressure point. A service address, geographic coordinate, money value, or contact block contains several scalar values, but it does not necessarily deserve its own database identity. EF Core 10 gives these values a first-class modeling construct: the complex type. This lesson separates complex/value semantics from entity and owned-entity semantics before any JSON or conversion decision is made.
Define primitive, entity, owned entity, and complex/value-object semantics without treating them as interchangeable storage choices.
Map an immutable ServiceAddress as an EF Core 10 complex type and inspect the resulting IModel metadata and table columns.
Explain nested and optional complex properties, including the EF Core 10 requirement for at least one required property in an optional complex type.
Show why complex types have no key, DbSet, independent tracking entry, or navigation properties.
Use shared-value examples to contrast complex value semantics with owned-entity reference/identity semantics.
Recognize EF Core 10 limitations and avoid teaching EF Core 11 complex-path keys/indexes as stable EF10 behavior.
1. Identity answers a domain question, not a C# class question
A C# class can represent either an entity or a value. The
decisive question is not “does it have several properties?” but
“does this thing have continuity and identity independent of the
values it currently contains?” A Technician remains
the same technician when the display name changes, so it is an
entity. A street/city/postal-code combination normally has no
such identity in ServiceHub: when any component changes, the
value has changed.
| EF model kind | Identity/tracking | Typical ServiceHub use |
|---|---|---|
| Entity type | Keyed identity; tracked independently |
WorkOrder, Technician,
Tag
|
| Owned entity type | Still an entity type; hidden/ownership identity | Lifecycle-bound checklist/audit structures |
| Complex type | No key or independent identity; structural value |
ServiceAddress, Money,
contact/value blocks
|
| Primitive | Single provider-mappable value |
int, Guid, string,
DateTime
|
2. Add an immutable ServiceAddress complex value
Use an immutable record so accidental reference sharing cannot mutate several owners through one shared object. Complex types can be reference or value types, but immutability gives the domain semantics the clearest expression.
public sealed record ServiceAddress( string Line1, string City, string Region, string PostalCode, string CountryCode);public sealed partial class WorkOrder{ public ServiceAddress ServiceAddress { get; private set; } = null!; public void Relocate(ServiceAddress address) => ServiceAddress = address ?? throw new ArgumentNullException(nameof(address));}
Because complex types are not discovered like normal entities,
configure the property explicitly. The relational default is
table splitting: scalar members become columns on
work_orders.
builder.ComplexProperty(x => x.ServiceAddress, address =>{ address.Property(x => x.Line1) .HasColumnName("service_line1") .HasMaxLength(180); address.Property(x => x.City) .HasColumnName("service_city") .HasMaxLength(100); address.Property(x => x.Region) .HasColumnName("service_region") .HasMaxLength(100); address.Property(x => x.PostalCode) .HasColumnName("service_postal_code") .HasMaxLength(24); address.Property(x => x.CountryCode) .HasColumnName("service_country_code") .HasMaxLength(2);});
3. Observe the model: complex metadata exists, but no entity entry exists
var workOrderType = db.Model.FindEntityType(typeof(WorkOrder))!;var addressProperty = workOrderType.FindComplexProperty(nameof(WorkOrder.ServiceAddress))!;Console.WriteLine($"Complex property: {addressProperty.Name}");Console.WriteLine($"CLR type: {addressProperty.ClrType.Name}");Console.WriteLine($"Complex type: {addressProperty.ComplexType.Name}");foreach (var property in addressProperty.ComplexType.GetProperties()){ Console.WriteLine($" {property.Name} -> {property.GetColumnName()}");}
The complex metadata is part of the containing entity's model.
There is no
db.Set<ServiceAddress>() representing
independently queryable rows and no separate entity entry with a
primary key. You query through
WorkOrder.ServiceAddress.
var northern = await db.WorkOrders .Where(x => x.ServiceAddress.Region == "North") .Select(x => new { x.WorkOrderNumber, x.ServiceAddress.City }) .ToListAsync(ct);Console.WriteLine(db.WorkOrders .Where(x => x.ServiceAddress.Region == "North") .ToQueryString());
With the SQLite table-split mapping, the generated predicate
should target the configured service_region column.
ToQueryString proves translation shape; it is not a
query plan or performance measurement.
4. Change tracking is structural
Assign a new immutable address and inspect the owner entry. EF
tracks the scalar members inside the complex value as part of
the WorkOrder state. A new object instance does not
force every column to update if only one scalar value differs.
var order = await db.WorkOrders.SingleAsync(x => x.Id == id, ct);order.Relocate(order.ServiceAddress with { PostalCode = "AZ1001" });db.ChangeTracker.DetectChanges();Console.WriteLine(db.ChangeTracker.DebugView.LongView);await db.SaveChangesAsync(ct);
The important evidence is which scalar member is marked modified
and which column appears in the generated UPDATE.
Do not infer write amplification from the fact that a new record
instance was assigned.
5. Nested and optional complex values are EF Core 10 features
EF Core 10 allows nested complex values and nullable complex properties. For example, a location can contain a nested coordinate, while an optional access instruction can be absent. Optional complex values currently require at least one required property in the complex type, so model nullability deliberately rather than discovering it only when model validation fails.
public readonly record struct GeoPoint(double Latitude, double Longitude);public sealed record ServiceAddress( string Line1, string City, string Region, string PostalCode, string CountryCode, GeoPoint Coordinate);builder.ComplexProperty(x => x.ServiceAddress, address =>{ address.ComplexProperty(x => x.Coordinate);});
EF Core 10 changed nested table-split naming to include the full complex path when conventions generate names. ServiceHub uses explicit column names where schema stability matters, so migrations remain reviewable across framework upgrades.
6. Deliberately wrong model: give a value object an ID because “EF needs keys”
A common pre-complex-types workaround is to turn every structured value into a keyed entity. That changes semantics: identity resolution, foreign keys, lifecycle, joins, and independent tracking now exist even if the domain never required them.
// Misleading for a true value object.public sealed class ServiceAddress{ public int Id { get; set; } // artificial identity public string City { get; set; } = "";}public int ServiceAddressId { get; set; }public ServiceAddress ServiceAddress { get; set; } = null!;
The repair is not “always use complex types.” If several work
orders really reference one location that has its own lifecycle,
permissions, geocoding status, history, or master-data identity,
a Site entity is appropriate. Choose identity from
domain behavior, then choose the EF construct.
7. Complex types are not owned types
| Question | Complex type | Owned entity type |
|---|---|---|
| Has hidden/entity identity? | No | Yes |
| Can same CLR instance/value be used in multiple places? | Value semantics; supported | Reference/ownership identity makes sharing problematic |
| Independent DbSet/query root? | No | No normal independent root; queried with owner |
| Separate table? | No; owner columns or JSON | Yes, possible |
| Navigations to entities? | Not supported | Supported within owned modeling constraints |
| Best default for Address/Money-like values? | Usually yes in EF Core 10 | Use when ownership/entity semantics are actually needed |
8. EF Core 11 preview boundaries matter
The current EF Core documentation notes that keys and indexes
directly targeting scalar properties nested inside complex types
begin in EF Core 11. This course is EF Core 10 LTS. Do not copy
an EF11 preview example such as
HasIndex(x => x.ServiceAddress.PostalCode) into
an EF10 production model and assume it is supported. If an EF10
workload needs an index, map/index an entity scalar or use
provider/database-specific migration SQL only with explicit
review and ownership.
The presence of EF Core 11 preview packages on NuGet does not change this course baseline. Stable lesson code remains on Microsoft.EntityFrameworkCore 10.0.11 and Microsoft.EntityFrameworkCore.Sqlite 10.0.11.
9. Hands-on lab: prove complex-value semantics
- Reset the disposable ServiceHub SQLite database from migrations and deterministic seed data.
-
Add
ServiceAddresstoWorkOrderand configure it withComplexProperty. -
Create/review the migration; confirm address members become
columns of
work_orders, not a new address table. -
Print
context.Modelcomplex metadata and verify no entity type/table exists forServiceAddress. -
Query by city/region and inspect
ToQueryString(). -
Replace only the postal code using an immutable
withexpression; inspect DebugView and command logging beforeSaveChangesAsync. -
Test one nested
GeoPointand inspect explicit column names. - Attempt the artificial-entity design in a scratch migration and compare the extra PK/FK/table/joins before discarding it.
Verification checklist
-
No independent key is invented for
ServiceAddress. - The migration matches intended relational storage.
-
Generated SQL reads/writes only through
work_orders. - Only expected scalar members are modified.
- No EF Core 11-only complex-path key/index API is used.
Check your understanding
- Why is ServiceAddress a complex type rather than an entity in this model?
- Can a complex type have a DbSet and be tracked independently?
- What is the default relational storage shape for a non-JSON complex property?
- Why prefer an immutable record for a shared reference-type complex value?
- What new optionality rule matters in EF Core 10?
- Why is HasIndex(x => x.ServiceAddress.PostalCode) not part of this EF10 lesson?
Review the answers
Its identity is its values; ServiceHub does not need independent lifecycle/identity for the address.
No. Complex values exist as part of an entity and have no independent key/tracking identity.
Its scalar members are stored with the containing entity (table splitting).
It avoids surprising aliasing/mutation when the same instance is reused and fits value semantics.
Nullable complex properties are supported, but an optional complex type currently needs at least one required property.
Direct keys/indexes over complex-property paths are documented as EF Core 11 features; the course baseline is EF Core 10 LTS.
10. Production judgment and bridge
Use a complex type when the domain says “structured value without identity,” not merely to reduce table count. Review migrations because table splitting widens the owner table, JSON changes query/index/storage behavior, and provider-specific capabilities differ. Keep relational constraints, indexes, query plans, and migration safety visible. The next lesson compares this value model with owned entity types, where ownership is real but hidden entity identity still exists.
Authoritative references
- Complex Types - EF Core — EF Core 10 complex/value semantics, optional/nested/collection support, JSON mapping, and limitations
- What's New in EF Core 10 — EF10 complex-type improvements and stable release scope
- Breaking changes in EF Core 10 — nested complex column-name changes and migration review
- Indexes - EF Core — index modeling and EF11 complex-path boundary
- Keys - EF Core — key semantics and EF11 complex-path boundary
- SQLite Provider — Microsoft-maintained SQLite provider baseline