Welcome to Chapter 7! So far, we've focused on querying data. Now it's time to dive deep into how EF Core tracks changes and saves data. Understanding the Change Tracker and entity states is crucial for building robust applications, especially in web scenarios where entities are disconnected. In this chapter, we'll master everything from basic CRUD operations to complex graph handling and concurrency. ---
7.1 Understanding the Change Tracker
The Change Tracker is the brain of EF Core. It monitors every entity that's loaded from or connected to the database, keeping track of what changes were made so that when you call SaveChanges(), it knows exactly what SQL to generate.
What is the Change Tracker?
The Change Tracker is a component of the DbContext that:
- Maintains references to all entities being tracked
- Records the original values when entities are loaded
- Detects changes to property values
- Manages relationships between entities
- Determines the appropriate SQL commands (INSERT, UPDATE, DELETE) when saving
Entity States
Every tracked entity has a state that indicates its relationship with the database:
| State | Description | Database Action on SaveChanges() |
|---|---|---|
| Detached | Entity is not being tracked by the DbContext | No action (not tracked) |
| Unchanged | Entity is tracked but has not been modified since it was loaded | No SQL generated |
| Added | Entity is tracked and will be inserted into the database | INSERT |
| Modified | Entity is tracked and some of its properties have been changed | UPDATE |
| Deleted | Entity is tracked and will be removed from the database | DELETE |
Visualizing State Transitions:
┌─────────────┐
│ Detached │
└──────┬──────┘
│ Add() / Attach() / Entry(...).State = ...
▼
┌─────────────┐ Modify property ┌─────────────┐
│ Added │──────────────────────────▶│ Modified │
└─────────────┘ └─────────────┘
│ │
│ SaveChanges() │ SaveChanges()
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Unchanged │◀──────────────────────────│ Unchanged │
└─────────────┘ After SaveChanges └─────────────┘
│
│ Remove()
▼
┌─────────────┐
│ Deleted │
└─────────────┘
│
│ SaveChanges()
▼
(Detached)
7.2 Exploring the Change Tracker
Let's see the Change Tracker in action with some code:
using var db = new AppDbContext();
// Clear and recreate database for clean demo
db.Database.EnsureDeleted();
db.Database.Migrate();
Console.WriteLine("1. Query a book (this will track it)");
var book = db.Books.FirstOrDefault();
if (book == null)
{
// Add a sample book if none exists
book = new Book
{
Title = "Sample Book",
Price = 19.99m,
PublishedOn = DateTime.Now,
Genre = "Sample"
};
db.Books.Add(book);
db.SaveChanges();
Console.WriteLine("Created sample book with Id: " + book.Id);
// Re-query to get a tracked entity
book = db.Books.FirstOrDefault();
}
Console.WriteLine("\n2. Check initial state");
var entry = db.Entry(book);
Console.WriteLine($"Entity State: {entry.State}");
Console.WriteLine($"Is Tracked: {entry.IsKeySet}");
Console.WriteLine("\n3. Modify a property");
book.Price = 24.99m;
entry = db.Entry(book);
Console.WriteLine($"Entity State after modification: {entry.State}");
Console.WriteLine("\n4. Examine property changes");
foreach (var prop in entry.Properties)
{
if (prop.IsModified)
{
Console.WriteLine($" Property {prop.Metadata.Name}:");
Console.WriteLine($" Original: {prop.OriginalValue}");
Console.WriteLine($" Current: {prop.CurrentValue}");
}
}
Console.WriteLine("\n5. Save changes");
int records = db.SaveChanges();
Console.WriteLine($"Records saved: {records}");
entry = db.Entry(book);
Console.WriteLine($"Entity State after save: {entry.State}");
Console.WriteLine("\n6. Mark as Deleted");
db.Books.Remove(book);
entry = db.Entry(book);
Console.WriteLine($"Entity State after Remove: {entry.State}");
Output showing Change Tracker in action:
1. Query a book (this will track it)
Created sample book with Id: 1
2. Check initial state
Entity State: Unchanged
Is Tracked: True
3. Modify a property
Entity State after modification: Modified
4. Examine property changes
Property Price:
Original: 19.99
Current: 24.99
5. Save changes
Records saved: 1
Entity State after save: Unchanged
6. Mark as Deleted
Entity State after Remove: Deleted
7.3 Insert Operations
7.3.1 Adding a Single Entity
using var db = new AppDbContext();
// Method 1: Add to DbSet
var book1 = new Book
{
Title = "New Book 1",
Price = 29.99m,
PublishedOn = DateTime.Now,
Genre = "Fiction"
};
db.Books.Add(book1);
// Method 2: Add through DbContext
var book2 = new Book
{
Title = "New Book 2",
Price = 19.99m,
PublishedOn = DateTime.Now,
Genre = "Fiction"
};
db.Add(book2); // DbContext will figure out the DbSet from the type
// Method 3: Set state directly
var book3 = new Book
{
Title = "New Book 3",
Price = 39.99m,
PublishedOn = DateTime.Now,
Genre = "Fiction"
};
db.Entry(book3).State = EntityState.Added;
Console.WriteLine("Before SaveChanges:");
Console.WriteLine($"book1 state: {db.Entry(book1).State}");
Console.WriteLine($"book2 state: {db.Entry(book2).State}");
Console.WriteLine($"book3 state: {db.Entry(book3).State}");
int records = db.SaveChanges();
Console.WriteLine($"\n{records} records inserted");
Console.WriteLine("\nAfter SaveChanges:");
Console.WriteLine($"book1 state: {db.Entry(book1).State}");
Console.WriteLine($"book1 Id: {book1.Id}"); // Auto-generated identity value populated
// SQL Generated:
// INSERT INTO [Books] ([Title], [Price], [PublishedOn], [Genre], ...)
// VALUES (@p0, @p1, @p2, @p3, ...);
// SELECT [Id] FROM [Books] WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity();
7.3.2 Adding Multiple Entities (AddRange)
using var db = new AppDbContext(); var books = new List{ new Book { Title = "Batch Book 1", Price = 14.99m, Genre = "Fiction" }, new Book { Title = "Batch Book 2", Price = 24.99m, Genre = "Fiction" }, new Book { Title = "Batch Book 3", Price = 34.99m, Genre = "Fiction" } }; // AddRange adds all entities in one operation db.Books.AddRange(books); // Alternative: AddRange on DbContext db.AddRange(books); int records = db.SaveChanges(); Console.WriteLine($"Inserted {records} books"); // All SQL statements are batched into a single database round-trip: /* INSERT INTO [Books] ([Title], [Price], ...) VALUES (@p0, @p1, ...); INSERT INTO [Books] ([Title], [Price], ...) VALUES (@p2, @p3, ...); INSERT INTO [Books] ([Title], [Price], ...) VALUES (@p4, @p5, ...); SELECT [Id] FROM [Books] WHERE @@ROWCOUNT = 1 AND [Id] = scope_identity(); ... */
7.3.3 Adding Related Data (Graph Insert)
using var db = new AppDbContext();
// Method 1: Create entire graph and add
var author = new Author
{
FirstName = "Brandon",
LastName = "Sanderson",
Books = new List
{
new Book { Title = "The Way of Kings", Price = 29.99m, Genre = "Fantasy" },
new Book { Title = "Words of Radiance", Price = 32.99m, Genre = "Fantasy" }
}
};
db.Authors.Add(author); // Adding the principal adds the entire graph
db.SaveChanges();
Console.WriteLine($"Author Id: {author.Id}");
foreach (var book in author.Books)
{
Console.WriteLine($" Book: {book.Title}, Id: {book.Id}, AuthorId: {book.AuthorId}");
}
// SQL Generated:
// INSERT INTO Authors ...
// INSERT INTO Books ... (with AuthorId = new author's Id)
// INSERT INTO Books ... (with AuthorId = new author's Id)
// Method 2: Add dependent first with navigation property
var category = new Category { Name = "Epic Fantasy" };
var book1 = new Book
{
Title = "Mistborn",
Price = 24.99m,
Categories = new List { category } // Associate
};
db.Books.Add(book1);
db.SaveChanges();
// EF Core handles the join table automatically
7.3.4 Insert with Relationships - Various Approaches
using var db = new AppDbContext();
// Approach 1: Set navigation property
var author1 = new Author { FirstName = "Author", LastName = "One" };
var bookA = new Book { Title = "Book A", Author = author1 };
db.Add(bookA);
db.SaveChanges();
// Author is inserted first, then Book with AuthorId
// Approach 2: Set foreign key (if you know the Id)
var bookB = new Book
{
Title = "Book B",
AuthorId = author1.Id // Using existing author's Id
};
db.Add(bookB);
db.SaveChanges();
// Approach 3: Add to existing author's collection
var author2 = db.Authors.FirstOrDefault(a => a.LastName == "Sanderson");
if (author2 != null)
{
author2.Books.Add(new Book { Title = "Rhythm of War", Price = 35.99m });
db.SaveChanges(); // Just the book is inserted, author already exists
}
// Approach 4: Mixed - new book with existing category
var existingCategory = db.Categories.FirstOrDefault(c => c.Name == "Fantasy");
if (existingCategory != null)
{
var bookC = new Book
{
Title = "Oathbringer",
Categories = new List { existingCategory }
};
db.Books.Add(bookC);
db.SaveChanges();
// Book inserted, join table record created
}
7.4 Update Operations
7.4.1 Tracking Scenario (Entity loaded from same DbContext)
using var db = new AppDbContext();
// Load an entity (tracked)
var book = db.Books.FirstOrDefault(b => b.Title.Contains("Way of Kings"));
if (book != null)
{
// Modify properties
book.Price = 31.99m;
book.Genre = "Epic Fantasy";
// No need to call Update() - Change Tracker knows it's modified
int records = db.SaveChanges();
Console.WriteLine($"Updated {records} record(s)");
// SQL Generated:
// UPDATE [Books] SET [Price] = @p0, [Genre] = @p1
// WHERE [Id] = @p2;
// Only modified columns are included!
}
7.4.2 Disconnected Scenario (The Web API Challenge)
In web applications, entities are "disconnected" - they come from the client and aren't tracked by the DbContext that receives them.
// Imagine this is a PUT endpoint in your API
public IActionResult UpdateBook(int id, Book updatedBook)
{
using var db = new AppDbContext();
// updatedBook came from the client - it's DETACHED
Console.WriteLine($"State before: {db.Entry(updatedBook).State}"); // Detached
// Method 1: Update() - marks entire entity as Modified
db.Books.Update(updatedBook);
Console.WriteLine($"State after Update: {db.Entry(updatedBook).State}"); // Modified
// PROBLEM: This will update ALL columns, even if they weren't changed!
// If the client didn't send some properties, they might be overwritten with defaults
db.SaveChanges();
// Method 2: Attach and set specific properties (better)
var existingBook = db.Books.Find(id);
if (existingBook == null) return NotFound();
// Update only the properties that actually changed
existingBook.Title = updatedBook.Title;
existingBook.Price = updatedBook.Price;
// Leave other properties untouched
db.SaveChanges();
return Ok();
}
7.4.3 Different Update Strategies
using var db = new AppDbContext();
// Strategy 1: Update entire entity (simple but dangerous)
var bookFromClient = new Book
{
Id = 1, // Must include the Id
Title = "Updated Title",
Price = 25.99m
// Genre, PublishedOn, etc. are null/default!
};
db.Books.Update(bookFromClient);
// SQL: UPDATE Books SET Title = '...', Price = 25.99, Genre = NULL, PublishedOn = NULL, ...
// This will wipe out any properties not set in bookFromClient!
// Strategy 2: Update with DTO (Data Transfer Object)
public class UpdateBookDto
{
public int Id { get; set; }
public string Title { get; set; }
public decimal? Price { get; set; } // Nullable means only update if provided
}
public void UpdateBookPartial(UpdateBookDto dto)
{
using var db = new AppDbContext();
var book = db.Books.Find(dto.Id);
if (book == null) return;
if (dto.Title != null)
book.Title = dto.Title;
if (dto.Price.HasValue)
book.Price = dto.Price.Value;
db.SaveChanges(); // Only modified properties are updated
}
// Strategy 3: Attach and set Modified for specific properties
var book2 = new Book { Id = 1 };
db.Books.Attach(book2); // Attached as Unchanged
book2.Title = "New Title";
db.Entry(book2).Property(b => b.Title).IsModified = true;
db.SaveChanges(); // Only Title is updated
// Strategy 4: Use original values
var book3 = new Book { Id = 1, Title = "Original", Price = 10 };
var entry3 = db.Entry(book3);
entry3.State = EntityState.Unchanged; // Mark as unchanged
entry3.Property(b => b.Title).CurrentValue = "Modified";
entry3.Property(b => b.Title).IsModified = true;
db.SaveChanges(); // Only Title is updated
7.4.4 Updating Related Data
using var db = new AppDbContext();
// Update one-to-many relationship
var book = db.Books
.Include(b => b.Author)
.FirstOrDefault(b => b.Id == 1);
if (book != null)
{
// Change author
var newAuthor = db.Authors.FirstOrDefault(a => a.LastName == "Sanderson");
book.Author = newAuthor; // Or book.AuthorId = newAuthor.Id
db.SaveChanges();
}
// Update many-to-many (simplified approach)
var book2 = db.Books
.Include(b => b.Categories)
.FirstOrDefault(b => b.Id == 2);
if (book2 != null)
{
// Clear all categories
book2.Categories.Clear();
// Add new categories
var categories = db.Categories
.Where(c => new[] { "Fantasy", "Epic Fantasy" }.Contains(c.Name))
.ToList();
foreach (var cat in categories)
{
book2.Categories.Add(cat);
}
db.SaveChanges();
// EF Core handles the join table - removes old, inserts new
}
// Update with explicit join entity
var bookCategory = db.Set()
.FirstOrDefault(bc => bc.BookId == 1 && bc.CategoryId == 1);
if (bookCategory != null)
{
bookCategory.IsPrimary = true;
db.SaveChanges();
}
7.5 Delete Operations
7.5.1 Deleting Tracked Entities
using var db = new AppDbContext();
// Load the entity first (tracked)
var book = db.Books.FirstOrDefault(b => b.Id == 1);
if (book != null)
{
// Method 1: Remove from DbSet
db.Books.Remove(book);
// Method 2: Remove through DbContext
db.Remove(book);
// Method 3: Set state directly
db.Entry(book).State = EntityState.Deleted;
Console.WriteLine($"State before save: {db.Entry(book).State}"); // Deleted
int records = db.SaveChanges();
Console.WriteLine($"Deleted {records} record(s)");
// SQL Generated:
// DELETE FROM [Books] WHERE [Id] = @p0;
}
7.5.2 Deleting Disconnected Entities
using var db = new AppDbContext();
// You have an entity from client with just the Id
var bookFromClient = new Book { Id = 5 };
// Method 1: Attach and Remove
db.Books.Attach(bookFromClient); // Attach as Unchanged
db.Books.Remove(bookFromClient); // Mark as Deleted
db.SaveChanges();
// Method 2: Remove directly (EF Core will attach then remove)
db.Books.Remove(bookFromClient);
db.SaveChanges();
// Method 3: Set state directly
db.Entry(bookFromClient).State = EntityState.Deleted;
db.SaveChanges();
// All methods result in the same SQL: DELETE FROM Books WHERE Id = 5
7.5.3 Deleting Multiple Entities
using var db = new AppDbContext();
// Load entities first
var oldBooks = db.Books
.Where(b => b.PublishedOn < new DateTime(2000, 1, 1))
.ToList();
db.Books.RemoveRange(oldBooks);
db.SaveChanges();
// More efficient: ExecuteDelete (EF Core 7+)
int deleted = db.Books
.Where(b => b.PublishedOn < new DateTime(2000, 1, 1))
.ExecuteDelete();
Console.WriteLine($"Deleted {deleted} books directly in database");
// SQL Generated:
// DELETE FROM [Books]
// WHERE [PublishedOn] < '2000-01-01'
7.5.4 Delete with Relationships and Cascade Delete
using var db = new AppDbContext();
// This behavior depends on your DeleteBehavior configuration
var author = db.Authors
.Include(a => a.Books)
.FirstOrDefault(a => a.Id == 1);
if (author != null)
{
db.Authors.Remove(author);
// What happens to the books?
// - If DeleteBehavior.Cascade: All books are also deleted
// - If DeleteBehavior.SetNull: Books remain, AuthorId becomes NULL
// - If DeleteBehavior.Restrict: Exception if books exist
try
{
db.SaveChanges();
}
catch (DbUpdateException ex)
{
Console.WriteLine($"Cannot delete: {ex.Message}");
}
}
// Delete principal without loading dependents (EF Core 7+)
db.Authors
.Where(a => a.Id == 1)
.ExecuteDelete(); // Will fail if books exist and not cascade
7.6 Deep Dive into Change Tracker
7.6.1 Tracking Multiple Entities
using var db = new AppDbContext();
// Load some entities
var books = db.Books.Take(3).ToList();
var authors = db.Authors.Take(2).ToList();
var categories = db.Categories.Take(2).ToList();
// See what's being tracked
var trackedEntities = db.ChangeTracker.Entries().ToList();
Console.WriteLine($"Tracking {trackedEntities.Count} entities:");
foreach (var entry in trackedEntities)
{
Console.WriteLine($" {entry.Entity.GetType().Name} Id: {entry.Property("Id").CurrentValue}, State: {entry.State}");
}
// Check specific entity
var firstBook = books.First();
var entry1 = db.Entry(firstBook);
Console.WriteLine($"\nDetails for {firstBook.Title}:");
foreach (var prop in entry1.Properties)
{
Console.WriteLine($" {prop.Metadata.Name}: Original={prop.OriginalValue}, Current={prop.CurrentValue}, Modified={prop.IsModified}");
}
// Get all modified entities
var modified = db.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified || e.State == EntityState.Added || e.State == EntityState.Deleted)
.ToList();
Console.WriteLine($"\nModified entities count: {modified.Count}");
7.6.2 Original vs. Current Values
using var db = new AppDbContext();
var book = db.Books.FirstOrDefault();
if (book != null)
{
var entry = db.Entry(book);
Console.WriteLine("Original Values:");
foreach (var prop in entry.Properties)
{
Console.WriteLine($" {prop.Metadata.Name}: {prop.OriginalValue}");
}
// Change some values
book.Price += 5;
book.Title = book.Title + " (Updated)";
Console.WriteLine("\nCurrent Values:");
foreach (var prop in entry.Properties)
{
Console.WriteLine($" {prop.Metadata.Name}: {prop.CurrentValue} (Modified: {prop.IsModified})");
}
// Get database values (what's actually in the database right now)
var databaseValues = entry.GetDatabaseValues();
Console.WriteLine("\nDatabase Values:");
foreach (var prop in databaseValues.Properties)
{
Console.WriteLine($" {prop.Name}: {databaseValues[prop]}");
}
// Reload from database (discard changes)
entry.Reload();
Console.WriteLine($"\nAfter Reload - Price: {book.Price}");
}
7.6.3 Detecting Changes
using var db = new AppDbContext();
var book = db.Books.FirstOrDefault();
// Change tracker automatically detects changes on SaveChanges
// But you can also detect manually
book.Price = 999.99m;
var hasChanges = db.ChangeTracker.HasChanges();
Console.WriteLine($"Has changes: {hasChanges}");
// Manually detect changes (usually automatic, but useful for performance)
db.ChangeTracker.DetectChanges();
// You can also accept all changes (reset original values to current)
// db.ChangeTracker.AcceptAllChanges();
// Or clear the tracker
// db.ChangeTracker.Clear();
7.6.4 Working with Shadow Properties
// Configure a shadow property in OnModelCreating
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity()
.Property("LastUpdated");
modelBuilder.Entity()
.Property("CreatedBy");
}
// Using shadow properties
using var db = new AppDbContext();
var book = new Book { Title = "Shadow Book", Price = 19.99m };
db.Books.Add(book);
// Set shadow property values
db.Entry(book).Property("LastUpdated").CurrentValue = DateTime.Now;
db.Entry(book).Property("CreatedBy").CurrentValue = "Admin";
db.SaveChanges();
// Query using shadow properties
var recentBooks = db.Books
.Where(b => EF.Property(b, "LastUpdated") > DateTime.Now.AddDays(-1))
.ToList();
// Read shadow property
foreach (var b in recentBooks)
{
var lastUpdated = db.Entry(b).Property("LastUpdated").CurrentValue;
var createdBy = db.Entry(b).Property("CreatedBy").CurrentValue;
Console.WriteLine($"{b.Title} - Updated: {lastUpdated}, By: {createdBy}");
}
7.7 Disconnected Scenarios (Web API Focus)
This is one of the most important sections for web developers. In web applications, the DbContext is typically created per request, and entities received from the client are "disconnected" - they were created in a different context instance.
7.7.1 The Problem Illustrated
// Client sends this JSON to your API
// PUT /api/books/1
// {
// "id": 1,
// "title": "Updated Title",
// "price": 29.99
// }
[HttpPut("{id}")]
public IActionResult UpdateBook(int id, Book book)
{
if (id != book.Id)
return BadRequest();
using var db = new AppDbContext();
// book is DETACHED - created by JSON serializer, not tracked by this DbContext
Console.WriteLine($"State: {db.Entry(book).State}"); // Detached
// If you simply update, you'll have problems
db.Books.Update(book); // Marks ALL properties as modified
db.SaveChanges();
return Ok();
}
7.7.2 Solution 1: Load and Apply Changes
[HttpPut("{id}")]
public IActionResult UpdateBook(int id, Book book)
{
using var db = new AppDbContext();
// Load the existing entity from database
var existingBook = db.Books.Find(id);
if (existingBook == null)
return NotFound();
// Update only the properties that should change
existingBook.Title = book.Title;
existingBook.Price = book.Price;
// Leave other properties (like Genre, PublishedOn) untouched
db.SaveChanges();
return Ok(existingBook);
}
7.7.3 Solution 2: Use DTOs and AutoMapper
public class UpdateBookDto
{
public int Id { get; set; }
public string Title { get; set; }
public decimal? Price { get; set; } // Nullable means optional
public string Genre { get; set; }
}
[HttpPut("{id}")]
public IActionResult UpdateBook(int id, UpdateBookDto dto)
{
using var db = new AppDbContext();
var book = db.Books.Find(id);
if (book == null)
return NotFound();
// Update only properties that are provided
if (dto.Title != null)
book.Title = dto.Title;
if (dto.Price.HasValue)
book.Price = dto.Price.Value;
if (dto.Genre != null)
book.Genre = dto.Genre;
db.SaveChanges();
return Ok();
}
7.7.4 Solution 3: Track Graph with Custom Logic
public void UpdateBookGraph(Book bookFromClient)
{
using var db = new AppDbContext();
// Attach the entity as Unchanged
db.Books.Attach(bookFromClient);
// Explicitly mark properties as modified based on what changed
// This requires knowing what changed - maybe from client
var entry = db.Entry(bookFromClient);
// Assuming client tells us what changed (e.g., in a separate array)
var changedProperties = new[] { "Title", "Price" };
foreach (var propName in changedProperties)
{
entry.Property(propName).IsModified = true;
}
db.SaveChanges();
}
// More sophisticated - track original values
public void UpdateWithOriginalValues(Book bookFromClient, Book originalBook)
{
using var db = new AppDbContext();
// Attach the original version
db.Books.Attach(originalBook);
// Copy changed values from client
var entry = db.Entry(originalBook);
entry.CurrentValues.SetValues(bookFromClient);
// EF will automatically mark properties that are different as Modified
db.SaveChanges();
}
7.7.5 Handling Graphs of Entities
// Client sends an author with its books
public IActionResult UpdateAuthor(Author authorFromClient)
{
using var db = new AppDbContext();
// This is complex - need to handle:
// - Existing author? (Update)
// - New books? (Insert)
// - Removed books? (Delete)
// - Modified books? (Update)
// Option 1: Update the graph manually
var existingAuthor = db.Authors
.Include(a => a.Books)
.FirstOrDefault(a => a.Id == authorFromClient.Id);
if (existingAuthor == null)
return NotFound();
// Update author properties
existingAuthor.FirstName = authorFromClient.FirstName;
existingAuthor.LastName = authorFromClient.LastName;
// Handle books - simplistic approach: replace all
existingAuthor.Books.Clear();
foreach (var book in authorFromClient.Books)
{
book.AuthorId = existingAuthor.Id;
existingAuthor.Books.Add(book);
}
db.SaveChanges();
// Option 2: Use TrackGraph for complex graphs
db.ChangeTracker.TrackGraph(authorFromClient, e =>
{
if (e.Entry.IsKeySet)
{
e.Entry.State = EntityState.Modified;
}
else
{
e.Entry.State = EntityState.Added;
}
});
db.SaveChanges();
return Ok();
}
7.7.6 The TrackGraph Method
using var db = new AppDbContext();
// Complex graph with multiple entities
var author = new Author
{
Id = 1, // Existing author
FirstName = "Updated",
LastName = "Name",
Books = new List
{
new Book { Id = 10, Title = "Existing Book Modified", Price = 19.99m }, // Existing
new Book { Title = "New Book", Price = 24.99m } // New
}
};
// TrackGraph visits every entity in the graph
db.ChangeTracker.TrackGraph(author, node =>
{
var entry = node.Entry;
var entity = entry.Entity;
// Decide state based on whether Id is set
if (entry.IsKeySet)
{
// If key is set, assume it's existing
entry.State = EntityState.Modified;
// You could be more sophisticated
if (entity is Book book && book.Title.StartsWith("New"))
{
entry.State = EntityState.Added; // Actually it's new
}
}
else
{
entry.State = EntityState.Added;
}
});
db.SaveChanges();
// You can also use a dictionary to track changes
var states = new Dictionary
7.8 Concurrency Control
Concurrency issues occur when two users try to modify the same data simultaneously.
7.8.1 The Problem
// User A and User B load the same book at the same time // User A updates and saves // User B updates and saves - overwriting User A's changes without knowing // In code: using var db1 = new AppDbContext(); using var db2 = new AppDbContext(); var book1 = db1.Books.Find(1); var book2 = db2.Books.Find(1); // User A updates book1.Price = 30m; db1.SaveChanges(); // Succeeds // User B updates based on old data book2.Price = 25m; db2.SaveChanges(); // Succeeds and overwrites User A's change! // Last write wins - data loss!
7.8.2 Solution: Concurrency Tokens
// Add a concurrency token to your entity
public class Book
{
public int Id { get; set; }
public string Title { get; set; }
public decimal Price { get; set; }
// Concurrency token - SQL Server: rowversion/timestamp
[Timestamp]
public byte[] RowVersion { get; set; }
}
// Or configure with Fluent API
modelBuilder.Entity()
.Property(b => b.RowVersion)
.IsRowVersion();
// Now when updating:
using var db1 = new AppDbContext();
using var db2 = new AppDbContext();
var book1 = db1.Books.Find(1);
var book2 = db2.Books.Find(1);
// User A updates
book1.Price = 30m;
db1.SaveChanges(); // Succeeds, RowVersion changes
// User B updates based on old RowVersion
book2.Price = 25m;
try
{
db2.SaveChanges(); // Throws DbUpdateConcurrencyException
// RowVersion in database doesn't match the one book2 was loaded with
}
catch (DbUpdateConcurrencyException ex)
{
Console.WriteLine("Concurrency conflict detected!");
// Handle the conflict
var entry = ex.Entries.Single();
var databaseValues = entry.GetDatabaseValues();
Console.WriteLine("Your values:");
foreach (var prop in entry.Properties)
{
Console.WriteLine($" {prop.Metadata.Name}: {prop.CurrentValue}");
}
Console.WriteLine("Database values:");
foreach (var prop in databaseValues.Properties)
{
Console.WriteLine($" {prop.Name}: {databaseValues[prop]}");
}
}
7.8.3 Handling Concurrency Conflicts
public IActionResult UpdateBook(Book book)
{
using var db = new AppDbContext();
try
{
db.Books.Update(book);
db.SaveChanges();
return Ok();
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var databaseValues = entry.GetDatabaseValues();
if (databaseValues == null)
{
// The record was deleted by someone else
return NotFound("Book was deleted by another user");
}
var databaseBook = (Book)databaseValues.ToObject();
// Option 1: Client wins - use client values
// entry.OriginalValues.SetValues(databaseValues);
// entry.CurrentValues.SetValues(book);
// db.SaveChanges();
// Option 2: Database wins - reload and show user
entry.Reload();
var currentBook = entry.Entity;
// Option 3: Merge - let user decide
return Conflict(new
{
message = "Concurrency conflict",
yourValues = book,
currentValues = currentBook
});
}
}
7.8.4 Alternative: Row Version with Custom Property
public class Book
{
public int Id { get; set; }
public string Title { get; set; }
public decimal Price { get; set; }
// Custom concurrency token
[ConcurrencyCheck]
public string Version { get; set; } = Guid.NewGuid().ToString();
}
// Or with Fluent API
modelBuilder.Entity()
.Property(b => b.Version)
.IsConcurrencyToken();
// When updating, EF Core will include Version in WHERE clause
// UPDATE Books SET Title = @p0 WHERE Id = @p1 AND Version = @p2
// If no rows updated, concurrency exception thrown
7.9 Transactions and SaveChanges
7.9.1 Default Transaction Behavior
using var db = new AppDbContext();
// By default, each SaveChanges call is wrapped in a transaction
// All changes in this call are committed or rolled back together
db.Books.Add(new Book { Title = "Book 1", Price = 10 });
db.Books.Add(new Book { Title = "Book 2", Price = 20 });
db.SaveChanges(); // Single transaction, both inserts succeed or fail together
// If you need to, you can manually control transactions
using var transaction = await db.Database.BeginTransactionAsync();
try
{
db.Books.Add(new Book { Title = "Book 3", Price = 30 });
db.SaveChanges();
db.Authors.Add(new Author { FirstName = "New", LastName = "Author" });
db.SaveChanges();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}
7.9.2 Multiple SaveChanges in One Transaction
using var db = new AppDbContext();
using var transaction = db.Database.BeginTransaction();
try
{
// Operation 1
var book = new Book { Title = "New Book", Price = 25 };
db.Books.Add(book);
db.SaveChanges();
// Operation 2 - uses the book's ID
var review = new Review
{
BookId = book.Id,
Rating = 5,
ReviewerName = "User",
ReviewDate = DateTime.Now
};
db.Reviews.Add(review);
db.SaveChanges();
// Both operations succeed or fail together
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
7.9.3 SaveChangesOptions
using var db = new AppDbContext(); // AcceptAllChangesOnSave - Default behavior // After SaveChanges, entities become Unchanged db.SaveChanges(); // AcceptAllChangesAfterSave - Equivalent to default // DetectChangesBeforeSave - Manually detect changes before saving db.SaveChanges(SaveChangesOptions.DetectChangesBeforeSave); // AutoDetectChangesEnabled (configured on ChangeTracker) db.ChangeTracker.AutoDetectChangesEnabled = false; // For performance // Don't forget to enable it again for normal operations db.ChangeTracker.AutoDetectChangesEnabled = true;
7.10 Best Practices and Performance Tips
DO: Use Add/Update for simple scenarios
db.Books.Add(newBook); // Good for inserts db.Books.Update(existing); // Good for disconnected updates
DO: Use Find() for single entity by key
var book = db.Books.Find(id); // Checks local cache first
DO: Use ExecuteDelete/ExecuteUpdate for bulk operations (EF Core 7+)
// Instead of loading and deleting
db.Books.Where(b => b.Price > 100).ExecuteDelete();
// Instead of loading and updating
db.Books.Where(b => b.Genre == "Fantasy")
.ExecuteUpdate(s => s.SetProperty(b => b.Price, b => b.Price * 1.1m));
DON'T: Use Add/Update for disconnected graphs
// Bad - will try to insert everything as new db.Books.Add(authorWithBooks); // Will cause duplicates if author exists // Good - handle each entity state individually
DON'T: Call SaveChanges multiple times unnecessarily
// Bad - multiple database round trips
foreach (var item in items)
{
db.Books.Add(item);
db.SaveChanges(); // Don't do this!
}
// Good - single SaveChanges
db.Books.AddRange(items);
db.SaveChanges();
DON'T: Ignore concurrency exceptions
try { db.SaveChanges(); }
catch (DbUpdateConcurrencyException)
{
// Always handle concurrency appropriately
}
Chapter Summary
| Concept | Key Points |
|---|---|
| Entity States | Detached, Unchanged, Added, Modified, Deleted. Each determines SaveChanges behavior. |
| Change Tracker | Tracks entities, original values, changes. Use db.Entry() to access. |
| Insert | Add(), AddRange(). Related entities are inserted automatically. |
| Update (Tracked) | Just modify properties, Change Tracker detects changes. |
| Update (Disconnected) | Load first, then modify. Or use Update() but be careful. |
| Delete | Remove(), RemoveRange(). ExecuteDelete() for bulk. |
| Concurrency | Use [Timestamp] or [ConcurrencyCheck] to prevent lost updates. |
| Transactions | SaveChanges is transactional. Use BeginTransaction() for multiple SaveChanges. |
| Disconnected Graphs | Use TrackGraph() or manual logic to set states correctly. |
| Bulk Operations | ExecuteDelete() and ExecuteUpdate() for performance-critical scenarios. |
What's Next?
Congratulations! You've mastered data modification and state management in EF Core. You now understand how to handle both connected and disconnected scenarios, which is essential for building web applications.
In Chapter 8: Advanced Mapping and Inheritance, we will:
- Explore Value Conversions (store complex types as simple columns)
- Learn about Owned Types (value objects)
- Master Inheritance Mapping strategies (TPH, TPT, TPC)
- Work with JSON columns (EF Core 7+)
- Handle Spatial Data and other advanced types
Ready to take your EF Core skills to the next level?