Chapter 06 · Complex Types, Owned Types, JSON Mapping, Conversions, Comparers, and Encapsulation
Owned Entity Types and Aggregate Boundaries: OwnsOne, OwnsMany, and Table Mapping
Use owned entity types only when lifecycle-bound entity semantics are real, and prove hidden identity, OwnsOne/OwnsMany keys, table mapping, tracking, and ownership behavior.
Learning outcomes
Complex types model value semantics, but some lifecycle-bound structures still need entity-style tracking and ownership. A work-order checklist is a useful example: each checklist item belongs to exactly one work order, may need stable identity within that owner, and can be stored in its own table without becoming an independently managed aggregate. EF Core models this with owned entity types.
Explain why an owned type is still an entity type with identity even when the CLR type has no explicit key property.
Configure OwnsOne and OwnsMany, including implicit/shadow keys and composite ownership keys.
Compare table splitting with separate-table ownership and inspect the resulting migration/foreign keys.
Show nested ownership and automatic inclusion with the owner.
Contrast owned tracking/reference semantics with complex value semantics.
Recognize cases where an owned type is being stretched beyond its lifecycle boundary and should become a normal entity or complex type instead.
1. Ownership says “this entity cannot live outside this owner”
OwnsOne and OwnsMany are not shorthand
for “put some properties in a class.” EF creates entity metadata
for the owned type. That metadata participates in state
management, ownership relationships, keys, migrations, cascade
behavior, and materialization.
If ServiceHub must reference, transfer, secure, version, or query the object independently of a work order, ownership may be the wrong boundary. A normal entity with its own key can be clearer.
2. OwnsOne: lifecycle-bound dispatch metadata
public sealed class DispatchMetadata{ public string Channel { get; private set; } = "manual"; public DateTime? DispatchedUtc { get; private set; } public string? ExternalDispatchId { get; private set; }}public sealed partial class WorkOrder{ public DispatchMetadata Dispatch { get; private set; } = new();}
builder.OwnsOne(x => x.Dispatch, owned =>{ owned.Property(x => x.Channel) .HasColumnName("dispatch_channel") .HasMaxLength(30); owned.Property(x => x.DispatchedUtc) .HasColumnName("dispatched_utc"); owned.Property(x => x.ExternalDispatchId) .HasColumnName("external_dispatch_id") .HasMaxLength(100);});
By convention, an OwnsOne owned entity shares the
owner's table and receives a shadow primary key whose value
follows the owner key. It still has entity identity in EF
metadata even though that identity is hidden from the CLR type.
3. Inspect the hidden owned identity
var orderType = db.Model.FindEntityType(typeof(WorkOrder))!;var ownership = orderType.FindNavigation(nameof(WorkOrder.Dispatch))!.ForeignKey;var ownedType = ownership.DeclaringEntityType;Console.WriteLine($"Owned type: {ownedType.Name}");Console.WriteLine($"Is ownership: {ownership.IsOwnership}");Console.WriteLine($"Primary key: {string.Join(", ", ownedType.FindPrimaryKey()!.Properties.Select(p => p.Name))}");
This is the mechanism that complex types deliberately avoid. If value semantics are what the domain needs, complex types are generally the cleaner model in EF Core 10.
4. OwnsMany needs an identity per child
ServiceHub checklist items need ordering and stable identity inside the work order. The owner's key alone cannot distinguish several items, so configure an additional key component.
public sealed class ChecklistItem{ public int Id { get; private set; } public string Text { get; private set; } = string.Empty; public int DisplayOrder { get; private set; } public bool IsCompleted { get; private set; }}public sealed partial class WorkOrder{ private readonly List<ChecklistItem> _checklist = new(); public IReadOnlyCollection<ChecklistItem> Checklist => _checklist;}
builder.OwnsMany(x => x.Checklist, owned =>{ owned.ToTable("work_order_checklist"); owned.WithOwner().HasForeignKey("work_order_id"); owned.Property<int>("id"); owned.HasKey("work_order_id", "id"); owned.Property(x => x.Text) .HasColumnName("text") .HasMaxLength(300); owned.Property(x => x.DisplayOrder) .HasColumnName("display_order"); owned.Property(x => x.IsCompleted) .HasColumnName("is_completed");});
The composite key makes the child identity immutable within its owner. If items must move between work orders while keeping identity, model them as normal entities instead.
5. Migration evidence distinguishes table splitting from a child table
-- OwnsOne Dispatch is table-split into work_orders.ALTER TABLE work_orders ADD COLUMN dispatch_channel TEXT NOT NULL DEFAULT 'manual';-- OwnsMany Checklist gets its own lifecycle-bound table.CREATE TABLE work_order_checklist ( work_order_id INTEGER NOT NULL, id INTEGER NOT NULL, text TEXT NOT NULL, display_order INTEGER NOT NULL, is_completed INTEGER NOT NULL, PRIMARY KEY (work_order_id, id), FOREIGN KEY (work_order_id) REFERENCES work_orders(work_order_id) ON DELETE CASCADE);
This is conceptual DDL. Use the generated migration as authoritative for exact quoting/defaults and SQLite rebuild behavior.
6. Owned values are loaded with the owner
Owned entity types are part of the owner's aggregate shape and
are normally included when querying the owner; you do not write
Include just to retrieve an owned value.
Separate-table storage changes SQL joins, not ownership
semantics.
var order = await db.WorkOrders .SingleAsync(x => x.Id == id, ct);Console.WriteLine(order.Dispatch.Channel);foreach (var item in order.Checklist.OrderBy(x => x.DisplayOrder)) Console.WriteLine($"{item.DisplayOrder}: {item.Text}");
7. Deliberately wrong approach: share one owned instance between owners
Owned entity types use reference/identity semantics. Reusing one owned instance as if it were a freely copyable value can produce tracking/ownership conflicts because EF cannot treat one owned entity instance as simultaneously owned by different owners.
var shared = new DispatchMetadata();orderA.ReplaceDispatchForTest(shared);orderB.ReplaceDispatchForTest(shared);// This is not value-object sharing. The owned entity has ownership identity.await db.SaveChangesAsync(ct);
The safe repair depends on meaning: create a distinct owned instance for each owner, or use a complex type if the object is truly a value whose properties should copy structurally.
8. Nested ownership and separate tables are available—but complexity is a signal
An owned type can itself own another type, and an
OwnsOne can map to a separate table with
ToTable. Those capabilities are useful for legacy
schemas and aggregate boundaries, but deeply nested ownership
can produce hard-to-review migrations and queries. If the nested
object is value-like, complex types may remove hidden identity.
If it needs independent lifecycle, a normal entity may be
clearer.
9. Hands-on lab: compare complex and owned models
-
Keep Chapter 06 Lesson 1's
ServiceAddressas a complex type. -
Add
DispatchMetadatawithOwnsOneandChecklistItemwithOwnsMany. -
Generate/review the migration and identify which columns stay
on
work_ordersand which table/PK/FK is added. -
Inspect
IModeland print the hidden owned primary key/ownership FK. -
Query an order and verify owned data materializes without
Include. -
Modify one checklist item and inspect ChangeTracker entries;
compare with a change inside the complex
ServiceAddress. - Run the shared-owned-instance broken example in the disposable database/context and capture the tracking/model failure.
- Reset after the experiment.
Verification checklist
-
ServiceAddressremains a complex type with no entity key. -
DispatchMetadatais owned and carries hidden identity. -
ChecklistItemhas an owner-scoped composite key. - Ownership delete behavior is reviewed as lifecycle behavior, not blindly accepted.
- No independently-lived business object is hidden inside ownership.
Check your understanding
- Why does OwnsOne have a key even when the CLR type defines none?
- Why is the owner key insufficient for an OwnsMany collection?
- Can an owned type be stored in a separate table?
- Do owned types and complex types have the same identity semantics?
- Why might moving an owned child between owners be a modeling smell?
- When is a normal entity preferable to ownership?
Review the answers
Owned types are entity types; EF creates hidden identity, commonly using the owner key for OwnsOne.
Several children need distinct identity inside one owner, so an additional key component is required.
Yes. ToTable can store owned types separately while retaining ownership semantics.
No. Owned types have entity/reference identity; complex types have no independent identity and model values.
Owner identity is commonly part of the child key/lifecycle; transfer semantics may mean the child should have its own entity identity.
When the object needs independent lifecycle, references, permissions, queries, transfer, or identity outside the owner.
10. Production judgment and bridge
Owned types remain useful, especially for aggregate-bound child entities or legacy schemas, but EF Core 10 complex types are generally the better fit for true value objects. Review table width, child-table indexes, cascades, and migration churn. The next lesson moves structural data into JSON columns and makes provider-specific query/update translation observable.
Authoritative references
- Owned Entity Types - EF Core — ownership, implicit keys, OwnsMany, table splitting, separate tables, and limitations
- Complex Types - EF Core — value semantics and comparison with owned entity types
- Relationships - EF Core — ownership is built on relationship metadata and lifecycle rules
- Cascade Delete - EF Core — delete behavior and ownership lifecycle implications
- Change Tracking - EF Core — entity-state evidence for owned values