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.

Intermediate100–125 minutescomplex types + metadata/table-split labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

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.

01

Define primitive, entity, owned entity, and complex/value-object semantics without treating them as interchangeable storage choices.

02

Map an immutable ServiceAddress as an EF Core 10 complex type and inspect the resulting IModel metadata and table columns.

03

Explain nested and optional complex properties, including the EF Core 10 requirement for at least one required property in an optional complex type.

04

Show why complex types have no key, DbSet, independent tracking entry, or navigation properties.

05

Use shared-value examples to contrast complex value semantics with owned-entity reference/identity semantics.

06

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.

csharp · ServiceAddress value object
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.

csharp · complex-property configuration
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

csharp · inspect complex metadata
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.

csharp · query through the owner
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.

csharp · replace immutable value and inspect changes
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.

csharp · nested complex values
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.

csharp · unnecessary entity identity
// 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.

Version discipline

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

  1. Reset the disposable ServiceHub SQLite database from migrations and deterministic seed data.
  2. Add ServiceAddress to WorkOrder and configure it with ComplexProperty.
  3. Create/review the migration; confirm address members become columns of work_orders, not a new address table.
  4. Print context.Model complex metadata and verify no entity type/table exists for ServiceAddress.
  5. Query by city/region and inspect ToQueryString().
  6. Replace only the postal code using an immutable with expression; inspect DebugView and command logging before SaveChangesAsync.
  7. Test one nested GeoPoint and inspect explicit column names.
  8. 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

  1. Why is ServiceAddress a complex type rather than an entity in this model?
  2. Can a complex type have a DbSet and be tracked independently?
  3. What is the default relational storage shape for a non-JSON complex property?
  4. Why prefer an immutable record for a shared reference-type complex value?
  5. What new optionality rule matters in EF Core 10?
  6. 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

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