Chapter 05 · Relationships, Navigations, Cascade Behavior, and Many-to-Many Modeling
Navigation Fixup, Relationship State Changes, Disconnected Graphs, and Modeling Pitfalls
Make relationship fixup, identity resolution, duplicate-instance failures, and disconnected graph updates observable, then replace blanket graph updates with explicit API intent.
Learning outcomes
In ServiceHub, EF's ChangeTracker is an identity map plus state
manager. Once relationships exist, it also keeps FK values and
navigations consistent among tracked entities. That convenience
becomes dangerous when an API deserializes duplicate entity
instances, partial collections, or a whole graph and then calls
Update without deciding what each node means. This
lesson makes fixup and graph-state transitions observable and
establishes a safer disconnected-update workflow.
Observe navigation fixup when tracked foreign keys and navigations change.
Explain identity resolution and why one DbContext cannot track two instances with the same key.
Initialize collection navigations safely without creating fake reference entities.
Diagnose duplicate-instance exceptions caused by disconnected serialization/deserialization.
Compare Attach, Update, query-then-apply, and TrackGraph for disconnected graphs.
Avoid interpreting omitted/partial relationships in a DTO as authoritative delete instructions.
1. Fixup keeps a tracked graph internally consistent
When EF materializes a technician and its work orders in a tracking query, it sets reference and collection navigations based on FK values. If a related entity is later tracked, EF can connect it to existing tracked entities. When you change an FK or navigation, fixup updates the inverse side where applicable.
var techA = await db.Technicians .Include(x => x.WorkOrders) .SingleAsync(x => x.EmployeeCode == "TECH-014", ct);var techB = await db.Technicians .Include(x => x.WorkOrders) .SingleAsync(x => x.EmployeeCode == "TECH-027", ct);var order = techA.WorkOrders.Single(x => x.WorkOrderNumber == "WO-2026-000201");order.AssignTo(techB);Console.WriteLine($"FK: {order.AssignedTechnicianId}");Console.WriteLine($"A contains order: {techA.WorkOrders.Contains(order)}");Console.WriteLine($"B contains order: {techB.WorkOrders.Contains(order)}");Console.WriteLine(db.ChangeTracker.DebugView.LongView);
Exact collection behavior depends on the domain collection
implementation and mapping, but EF relationship fixup attempts
to align tracked navigations with FK state. The database is
unchanged until SaveChanges.
2. Changing the FK can also change the navigation
var order = await db.WorkOrders .Include(x => x.AssignedTechnician) .SingleAsync(x => x.Id == id, ct);var techB = await db.Technicians.SingleAsync(x => x.Id == replacementId, ct);// If the FK setter is exposed internally for this test:db.Entry(order).Property(x => x.AssignedTechnicianId).CurrentValue = techB.Id;db.ChangeTracker.DetectChanges();Console.WriteLine(order.AssignedTechnician?.EmployeeCode);Console.WriteLine(db.ChangeTracker.DebugView.LongView);
In domain code, prefer one clear operation
(AssignTo) rather than independently setting FK and
navigation in different layers. Two sources of truth create bugs
when disconnected objects are incomplete.
3. Identity resolution: one key, one tracked instance
For a given entity type/key, a DbContext tracks one
instance. This ensures EF has one set of property and
relationship values to persist. Attaching another instance with
the same key throws.
var tracked = await db.WorkOrders.SingleAsync(x => x.Id == 42, ct);var incoming = new WorkOrderDto{ Id = 42, Summary = "Updated from API"};// Bad pattern: materialize a second WorkOrder instance with Id=42// and then db.Update(secondInstance) while tracked is still present.// EF throws InvalidOperationException for duplicate key tracking.
This is not an arbitrary restriction. If two objects with the same key disagree about technician, tags, notes, summary, or concurrency token, EF cannot know which graph is authoritative.
4. Deliberately wrong API pattern: deserialize graph, then Update everything
[HttpPut("{id:int}")]public async Task<IActionResult> Put(int id, WorkOrder incoming, CancellationToken ct){ db.Update(incoming); // marks a wide reachable graph for persistence await db.SaveChangesAsync(ct); return NoContent();}
Problems include over-posting fields the caller should not control, inserting nodes that merely lack generated keys, marking unrelated nodes modified, duplicate tracked instances, concurrency-token mishandling, and ambiguous meaning for missing collection members. A missing tag in JSON might mean “unchanged/not loaded” or “remove it”; EF cannot infer API intent.
5. Safer pattern: command DTO → query tracked aggregate → apply explicit intent
public sealed record ReassignWorkOrderCommand( int WorkOrderId, int? TechnicianId, Guid Revision);public async Task ReassignAsync( ReassignWorkOrderCommand command, CancellationToken ct){ var order = await db.WorkOrders .SingleAsync(x => x.Id == command.WorkOrderId, ct); db.Entry(order).Property(x => x.Revision).OriginalValue = command.Revision; Technician? technician = command.TechnicianId is null ? null : await db.Technicians.SingleAsync(x => x.Id == command.TechnicianId, ct); order.AssignTo(technician); order.AdvanceRevision(); await db.SaveChangesAsync(ct);}
The API expresses a specific action. The server decides which fields/relationships may change, loads one tracked instance, enforces authorization, and preserves Chapter 04 concurrency semantics. This often costs a database read, but buys clear correctness.
6. Attach can be appropriate when you know exact state
Attach begins tracking an existing entity as
Unchanged. You can then mark selected properties
modified. This can avoid a read, but the caller must supply
enough trusted key/original/concurrency information, and
relationship changes must still be explicit.
var stub = WorkOrder.RehydrateForAssignment( command.WorkOrderId, command.Revision);db.Attach(stub);db.Entry(stub).Property(x => x.Revision).OriginalValue = command.Revision;// Explicitly set only the relationship FK intended by this command.db.Entry(stub).Property(x => x.AssignedTechnicianId).CurrentValue = command.TechnicianId;db.Entry(stub).Property(x => x.AssignedTechnicianId).IsModified = true;stub.AdvanceRevision();await db.SaveChangesAsync(ct);
This is an advanced pattern: validation, authorization, FK existence, and concurrency must still be handled. The simplest correct approach is often query-then-apply.
7. Partial collections are not deletion lists
Suppose a mobile client sends two tags because it only
downloaded the first page. Replacing a loaded
WorkOrder.Tags collection with those two items can
sever valid relationships that were never in the payload. The
API contract must distinguish:
| Payload meaning | Server action |
|---|---|
| “Here are all desired tag IDs” | Load authoritative current associations, compute add/remove diff, validate, apply diff. |
| “Add these tag IDs” | Only add listed associations. |
| “Remove these tag IDs” | Only remove listed associations. |
| “Here are tags I happened to load” | Do not infer relationship changes. |
8. TrackGraph is a tool, not an intent oracle
ChangeTracker.TrackGraph visits a disconnected
graph and lets code choose the state of each node before it is
tracked. It can help with known graph protocols, but your
callback must still resolve duplicates and encode business
meaning.
db.ChangeTracker.TrackGraph(root, node =>{ var entry = node.Entry; if (entry.IsKeySet) entry.State = EntityState.Unchanged; else entry.State = EntityState.Added;});
This simplistic policy does not mark intended updates, handle deletions, authorize relationships, or merge duplicates. Real protocols need explicit state flags/original values or a server-side reconciliation algorithm.
9. Resolve duplicates before tracking
Serializers can create multiple instances with the same key.
Prefer serialization/reference-handling options that preserve
identity when possible. Otherwise consolidate duplicates before
calling EF tracking APIs or use a deliberate
TrackGraph resolution strategy. If duplicate
instances disagree, you need a merge/conflict policy;
arbitrarily discarding one can lose data.
foreach (var entry in db.ChangeTracker.Entries()){ var key = entry.Metadata.FindPrimaryKey()!; var values = key.Properties .Select(p => entry.Property(p.Name).CurrentValue); Console.WriteLine($"{entry.Metadata.DisplayName()} [{string.Join(",", values)}] {entry.State}");}
10. Tracking, no-tracking, and identity resolution are different modes
Normal tracking queries perform identity resolution and reuse
tracked instances. AsNoTracking does not track and
generally does not perform identity resolution.
AsNoTrackingWithIdentityResolution uses a temporary
standalone tracker to deduplicate result instances without
attaching them to the context. These choices matter when
projecting/serializing graphs, but no-tracking results are not
automatically safe to attach back into a context.
11. Hands-on lab: diagnose and repair a disconnected graph
- Seed two technicians, one work order, three tags, and one SLA snapshot.
- Run a tracking query with technician/tags and print object-reference/FK/fixup evidence.
- Reassign the work order and inspect both technicians' collections plus DebugView.
-
Create a second
WorkOrderinstance with the same key and attempt to attach it; capture the duplicate-trackingInvalidOperationException. - Simulate a partial DTO containing only one of three tags; demonstrate why replacing the authoritative collection is ambiguous.
-
Implement an explicit
AddTagsCommandand apply only additions. - Implement the reassign command with query-then-apply and the Chapter 04 revision token.
-
Run the same read with
AsNoTrackingandAsNoTrackingWithIdentityResolution; compare instance/tracker evidence. - Reset the disposable database after intentionally broken graph experiments.
Verification checklist
- Only one instance per entity key is tracked in a context.
- API payload semantics distinguish partial data from authoritative relationship replacement.
- Relationship writes are explicit and authorized.
- Concurrency original values are not lost during disconnected updates.
-
No broad
Update(graph)is used as a generic API persistence strategy.
Check your understanding
- What is relationship fixup?
- Why does EF reject two tracked instances with the same key?
- Why is db.Update(incomingGraph) dangerous for APIs?
- What does a missing collection item in JSON mean?
- When is TrackGraph useful?
- How does AsNoTrackingWithIdentityResolution differ from normal tracking?
Review the answers
Synchronization of FK values and navigations among tracked entities as relationships are discovered or changed.
EF needs one unambiguous set of property/relationship values for each tracked identity.
It can mark a broad reachable graph for insert/update and cannot infer authorization, payload completeness, or relationship intent.
Nothing universal; the API contract must say whether omission means unchanged, remove, or simply not loaded.
When a disconnected graph follows a known protocol and code needs to choose state for each node before tracking; it still requires business/state rules.
It deduplicates result instances using a temporary tracker but does not attach them to the context for SaveChanges.
12. Chapter 05 production judgment and bridge
Relationships are a coordinated contract across object graphs, EF metadata, database foreign keys, unique constraints, delete actions, concurrency state, and API semantics. Keep those layers explicit. A clean object navigation does not excuse missing database constraints; a valid database FK does not make a disconnected graph safe to update blindly.
Chapter 06 will build richer value/object shapes on top of these relationship foundations: complex types, owned entity types, JSON mapping, value converters/comparers, and encapsulation choices.
Authoritative references
- Changing Foreign Keys and Navigations — relationship fixup, FK/navigation changes, orphans, and many-to-many tracker behavior
- Identity Resolution - EF Core — single-instance tracking and disconnected duplicate resolution
- Tracking vs. No-Tracking Queries — tracking, no-tracking, and AsNoTrackingWithIdentityResolution
- Explicitly Tracking Entities — Attach/Update/TrackGraph and entity-state behavior
- Saving Related Data - EF Core — related inserts, relationship changes, and removal side effects