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.

Intermediate105–130 minutesfixup + disconnected-graph repair labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

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.

01

Observe navigation fixup when tracked foreign keys and navigations change.

02

Explain identity resolution and why one DbContext cannot track two instances with the same key.

03

Initialize collection navigations safely without creating fake reference entities.

04

Diagnose duplicate-instance exceptions caused by disconnected serialization/deserialization.

05

Compare Attach, Update, query-then-apply, and TrackGraph for disconnected graphs.

06

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.

csharp · change navigation and observe both sides
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

csharp · FK-driven fixup
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.

csharp · duplicate tracked instance failure
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

csharp · unsafe disconnected graph update
[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

csharp · explicit disconnected update boundary
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.

csharp · selective attach sketch
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.

csharp · TrackGraph state policy sketch
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.

csharp · tracker duplicate evidence
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

  1. Seed two technicians, one work order, three tags, and one SLA snapshot.
  2. Run a tracking query with technician/tags and print object-reference/FK/fixup evidence.
  3. Reassign the work order and inspect both technicians' collections plus DebugView.
  4. Create a second WorkOrder instance with the same key and attempt to attach it; capture the duplicate-tracking InvalidOperationException.
  5. Simulate a partial DTO containing only one of three tags; demonstrate why replacing the authoritative collection is ambiguous.
  6. Implement an explicit AddTagsCommand and apply only additions.
  7. Implement the reassign command with query-then-apply and the Chapter 04 revision token.
  8. Run the same read with AsNoTracking and AsNoTrackingWithIdentityResolution; compare instance/tracker evidence.
  9. 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

  1. What is relationship fixup?
  2. Why does EF reject two tracked instances with the same key?
  3. Why is db.Update(incomingGraph) dangerous for APIs?
  4. What does a missing collection item in JSON mean?
  5. When is TrackGraph useful?
  6. 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

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