Це продовження статті «Change Tracker». Читайте послідовно.
У веб-розробці з EF Core є фундаментальне розмежування між двома сценаріями роботи:
Connected — entity завантажується і змінюється в межах одного DbContext. Трекер бачить початковий стан і всі зміни — ніяких додаткових кроків не потрібно.
Disconnected — entity завантажується в одному запиті (або десеріалізується з HTTP тіла), передається клієнту, а потім повертається назад у новому HTTP-запиті. Новий DbContext не знає про попередній стан — entity є Detached.
Це розмежування критичне, бо HTTP природно є stateless: кожен запит — новий DbContext (Scoped). Тому більшість веб-сценаріїв є disconnected:
GET /api/orders/42
→ DbContext #1: завантажує Order #42, серіалізує в JSON, закривається
// Клієнт отримує JSON, редагує поле Status
PUT /api/orders/42
→ DbContext #2: отримує JSON, десеріалізує в новий Order об'єкт
→ Цей об'єкт DETACHED — DbContext #2 не знає що він завантажений
Розглянемо PUT endpoint — клієнт надіслав змінений Order. Як правильно зберегти?
Найнадійніший і найбезпечніший підхід: не довіряєте клієнту — завантажуєте актуальний стан з бази і оновлюєте лише те що дозволено:
[HttpPut("{id}")]
public async Task<IActionResult> UpdateOrder(int id, UpdateOrderDto dto)
{
// Крок 1: Завантажити актуальний стан з бази (Tracked)
var order = await context.Orders
.Include(o => o.LineItems)
.FirstOrDefaultAsync(o => o.Id == id);
if (order is null) return NotFound();
// Крок 2: Оновити лише дозволені поля (не довіряємо клієнту)
order.Status = dto.Status;
order.Notes = dto.Notes;
// order.CustomerId — НЕ оновлюємо (не можна змінити клієнта замовлення)
// order.CreatedAt — НЕ оновлюємо (незмінне)
// Крок 3: SaveChanges — Change Tracker знає що змінилось
await context.SaveChanges();
// UPDATE Orders SET Status=@s, Notes=@n WHERE Id=@id ← тільки змінене!
return NoContent();
}
Переваги: безпечний (ізоляція від клієнтських маніпуляцій), генерує мінімальний UPDATE, підтримує Optimistic Concurrency.
Недоліки: два звернення до бази (SELECT + UPDATE). Прийнятно для більшості OLTP-сценаріїв.
Коли SELECT занадто дорогий або даних нема у базі (перевіряти нічого):
[HttpPut("{id}")]
public async Task<IActionResult> UpdateOrderStatus(int id, string newStatus)
{
// Знаємо точно що хочемо оновити: лише Status
var order = new Order { Id = id, Status = newStatus };
context.Orders.Attach(order); // Detached → Unchanged
context.Entry(order).Property(o => o.Status).IsModified = true; // лише Status → Modified
try
{
await context.SaveChanges();
// UPDATE Orders SET Status=@s WHERE Id=@id ← без зайвого SELECT!
}
catch (DbUpdateConcurrencyException)
{
return NotFound(); // рядок не існує (0 affected)
}
return NoContent();
}
Переваги: один SQL (без SELECT), мінімальний UPDATE.
Недоліки: потрібно точно знати які поля змінено. Розробник несе відповідальність за коректність.
// Часто використовують, але це неоптимально:
[HttpPut("{id}")]
public async Task<IActionResult> UpdateOrder(int id, UpdateOrderDto dto)
{
var order = _mapper.Map<Order>(dto); // маппинг DTO → Entity
order.Id = id;
context.Orders.Update(order); // ← всі властивості Modified!
await context.SaveChanges();
// UPDATE Orders SET Status=@s, Notes=@n, CustomerId=@c, CreatedAt=@t, ... WHERE Id=@id
// Всі стовпці у SET — навіть ті що не змінювались!
}
Update позначає всі властивості як Modified — генерує «жирний» UPDATE. Також може перезаписати поля що клієнт не мав права змінювати (якщо CreatedAt, CustomerId є у DTO і маппер заповнив їх).
Disconnected сценарій ускладнюється коли entity має навігаційні властивості — дочірні колекції:
// Клієнт надіслав Order з оновленими LineItems:
// Старий: LineItems [Id=1, Id=2, Id=3]
// Новий: LineItems [Id=1 (змінений), Id=3 (незмінений), Id=4 (новий), Id=2 (видалений)]
[HttpPut("{id}")]
public async Task<IActionResult> UpdateOrderWithItems(int id, OrderUpdateDto dto)
{
// Завантажуємо актуальний стан з УСІМА рядками
var order = await context.Orders
.Include(o => o.LineItems)
.FirstOrDefaultAsync(o => o.Id == id);
if (order is null) return NotFound();
// Оновлюємо базові поля
order.Notes = dto.Notes;
// Знаходимо видалені рядки: є у БД, але немає у dto
var dtoItemIds = dto.LineItems.Select(li => li.Id).ToHashSet();
var itemsToRemove = order.LineItems
.Where(li => li.Id != 0 && !dtoItemIds.Contains(li.Id))
.ToList();
foreach (var item in itemsToRemove)
context.OrderLineItems.Remove(item);
// Оновлюємо існуючі та додаємо нові
foreach (var dtoItem in dto.LineItems)
{
var existing = order.LineItems.FirstOrDefault(li => li.Id == dtoItem.Id);
if (existing is not null)
{
// Існуючий: оновлюємо поля
existing.Quantity = dtoItem.Quantity;
existing.UnitPrice = dtoItem.UnitPrice;
}
else
{
// Новий: додаємо до колекції
order.LineItems.Add(new OrderLineItem
{
ProductId = dtoItem.ProductId,
Quantity = dtoItem.Quantity,
UnitPrice = dtoItem.UnitPrice
});
}
}
await context.SaveChanges();
// DELETE де потрібно, UPDATE де треба, INSERT для нових
return NoContent();
}
Одна з найпоширеніших помилок з ChangeTracker — спроба приєднати entity що вже відстежується:
// ПОМИЛКА: спроба прикріпити entity що вже трекується
var product1 = await context.Products.FindAsync(42); // Tracked: Id=42
var product2 = new Product { Id = 42, Name = "Updated" };
context.Products.Update(product2);
// InvalidOperationException: Cannot track entity because another instance
// with the same key value is already being tracked.
// Або:
context.Products.Attach(product2);
// Та сама помилка!
Причина: Identity Map не допускає двох різних C#-об'єктів з однаковим PK у трекері одночасно.
// Якщо відомо що entity може бути вже в трекері — перевіряємо:
var tracked = context.ChangeTracker.Entries<Product>()
.FirstOrDefault(e => e.Entity.Id == 42);
if (tracked is not null)
{
// Оновлюємо через існуючий Entry
tracked.CurrentValues.SetValues(updatedProduct);
}
else
{
// Безпечно прикріпляємо
context.Products.Update(updatedProduct);
}
// У деяких сценаріях — відключити всі entity
context.ChangeTracker.Clear();
// Тепер безпечно
context.Products.Update(product2);
SetValues// Завантажуємо і оновлюємо через CurrentValues.SetValues:
var existing = await context.Products.FindAsync(42);
if (existing is not null)
{
// Копіюємо всі значення з updatedProduct в existing
context.Entry(existing).CurrentValues.SetValues(updatedProduct);
// Мінімальний UPDATE: лише фактично змінені поля
await context.SaveChanges();
}
SetValues(obj) копіює всі однойменні властивості з obj в поточний tracked entity і Change Tracker автоматично виявляє яке змінилось.
Один з найкращих прикладів використання Change Tracker — автоматичний audit timestamp у SaveChangesAsync override:
public class AppDbContext : DbContext
{
private readonly ICurrentUserService _currentUser;
public AppDbContext(DbContextOptions<AppDbContext> options,
ICurrentUserService currentUser)
: base(options)
{
_currentUser = currentUser;
}
public override async Task<int> SaveChangesAsync(
CancellationToken cancellationToken = default)
{
// Перед збереженням — автоматично заповнюємо аудит-поля
ApplyAuditFields();
return await base.SaveChangesAsync(cancellationToken);
}
private void ApplyAuditFields()
{
var now = DateTime.UtcNow;
var userId = _currentUser.GetUserId();
foreach (var entry in ChangeTracker.Entries<IAuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedAt = now;
entry.Entity.CreatedBy = userId;
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
break;
case EntityState.Modified:
// Не перезаписуємо CreatedAt/By при оновленні!
entry.Property(e => e.CreatedAt).IsModified = false;
entry.Property(e => e.CreatedBy).IsModified = false;
entry.Entity.UpdatedAt = now;
entry.Entity.UpdatedBy = userId;
break;
}
}
}
}
public interface IAuditableEntity
{
DateTime CreatedAt { get; set; }
string? CreatedBy { get; set; }
DateTime UpdatedAt { get; set; }
string? UpdatedBy { get; set; }
}
Тепер будь-який entity що реалізує IAuditableEntity автоматично отримує CreatedAt, UpdatedAt і автора — без жодного коду у сервісах.
При batch processing (обробка тисяч записів) — Change Tracker накопичує entity і споживає RAM:
// ПРОБЛЕМА: Change Tracker росте необмежено
for (int i = 0; i < 100_000; i++)
{
var item = await context.Items.FindAsync(i);
item!.ProcessedAt = DateTime.UtcNow;
await context.SaveChanges(); // Entity залишається у трекері зі станом Unchanged
}
// Після 100K ітерацій: 100K entity у трекері → гігабайти RAM
const int batchSize = 500;
var allIds = await context.Items
.Where(i => i.ProcessedAt == null)
.Select(i => i.Id)
.ToListAsync();
foreach (var batch in allIds.Chunk(batchSize))
{
var items = await context.Items
.Where(i => batch.Contains(i.Id))
.ToListAsync();
foreach (var item in items)
item.ProcessedAt = DateTime.UtcNow;
await context.SaveChanges();
context.ChangeTracker.Clear(); // ← очистити трекер після батчу
// Тепер items знову Detached — трекер порожній, RAM звільнена
}
Для простих оновлень — взагалі без ChangeTracker:
// Один SQL без трекінгу
await context.Items
.Where(i => i.ProcessedAt == null)
.ExecuteUpdateAsync(s => s.SetProperty(i => i.ProcessedAt, DateTime.UtcNow));
У монолітних застосунках DbContext — Scoped (один на HTTP-запит). Але при великих операціях або batch workers — scope потрібно контролювати вручну.
// ❌ НЕПРАВИЛЬНО: один DbContext на весь background worker
public class OrderProcessingWorker : BackgroundService
{
private readonly AppDbContext _context; // Singleton! Небезпечно!
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var orders = await _context.Orders // Трекер зростає нескінченно!
.Where(o => o.Status == "Pending")
.ToListAsync(stoppingToken);
foreach (var order in orders)
await ProcessOrderAsync(order, stoppingToken);
await Task.Delay(5000, stoppingToken);
}
}
}
// ✅ ПРАВИЛЬНО: новий scope і DbContext для кожної ітерації
public class OrderProcessingWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public OrderProcessingWorker(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (var scope = _scopeFactory.CreateScope())
{
var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var pendingOrders = await context.Orders
.Where(o => o.Status == "Pending")
.Take(100) // Обробляємо батчами
.ToListAsync(stoppingToken);
foreach (var order in pendingOrders)
{
order.Status = "Processing";
order.ProcessingStartedAt = DateTime.UtcNow;
}
await context.SaveChanges(stoppingToken);
} // ← Scope закривається: DbContext Disposed, трекер очищено
await Task.Delay(5000, stoppingToken);
}
}
}
Кожна ітерація — свій DbContext → чистий трекер → без RAM-витоку.
Завдання 1.1: Connected vs Disconnected реалізація
Для Customer реалізуйте два варіанти PUT endpoint:
Для кожного перевірте через LogTo: скільки SQL-запитів виконується?
Завдання 1.2: SetValues для безпечного оновлення
var updateDto = new ProductUpdateDto { Name = "New Name", Price = 9999m };
Реалізуйте метод UpdateProductAsync(int id, ProductUpdateDto dto) через FindAsync + CurrentValues.SetValues(dto). Перевірте: якщо в dto відсутнє поле CategoryId — чи не перезаписує воно значення у БД?
Завдання 1.3: ChangeTracker.Clear() у batch
Обробіть 10,000 LogEntry записів батчами по 500:
Clear(): виміряйте Process.GetCurrentProcess().WorkingSet64 в кінціClear() після кожного батчу: те ж вимірюванняРізниця у споживанні RAM?
Завдання 2.1: Повний Audit Log через SaveChanges override
Overriding SaveChangesAsync у DbContext:
ChangeTracker.Entries() для всіх EntityState.Modified і Deleted{ PropertyName, OriginalValue, CurrentValue } для змінених полівAuditLog таблицюПротестуйте: після order.Status = "Shipped"; SaveChanges() — перевірте запис в AuditLog.
Завдання 2.2: Disconnected граф (Order + LineItems)
PUT /api/orders/{id} приймає:
{
"notes": "Updated",
"lineItems": [
{ "id": 1, "quantity": 3 }, // існуючий, змінений
{ "id": 0, "productId": 99, "quantity": 1 }, // новий
// item з id=2: відсутній → видалити
]
}
Реалізуйте повне порівняння: видалення відсутніх, оновлення існуючих, додавання нових — все в одному SaveChanges.
Завдання 3.1: Domain Events через SaveChanges
Реалізуйте повноцінний Domain Events механізм:
IDomainEntity інтерфейс: IReadOnlyCollection<IDomainEvent> Events, RaiseDomainEvent(IDomainEvent), ClearDomainEvents()Order клас: при зміні Status додає OrderStatusChangedEvent { OrderId, OldStatus, NewStatus }AppDbContext.SaveChangesAsync override: після збереження — збирає всі Events з ChangeTracker.Entries<IDomainEntity>(), публікує через IPublisher (MediatR)OrderStatusChangedEventHandler — надсилає email через IEmailServiceПротестуйте що Event публікується рівно один раз після SaveChanges і не публікується при ролбеку.
Ця стаття повністю розкрила Change Tracker у EF Core:
Частина 1 — Основи:
FindAsync перевіряє кеш.DetectChanges порівнює. UPDATE тільки змінених стовпців.AutoDetectChangesEnabled = false для batch processing.Частина 2 — Просунуті сценарії:
InvalidOperationException при Attach вже трекованого. Вирішення через SetValues.SaveChangesAsync override для автоматичного аудиту (CreatedAt, UpdatedAt, Domain Events).ChangeTracker.Clear() для batch processing.IServiceScopeFactory для правильного DbContext lifecycle у background workers.Наступна стаття — Saving Data та Транзакції (стаття 21) — розкриє SaveChanges, явні транзакції, Savepoints, транзакції між кількома DbContext і паттерни надійного запису.
Change Tracker — Відстеження Змін (Частина 1)
Глибоке розуміння Change Tracker в EF Core — як DbContext відстежує стани Entity, Identity Map, Snapshot-based Detection, EntityState lifecycle. Різниця між Tracked і Untracked об'єктами, ручне керування через Entry та ChangeTracker API.
Збереження Даних та Транзакції (Частина 1)
SaveChanges і SaveChangesAsync в EF Core — атомарність за замовчуванням, що відбувається під капотом. Явні транзакції через BeginTransactionAsync, IsolationLevel, Savepoints. Обробка DbUpdateException та транзакційна відмова.