Chapter 11 · Change Tracking, Entity States, Identity Resolution, and Disconnected Graphs
Attach, Update, Entry, TrackGraph, and Selective Changes in Disconnected Applications
Handle disconnected API/message graphs with Attach, Update, Entry, TrackGraph, explicit concurrency originals, property allow-lists, and over-posting defenses.
Learning outcomes
An HTTP request or message consumer receives a work-order
payload that was created outside the current
DbContext. EF has no original snapshot for that
object. This is a disconnected workflow: the
server must decide which rows already exist, which values are
trustworthy, which properties may change, how concurrency is
enforced, and how much of the graph should become tracked.
Distinguish Attach, Update, Entry.State, property IsModified, and TrackGraph responsibilities.
Use generated-key IsKeySet information without treating it as authorization or existence proof.
Expose why Update(graph) can create broad writes and over-posting risk.
Apply a command DTO to a queried tracked aggregate as the default safe repair.
Use a selective attach pattern only when trusted key/original/concurrency data are available.
Use TrackGraph for mixed new/existing graphs while resolving duplicate identities and validating every state decision.
Mandatory labs use .NET SDK 10.0.400, .NET runtime 10.0.11, Microsoft.EntityFrameworkCore/SQLite 10.0.11, dotnet-ef 10.0.11, the disposable servicehub-lab.db, deterministic ServiceHub seed data, and no paid tooling. EF Core 11 preview APIs are not part of the mandatory path.
1. Disconnected means the original tracker is gone
In a request/message architecture, the context that originally
read an entity has usually been disposed before the client
returns changes. The new context receives a CLR object but not
the old context's original snapshots, relationship state, shadow
values, or authorization decision. Attach and
Update can reconstruct tracking state, but they
cannot reconstruct business intent automatically.
| API | Initial declaration | Important consequence |
|---|---|---|
Attach(root) |
Existing graph, generally Unchanged; generated-key unset nodes can be treated as new by graph rules. | No broad update until you mark changes. |
Update(root) |
Existing keyed graph is generally Modified; generated-key unset nodes become Added. | Can write many columns/rows. |
Entry(entity).State |
State for one entry. | Does not traverse the full graph like Add/Attach/Update. |
Property(...).IsModified |
One property should be written. | Narrow but requires trusted originals/concurrency. |
TrackGraph |
Your callback decides state while traversing a graph. | Powerful; correctness burden is yours. |
2. Deliberately wrong: deserialize entities and call Update
// Anti-pattern: reconstruct a persistence entity from client-controlled values// and declare the entire detached instance modified.var posted = WorkOrder.RehydrateForAssignment(payload.Id, payload.Revision);posted.ReviseSummary(payload.Summary);db.Update(posted);await db.SaveChangesAsync(ct);
The client may submit values it was never authorized to edit. Missing/default fields can overwrite database values. Related objects in the graph can be marked Added/Modified/Deleted according to graph state. This is both a persistence problem and an API security problem.
UPDATE work_ordersSET customer_name = @p0, priority = @p1, summary = @p2, assigned_technician_id = @p3, revision = @p4, ...WHERE work_order_id = @id AND revision = @originalRevision;
3. Preferred repair: command DTO → query → apply explicit domain intent
public sealed record ReviseWorkOrderCommand( int WorkOrderId, string Summary, Guid Revision);public async Task ReviseAsync(ReviseWorkOrderCommand command, CancellationToken ct){ var order = await db.WorkOrders .SingleAsync(x => x.Id == command.WorkOrderId, ct); AuthorizeRevision(order); // application authorization, not EF db.Entry(order).Property(x => x.Revision).OriginalValue = command.Revision; order.ReviseSummary(command.Summary); order.AdvanceRevision(); await db.SaveChangesAsync(ct);}
This path loads one tracked instance, keeps server-owned values out of the payload, reuses domain validation, and preserves Chapter 04's optimistic-concurrency contract. The extra SELECT is often an excellent correctness trade.
4. Selective Attach can avoid a read, but it raises the evidence burden
var stub = WorkOrder.RehydrateForAssignment( command.WorkOrderId, command.Revision);db.Attach(stub); // existing row: Unchangedvar entry = db.Entry(stub);entry.Property(x => x.Revision).OriginalValue = command.Revision;entry.Property(x => x.AssignedTechnicianId).CurrentValue = command.TechnicianId;entry.Property(x => x.AssignedTechnicianId).IsModified = true;stub.AdvanceRevision();await db.SaveChangesAsync(ct);
This pattern assumes the key is trusted, the target row should exist, authorization has already been established without loading it, the FK target is valid, and the submitted concurrency token is the correct original. If those assumptions are awkward to prove, query first.
5. IsKeySet helps classify generated-key nodes, not authorize them
var entry = db.Entry(candidate);Console.WriteLine($"Key set: {entry.IsKeySet}");
For generated keys, an unset key is a useful signal that a disconnected node is probably new. A set key does not prove the row exists, belongs to the current tenant/user, or is safe to modify. Those are database/application checks.
6. TrackGraph makes the state policy explicit
db.ChangeTracker.TrackGraph(root, node =>{ var entry = node.Entry; if (entry.Entity is WorkOrder) { entry.State = EntityState.Unchanged; // root policy handled explicitly later return; } entry.State = entry.IsKeySet ? EntityState.Unchanged : EntityState.Added;});// After traversal, apply only the properties/relationships authorized by the command.var rootEntry = db.Entry(root);rootEntry.Property(x => x.AssignedTechnicianId).IsModified = true;
TrackGraph walks a graph and invokes your callback
before the graph is fully tracked. That is useful when mixed
new/existing nodes need custom decisions. It is not a magic
“merge graph” API: your callback must resolve duplicates,
validate ownership, handle deletions intentionally, and set
concurrency originals where needed.
7. Duplicate identities should be consolidated before tracking
Serialized graphs often repeat the same entity in multiple
navigation paths. If two distinct Tag or
WorkOrder objects carry the same key and disagree
about values, EF cannot safely choose one. Prefer DTO contracts
that avoid cyclic entity graphs, or consolidate duplicates by
key before calling tracking APIs. The one-instance-per-key
invariant from Lesson 3 still applies.
The conflict is evidence that the incoming graph is ambiguous. Resolve the graph/data contract; do not detach whichever instance happens to throw first.
8. Observe exactly what a disconnected attach would write
db.ChangeTracker.DetectChanges();Console.WriteLine(db.ChangeTracker.DebugView.LongView);foreach (var e in db.ChangeTracker.Entries()){ Console.WriteLine($"{e.Metadata.ClrType.Name}: {e.State}"); foreach (var p in e.Properties.Where(p => p.IsModified)) Console.WriteLine($" WRITE {p.Metadata.Name}: {p.CurrentValue}");}
Make this audit part of tests for risky disconnected flows. The generated SQL command log is the next layer of evidence: verify which columns are actually sent, which original concurrency tokens appear in predicates, and whether unexpected inserts/deletes were generated.
9. Hands-on lab: broad Update versus selective intent
- Reset the disposable database and choose one deterministic work order.
- Construct a detached teaching graph with an existing root and one new child note.
-
Call
Update(graph)without saving; inspect states/modified properties and record how broad the write would be. - Clear/reset the context; implement query → command application and compare states/SQL.
- Implement the trusted selective-attach assignment example and verify only the FK + revision are written.
-
Build a mixed graph and apply a
TrackGraphcallback; assert each node state before save. - Inject a duplicate same-key node and verify your pre-tracking validation rejects/consolidates it.
Check your understanding
- What information is missing when an entity arrives disconnected?
- Why can Update(graph) over-write too much?
- What is the safest default update boundary for an API?
- What does IsKeySet prove?
- When is selective Attach reasonable?
- What responsibility does TrackGraph leave to you?
Review the answers
The original context tracker, including snapshots, relationship state, shadow values, and server-side authorization intent.
It broadly declares existing keyed entities/properties modified according to graph rules rather than inferring an allow-listed client intent.
A command/DTO, a server query for the tracked aggregate, authorization/validation, explicit domain changes, then SaveChanges.
Only that EF considers the generated key set; it does not prove database existence or authorization.
When key, original/concurrency values, permitted property set, and related-row validity are trusted/validated without requiring a read.
The state policy for every node, duplicate resolution, ownership/security checks, deletion semantics, and concurrency originals.
10. Production judgment and bridge
Disconnected writes are security and consistency boundaries, not
merely EF syntax. Prefer narrow commands and tracked server-side
intent. Use selective attach or TrackGraph only
when their assumptions are explicit and tested. Lesson 5
provides the forensic tools for when the state you expected and
the SQL EF generated still disagree.
Authoritative references
- Disconnected Entities - EF Core — query/update patterns, generated keys, graph handling.
- Explicitly Tracking Entities - EF Core — Attach/Update graph behavior and key semantics.
- Identity Resolution - EF Core — duplicate same-key instance constraints and serialized graphs.
- ChangeTracker.TrackGraph API — custom graph traversal/state callbacks.
- ASP.NET Core request security concepts — application security boundary context; EF tracking is not authorization.