.NET Tutorial
Middle6 мин чтенияВопрос 2 из 14

Как работает DbContext: Unit of Work, Identity Map, Change Tracker?

Роль DbContext

C#
public sealed class ShopDbContext(DbContextOptions<ShopDbContext> options) : DbContext(options)
{
    public DbSet<Order> Orders => Set<Order>();
    public DbSet<Customer> Customers => Set<Customer>();

    protected override void OnModelCreating(ModelBuilder b) =>
        b.ApplyConfigurationsFromAssembly(typeof(ShopDbContext).Assembly);
}

builder.Services.AddDbContext<ShopDbContext>(o =>
    o.UseNpgsql(builder.Configuration.GetConnectionString("Default")));

DbContext

Модель
(метаданные сущностей, кэшируется
на всё приложение)

ChangeTracker
EntityEntry для каждой сущности:
состояние + исходные значения

Identity Map
ключ → экземпляр

Query pipeline
LINQ → SQL

Database facade
соединение, транзакции

SaveChanges
DetectChanges → команды SQL

DbContext

Модель
(метаданные сущностей, кэшируется
на всё приложение)

ChangeTracker
EntityEntry для каждой сущности:
состояние + исходные значения

Identity Map
ключ → экземпляр

Query pipeline
LINQ → SQL

Database facade
соединение, транзакции

SaveChanges
DetectChanges → команды SQL

Из чего состоит DbContext

Состояния сущностей

new Order()

db.Add(order)

db.Attach(order)

загружена запросом

изменили свойство

db.Remove(entity)

SaveChanges (INSERT)

SaveChanges (UPDATE)

SaveChanges (DELETE)

Remove до сохранения

entry.State = Detached

Detached

Added

Unchanged

Modified

Deleted

new Order()

db.Add(order)

db.Attach(order)

загружена запросом

изменили свойство

db.Remove(entity)

SaveChanges (INSERT)

SaveChanges (UPDATE)

SaveChanges (DELETE)

Remove до сохранения

entry.State = Detached

Detached

Added

Unchanged

Modified

Deleted

Жизненный цикл сущности в контексте
Состояние Что сделает SaveChanges
Added INSERT
Unchanged Ничего
Modified UPDATE только изменённых столбцов
Deleted DELETE
Detached Контекст о сущности не знает

Как работает отслеживание изменений

C#
await using var db = new ShopDbContext(options);

var order = await db.Orders.FirstAsync(o => o.Id == 42);   // Unchanged, сохранён снимок исходных значений
order.Status = OrderStatus.Paid;                           // обычное присваивание — EF пока ничего не знает
order.PaidAt = DateTime.UtcNow;

Console.WriteLine(db.Entry(order).State);                  // вызов Entry → DetectChanges → Modified

await db.SaveChangesAsync();
// UPDATE orders SET status = @p0, paid_at = @p1 WHERE id = @p2;
// (остальные столбцы не трогаются)
База данныхChangeTrackerКодSaveChangesAsync()DetectChanges:сравнить текущиезначения со снимкамиупорядочить командыпо зависимостям (FK)BEGIN (если нет внешнейтранзакции)пакет команд INSERT /UPDATE / DELETE(batching)сгенерированные ключи,rowversionCOMMITсостояния →Unchanged, обновитьснимкичисло затронутых строк
База данныхChangeTrackerКодSaveChangesAsync()DetectChanges:сравнить текущиезначения со снимкамиупорядочить командыпо зависимостям (FK)BEGIN (если нет внешнейтранзакции)пакет команд INSERT /UPDATE / DELETE(batching)сгенерированные ключи,rowversionCOMMITсостояния →Unchanged, обновитьснимкичисло затронутых строк
Что происходит в SaveChanges

По умолчанию используется snapshot change tracking: при загрузке копируются исходные значения, при DetectChanges — сравниваются. Это автоматически, но стоит CPU и памяти при тысячах отслеживаемых сущностей. Альтернатива — сущности с INotifyPropertyChanged (уведомления вместо сравнения), на практике используется редко.

Identity Map

C#
var a = await db.Customers.FirstAsync(c => c.Id == 7);
var b = await db.Customers.FirstAsync(c => c.Email == "ann@x.uz");   // та же строка
Console.WriteLine(ReferenceEquals(a, b));                              // True — один объект

var c = await db.Customers.FindAsync(7);    // Find сначала ищет в Identity Map — без запроса к БД

