Ef Core

Change Tracker — Графи Об'єктів та Disconnected (Частина 2)

Change Tracker у зв'язаних графах — connected vs disconnected сценарії, проблема «подвійного трекування», керування навігаційними властивостями через ChangeTracker. Overriding SaveChangesAsync для автоматичного аудиту, ClearContext, Repository pattern lifecycle.

Change Tracker: Графи Об'єктів та Disconnected Сценарії

Це продовження статті «Change Tracker». Читайте послідовно.


Connected vs Disconnected: ключове розмежування

У веб-розробці з 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 не знає що він завантажений

Disconnected сценарій: три підходи до збереження

Розглянемо PUT endpoint — клієнт надіслав змінений Order. Як правильно зберегти?

Підхід A: Завантажити → Оновити → Зберегти (рекомендований)

Найнадійніший і найбезпечніший підхід: не довіряєте клієнту — завантажуєте актуальний стан з бази і оновлюєте лише те що дозволено:

[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-сценаріїв.

Підхід B: Attach з ручним позначенням Modified

Коли 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.

Недоліки: потрібно точно знати які поля змінено. Розробник несе відповідальність за коректність.

Підхід C: context.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

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 у трекері одночасно.

Вирішення A: Не завантажувати якщо збираєтесь Attach/Update

// Якщо відомо що 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);
}

Вирішення B: Очистити трекер перед Attach

// У деяких сценаріях — відключити всі entity
context.ChangeTracker.Clear();

// Тепер безпечно
context.Products.Update(product2);

Вирішення C: Використати 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 автоматично виявляє яке змінилось.


Перевантаження SaveChangesAsync для автоматичного аудиту

Один з найкращих прикладів використання 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 і автора — без жодного коду у сервісах.


ChangeTracker.Clear() та Context Recycling

При 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

Рішення A: Clear після кожного батчу

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 звільнена
}

Рішення B: AsNoTracking + ExecuteUpdate

Для простих оновлень — взагалі без ChangeTracker:

// Один SQL без трекінгу
await context.Items
    .Where(i => i.ProcessedAt == null)
    .ExecuteUpdateAsync(s => s.SetProperty(i => i.ProcessedAt, DateTime.UtcNow));

Repository Pattern і правильний DbContext lifecycle

У монолітних застосунках DbContext — Scoped (один на HTTP-запит). Але при великих операціях або batch workers — scope потрібно контролювати вручну.

Проблема довгоживучого DbContext

// ❌ НЕПРАВИЛЬНО: один 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);
        }
    }
}

Правильний підхід: IServiceScopeFactory

// ✅ ПРАВИЛЬНО: новий 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-витоку.


Практичні завдання (Частина 2)

Рівень 1 — Базовий

Завдання 1.1: Connected vs Disconnected реалізація

Для Customer реалізуйте два варіанти PUT endpoint:

  1. Connected: завантажити → оновити поля → SaveChanges
  2. Disconnected через Attach: не завантажувати, Attach + позначити IsModified = true для конкретних полів

Для кожного перевірте через 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:

  1. Без Clear(): виміряйте Process.GetCurrentProcess().WorkingSet64 в кінці
  2. З Clear() після кожного батчу: те ж вимірювання

Різниця у споживанні RAM?

Рівень 2 — Логіка

Завдання 2.1: Повний Audit Log через SaveChanges override

Overriding SaveChangesAsync у DbContext:

  1. Перед збереженням — читає ChangeTracker.Entries() для всіх EntityState.Modified і Deleted
  2. Для кожного entry збирає { PropertyName, OriginalValue, CurrentValue } для змінених полів
  3. Серіалізує у JSON і зберігає у AuditLog таблицю
  4. Зберігає у тій самій транзакції що і основні зміни

Протестуйте: після 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 — Архітектура

Завдання 3.1: Domain Events через SaveChanges

Реалізуйте повноцінний Domain Events механізм:

  1. IDomainEntity інтерфейс: IReadOnlyCollection<IDomainEvent> Events, RaiseDomainEvent(IDomainEvent), ClearDomainEvents()
  2. Order клас: при зміні Status додає OrderStatusChangedEvent { OrderId, OldStatus, NewStatus }
  3. AppDbContext.SaveChangesAsync override: після збереження — збирає всі Events з ChangeTracker.Entries<IDomainEntity>(), публікує через IPublisher (MediatR)
  4. OrderStatusChangedEventHandler — надсилає email через IEmailService

Протестуйте що Event публікується рівно один раз після SaveChanges і не публікується при ролбеку.


Підсумок статті 20

Ця стаття повністю розкрила Change Tracker у EF Core:

Частина 1 — Основи:

  • Identity Map: один PK = один C#-об'єкт, FindAsync перевіряє кеш.
  • EntityState lifecycle: Detached → Added/Unchanged/Modified/Deleted.
  • Snapshot Detection: копія при завантаженні, DetectChanges порівнює. UPDATE тільки змінених стовпців.
  • AutoDetectChangesEnabled = false для batch processing.
  • ChangeTracker.Entries API: перегляд і маніпуляція станом.
  • Attach vs Update: partial vs full Modified.

Частина 2 — Просунуті сценарії:

  • Connected vs Disconnected: HTTP stateless = кожен запит новий DbContext = Disconnected.
  • Три підходи до оновлення: Load+Modify (безпечно), Attach+IsModified (ефективно), Update (зручно, але жирний SQL).
  • Disconnected граф: порівняння колекцій → DELETE/UPDATE/INSERT в одній транзакції.
  • Проблема подвійного трекування: InvalidOperationException при Attach вже трекованого. Вирішення через SetValues.
  • SaveChangesAsync override для автоматичного аудиту (CreatedAt, UpdatedAt, Domain Events).
  • ChangeTracker.Clear() для batch processing.
  • IServiceScopeFactory для правильного DbContext lifecycle у background workers.

Наступна стаття — Saving Data та Транзакції (стаття 21) — розкриє SaveChanges, явні транзакції, Savepoints, транзакції між кількома DbContext і паттерни надійного запису.


Додаткові ресурси