Unit of Work на практике

C#
public async Task TransferAsync(int fromId, int toId, decimal amount, CancellationToken ct)
{
    var from = await db.Accounts.FindAsync([fromId], ct) ?? throw new NotFoundException();
    var to   = await db.Accounts.FindAsync([toId], ct) ?? throw new NotFoundException();

    from.Withdraw(amount);                                  // доменная логика меняет состояние
    to.Deposit(amount);
    db.Transfers.Add(new Transfer(fromId, toId, amount));

    await db.SaveChangesAsync(ct);                          // 2 UPDATE + 1 INSERT в одной транзакции
}

Все изменения применяются атомарно: либо всё, либо ничего.

Работа с «отсоединёнными» сущностями

В веб-API объект часто приходит из запроса (DTO), а не из контекста:

C#
// Вариант 1 (рекомендуемый): загрузить и изменить
var product = await db.Products.FindAsync(dto.Id);
product!.Rename(dto.Name);
await db.SaveChangesAsync();                              // UPDATE только Name

// Вариант 2: Update — пометит ВСЕ свойства как Modified (и все связанные сущности графа)
db.Products.Update(new Product { Id = dto.Id, Name = dto.Name });   // остальные поля затрутся значениями по умолчанию!

// Вариант 3: массовое обновление без загрузки (EF Core 7+)
await db.Products.Where(p => p.Id == dto.Id)
    .ExecuteUpdateAsync(s => s.SetProperty(p => p.Name, dto.Name));

Время жизни и потокобезопасность

  • Короткоживущий: чем дольше живёт контекст, тем больше сущностей в Change Tracker, тем медленнее DetectChanges и больше памяти, тем более устаревшие данные в Identity Map.
  • Не потокобезопасный: две одновременные операции на одном контексте → InvalidOperationException: A second operation was started on this context instance before a previous operation completed. Для параллельных запросов — отдельные контексты (IDbContextFactory<T>).
  • В Blazor Server и фоновых службах — IDbContextFactory<T>.CreateDbContextAsync() на каждую операцию.

Полезные инструменты Change Tracker

C#
db.ChangeTracker.DebugView.LongView;              // что отслеживается и в каком состоянии
db.ChangeTracker.Clear();                         // сбросить всё (после пакетной обработки)
db.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;   // по умолчанию без отслеживания
db.ChangeTracker.AutoDetectChangesEnabled = false;                           // для массовых вставок (затем DetectChanges вручную)

// Аудит: перехватить изменения перед сохранением
foreach (var entry in db.ChangeTracker.Entries<IAuditable>())
{
    if (entry.State == EntityState.Added) entry.Entity.CreatedAt = DateTime.UtcNow;
    if (entry.State == EntityState.Modified) entry.Entity.UpdatedAt = DateTime.UtcNow;
}

Вопросы на засыпку

Зачем нужен Repository, если DbContext уже Unit of Work, а DbSet — репозиторий?

Во многих проектах — не нужен: DbContext и есть реализация UoW и Repository. Собственный репозиторий оправдан для инкапсуляции сложных запросов, ограничения доступных операций (DDD-агрегаты), или если хочется изолировать домен от EF. Подробнее — в разделе архитектуры.

Что делает SaveChanges, если одна из команд упала?

Вся транзакция откатывается, в БД ничего не изменится. Но состояние Change Tracker не откатывается — сущности остаются в Added/Modified. Повторный SaveChanges попытается снова. Обычно после ошибки контекст выбрасывают.

Почему EF не видит изменение, сделанное через ExecuteUpdate?

ExecuteUpdate/ExecuteDelete выполняются напрямую в БД, минуя Change Tracker. Уже загруженные сущности в контексте останутся со старыми значениями. Это нормально для массовых операций, но смешивать с отслеживаемыми изменениями нужно аккуратно.

Что такое DbContext pooling?

AddDbContextPool переиспользует экземпляры контекста: после запроса контекст сбрасывается (ChangeTracker.Clear и т.п.) и возвращается в пул. Экономит создание объектов на высоких нагрузках. Нельзя хранить собственное состояние в полях контекста.

Итог

DbContext — короткоживущая сессия, объединяющая Unit of Work (все изменения — одним SaveChanges), Identity Map (одна строка = один объект) и Change Tracker (состояния и снимки для генерации минимальных UPDATE). Держите контекст коротким, не делите между потоками и отключайте отслеживание для чистого чтения.