EF Core
Як EF Core перетворює LINQ на SQL, а рядки назад на об’єкти, як відстежує зміни, завантажує пов’язані дані й живе з міграціями в команді. Кандидата з досвідом EF питають не «що таке DbSet», а чому запит гальмує, звідки N+1 і як розгортати схему.
Перед цією темою: Колекції та LINQ, Транзакції та блокування.
Що таке EF Core і як він влаштований: DbContext, DbSet, модель і переклад LINQ на SQL?
Section titled “Що таке EF Core і як він влаштований: DbContext, DbSet, модель і переклад LINQ на SQL?”Що це
EF Core — ORM: бібліотека, яка відображає класи на таблиці. Ви працюєте з об’єктами, SQL генерує EF.
З чого складається
DbContext— сесія роботи з базою: виконує запити й зберігає зміни.DbSet<T>— властивість контексту для запитів до таблиці:db.Orders.Where(...).- Сутність — звичайний клас із ключем. Властивість
IdабоOrderIdEF вважає ключем за угодою. - Провайдер — пакет під конкретну базу:
Microsoft.EntityFrameworkCore.SqlServer,Npgsql.EntityFrameworkCore.PostgreSQL.
Як користуватися
- Успадкуйте контекст від
DbContextі оголосітьDbSet<T>. - Запит виконається під час
ToListAsync()абоFirstOrDefaultAsync(). - Зміни об’єктів збереже
SaveChangesAsync().
Типова помилка
Не задати довжину рядків. За замовчуванням string на SQL Server стає nvarchar(max), і такий стовпець не може бути ключем індексу.
Як влаштовано
- Модель. Під час першого використання типу контексту EF будує модель: сутності, ключі, зв’язки, імена таблиць і типи стовпців. Модель кешується.
- Конфігурація. Пріоритет: Fluent API в
OnModelCreating, потім атрибути ([MaxLength],[Table]), потім угоди (Id— ключ,CustomerId— зовнішній ключ). - Запит. LINQ будує дерево виразів. Під час перебору EF шукає його в кеші запитів, у разі промаху перекладає на SQL через провайдер.
- Матеріалізація. Рядки результату стають об’єктами, сутності за замовчуванням потрапляють у трекер змін.
Коли що обирати
| Спосіб конфігурації | Коли |
|---|---|
| Угоди | усе, що їм відповідає |
| Атрибути | прості обмеження поруч із властивістю: [MaxLength], [Precision] |
| Fluent API | зв’язки й каскадне видалення, фільтровані індекси, конвертери значень, глобальні фільтри |
Пастки
- Абстракція протікає. Не кожен вираз перекладається на SQL. Із EF Core 3.0 неперекладний
Whereкидає виняток, а не виконується в пам’яті. - Конфігурація врозкид. Якщо атрибут і Fluent API суперечать одне одному, перемагає Fluent API, і з класу сутності цього не видно.
Правило
Сутності — прості класи, конфігурація — у класах IEntityTypeConfiguration<T> через ApplyConfigurationsFromAssembly, згенерований SQL — під контролем.
Під капотом
- Модель будується під час першого звернення до типу контексту. Для сотень сутностей це помітна частина холодного старту, тому її генерують заздалегідь:
dotnet ef dbcontext optimize(compiled model). Із EF Core 9 така модель підхоплюється безUseModel, якщо лежить у збірці контексту. Є обмеження: наприклад, не підтримуються lazy-loading-проксі. - Конвеєр запиту: дерево виразів → розкриття навігацій → SQL-дерево провайдера → текст SQL і делегат матеріалізації. Результат кешується за формою дерева, захоплені змінні стають параметрами.
- Сам
DbContextлегкий: з’єднання береться з пулу ADO.NET на час команди й одразу повертається, якщо немає відкритої транзакції.
Компроміси
- Продуктивність проти контролю. Типобезпечні запити, трекінг і міграції заощаджують код, але кожен неочевидний запит треба перевірити очима.
- Семантика провайдера. Той самий LINQ на SQL Server і PostgreSQL дає різний SQL: колація SQL Server зазвичай нечутлива до регістру, PostgreSQL — чутлива. InMemory-провайдер цього не покаже, тому тестують на справжній базі.
- Модель проти схеми. Помилку маппінгу компілятор не бачить, вона приходить як
SqlException: Invalid column name.
Як перевірити в продакшені
- SQL і час команд пише категорія логів
Microsoft.EntityFrameworkCore.Database.Command. Значення параметрів видно лише зEnableSensitiveDataLogging, у продакшені його не вмикають. - Час першої операції після старту показує ціну побудови моделі.
Підсумок: EF Core — компілятор LINQ у SQL поверх спільної моделі; його цінність — продуктивність, ціна — обов’язок розуміти згенерований SQL.
using Microsoft.EntityFrameworkCore;
using var db = new ShopContext();var maxPrice = 100m;
// LINQ будує дерево виразів, провайдер SQL Server перекладає його на SQLvar query = db.Products .Where(p => p.Price < maxPrice) .OrderBy(p => p.Name) .Select(p => new { p.Id, p.Name });
// ToQueryString не звертається до бази: лише показує SQLConsole.WriteLine(query.ToQueryString());
public class ShopContext : DbContext{ public DbSet<Product> Products => Set<Product>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");
// Fluent API: має пріоритет над атрибутами й угодами protected override void OnModelCreating(ModelBuilder model) => model.Entity<Product>(e => { e.ToTable("Products", "sales"); // схема й ім'я таблиці e.Property(p => p.Name).HasMaxLength(200); // nvarchar(200), а не nvarchar(max) e.Property(p => p.Price).HasPrecision(18, 2); // decimal(18,2) });}
public class Product{ public int Id { get; set; } // ключ за угодою public required string Name { get; set; } public decimal Price { get; set; }}Вивід (EF Core 10): змінна maxPrice стала параметром, константа потрапила б у SQL як є.
DECLARE @maxPrice decimal(18,2) = 100.0;
SELECT [p].[Id], [p].[Name]FROM [sales].[Products] AS [p]WHERE [p].[Price] < @maxPriceORDER BY [p].[Name]- Коли будується модель? — Під час першого використання типу контексту, потім кешується на весь процес.
- Що має пріоритет: атрибути чи Fluent API? — Fluent API; атрибути — над угодами.
- Як побачити SQL запиту? —
ToQueryString()без виконання або логування черезLogToтаILogger. - На що за замовчуванням перетворюється
stringна SQL Server? — Наnvarchar(max); довжину задаютьHasMaxLengthабо[MaxLength]. - Чому InMemory-провайдер поганий для тестів запитів? — Він не реляційний: немає SQL, транзакцій і семантики справжньої бази.
Як працює відстеження змін і SaveChanges?
Section titled “Як працює відстеження змін і SaveChanges?”Що це
Контекст стежить за завантаженими сутностями. Ви змінюєте властивості звичайних об’єктів, а EF сам вирішує, який SQL потрібен.
Стани сутності
| Стан | Що зробить SaveChanges |
|---|---|
Added |
INSERT |
Unchanged |
нічого |
Modified |
UPDATE змінених стовпців |
Deleted |
DELETE |
Detached |
нічого: контекст не знає про об’єкт |
Як користуватися
- Завантажте сутність запитом — вона
Unchanged. - Змініть властивість — на момент збереження вона стане
Modified. AddіRemoveпереводять об’єкт вAddedіDeleted.await db.SaveChangesAsync()надішле всі зміни разом.
Типова помилка
Завантажувати з відстеженням дані лише для показу. Для читання пишіть AsNoTracking(): запит швидший і не займає пам’ять трекера.
Як влаштовано
- Запит. Запит із відстеженням матеріалізує сутність і запам’ятовує знімок її значень. Якщо сутність із таким ключем уже відстежується, повертається той самий об’єкт без перезапису властивостей.
- Зміни.
DetectChangesпорівнює поточні значення зі знімком. Його автоматично викликаютьSaveChanges,EntryіChangeTracker.Entries. - Збереження.
SaveChangesбудує команди, виконує їх у транзакції, записує в сутності згенеровані ключі й переводить їх вUnchanged.
Коли що обирати
| Режим | Коли |
|---|---|
| Відстеження (за замовчуванням) | змінюватимете й зберігатимете |
AsNoTracking() |
лише читання: списки, звіти, GET в API |
AsNoTrackingWithIdentityResolution() (EF Core 5+) |
лише читання, але один рядок має бути одним об’єктом |
Пастки
- Дублікати без трекінгу. Із EF Core 3.0
AsNoTrackingстворює новий об’єкт на кожне входження рядка: замовлення зInclude(o => o.Customer)отримають стільки копій клієнта, скільки в нього замовлень. Updateдля DTO.db.Update(entity)позначаєModifiedусі властивості всього графа, іUPDATEперепише навіть незмінене.- Другий екземпляр із тим самим ключем.
Attachдубліката даєInvalidOperationException: The instance of entity type ... cannot be tracked.
Правило
Читаєте — AsNoTracking(). Змінюєте — завантажте сутність, змініть властивості, викличте SaveChanges.
Під капотом
- На кожну сутність трекер зберігає запис: стан, вихідні значення, прапорці змінених властивостей. Identity map — словник «ключ → запис» для кожного типу.
DetectChangesобходить усі відстежувані сутності. Його ціна зростає лінійно з їх кількістю й сплачується під час кожногоSaveChanges.SaveChangesсортує команди за залежностями, пакетує їх і виконує в транзакції. Якщо транзакцію відкотили, стани в трекері лишаються попередніми, і повторний виклик спробує знову.
Ціна
- Знімок — копія значень кожної відстежуваної сутності: пам’ять зростає з кількістю рядків.
- Контекст у singleton або в довгому фоновому циклі накопичує сутності, і кожен
SaveChangesповільніший за попередній. Лікується коротким контекстом абоChangeTracker.Clear()між пачками.
Компроміси
- Для сервісів, які здебільшого читають, —
UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking)у налаштуваннях іAsTracking()точково. - DTO з API: завантажити сутність і застосувати
Entry(entity).CurrentValues.SetValues(dto)— зайвийSELECT, зате точнийUPDATE. АбоAttachі позначити змінені властивості вручну — безSELECT, але повнота на вас. - Атомарний лише один виклик
SaveChanges. Кілька викликів або суміш зExecuteUpdateоб’єднує явна транзакція.
Як перевірити в продакшені
- Під час налагодження
ChangeTracker.DebugView.LongViewпоказує стани й значення всіх сутностей контексту. - Метрики EF Core 9+ (meter
Microsoft.EntityFrameworkCore) і лічильникиdotnet-counters: активні контексти й частотаSaveChanges. Зростання кількості контекстів — витік.
Підсумок: трекінг коштує пам’яті й CPU пропорційно кількості сутностей, тому контекст короткий, читання — без трекінгу, а від’єднані сутності оновлюють явно.
using Microsoft.EntityFrameworkCore;
await using var db = new ShopContext();await db.Database.OpenConnectionAsync(); // база :memory: живе, доки відкрите з'єднанняawait db.Database.EnsureCreatedAsync();
var order = new Order { Status = "New" };db.Orders.Add(order);Console.WriteLine(db.Entry(order).State); // Addedawait db.SaveChangesAsync(); // INSERT, Id прийшов із базиConsole.WriteLine(db.Entry(order).State); // Unchanged
order.Status = "Paid"; // звичайне присвоєнняConsole.WriteLine(db.Entry(order).State); // Modified
// Identity resolution: той самий об'єкт, властивості не перезаписаніvar same = await db.Orders.SingleAsync(o => o.Id == order.Id);Console.WriteLine($"{ReferenceEquals(same, order)} {same.Status}"); // True Paid
// Без трекінгу: новий об'єкт із даними з базиvar copy = await db.Orders.AsNoTracking().SingleAsync(o => o.Id == order.Id);Console.WriteLine($"{ReferenceEquals(copy, order)} {copy.Status}"); // False New
await db.SaveChangesAsync(); // UPDATE "Orders" SET "Status" = @p0 WHERE "Id" = @p1
public class ShopContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlite("Data Source=:memory:");}
public class Order{ public int Id { get; set; } public required string Status { get; set; }}- Чи перезапише повторний запит змінену відстежувану сутність? — Ні, поверне той самий об’єкт із вашими змінами.
- Чим
Attachвідрізняється відUpdate? —AttachставитьUnchanged,Update—Modifiedусім властивостям графа. - Коли
DetectChangesвикликається сам? — УSaveChanges,Entry,ChangeTracker.EntriesіHasChanges. - Як скинути трекер, не створюючи контекст заново? —
ChangeTracker.Clear()(EF Core 5+). - Що лишиться в трекері після невдалого
SaveChanges? — Попередні стани: транзакцію відкотили, зміни не прийняті, виклик можна повторити.
Як завантажувати пов’язані дані й що таке проблема N+1?
Section titled “Як завантажувати пов’язані дані й що таке проблема N+1?”Що це
Навігаційна властивість — посилання на пов’язану сутність (order.Customer) або колекція (order.Lines). Звичайний запит завантажує лише саму сутність, навігації лишаються null або порожніми.
Як користуватися
Include(o => o.Lines)— завантажити рядки замовлення в тому самому запиті.ThenInclude(l => l.Product)— наступний рівень: продукт кожного рядка.Select(o => new { o.Id, LineCount = o.Lines.Count })— взяти лише потрібні поля.
Що таке N+1
- Один запит завантажує N замовлень.
- Код у циклі звертається до
order.Lines, і ліниве завантаження робить запит на кожне замовлення. - Разом N+1 запитів замість одного.
Типова помилка
Увімкнути ліниве завантаження, «щоб не думати». Код виглядає так само, а кількість запитів зростає разом із даними.
Як влаштовано
| Спосіб | Як увімкнути | Запитів |
|---|---|---|
| Жадібне | Include, ThenInclude; з EF Core 5 — з фільтром усередині |
1 або по одному на колекцію зі split query |
| Явне | db.Entry(order).Collection(o => o.Lines).LoadAsync() |
+1 на виклик |
| Ліниве | пакет Microsoft.EntityFrameworkCore.Proxies, UseLazyLoadingProxies(), virtual-навігації |
+1 на перше звернення до кожної навігації |
Коли що обирати
- Відповідь API або звіт — проєкція в DTO через
Select. - Треба змінити граф —
Includeтого, що змінюватимете. - Дві й більше колекції одного рівня —
AsSplitQuery().
Пастки
- Cartesian explosion.
Include(o => o.Lines).Include(o => o.Payments): замовлення з 10 рядками й 10 платежами дасть 100 рядків результату. Якщо режим не задано, EF пише в логMultipleCollectionIncludeWarning. - Ліниве завантаження. Синхронний запит у гетері властивості та N+1 у циклах і під час серіалізації в JSON: серіалізатор обходить усі навігації.
- Split query і пагінація. До EF Core 10 зі
Skip/Takeсортування має бути унікальним, інакше запити повернуть неузгоджені дані.
Правило
Для читання — проєкція, для змін — Include потрібного. Ліниве завантаження у веб-застосунках не вмикають.
Під капотом
- Із EF Core 3.0
Includeколекцій — один запит зLEFT JOINіORDER BYза ключами; EF Core 2.x робив окремі запити. На цьому переході багато проєктів і отримали cartesian explosion, а явнийAsSplitQueryз’явився в EF Core 5.0. - Split query повторює фільтр і сортування кореня в запиті кожної колекції. Без MARS результати попередніх запитів буферизуються в пам’яті.
- Агрегат у проєкції (
o.Lines.Sum(...)) стає корельованим підзапитом: рахує база, у застосунок приходить одне число.
Ціна
| Варіант | Чим платимо |
|---|---|
| Ліниве завантаження | N round trip, синхронний I/O у гетері |
Один запит з JOIN |
рядків — добуток розмірів колекцій, стовпці батька дублюються |
| Split query | +1 round trip на колекцію, узгодженість лише під SNAPSHOT або SERIALIZABLE |
Компроміси
- Глобальний
UseQuerySplittingBehavior(SplitQuery)рятує від вибуху рядків, але додає round trip кожномуIncludeі змінює узгодженість усіх запитів одразу. Частіше його вмикають точково. - Що далі база від застосунку, то дорожчі зайві round trip і то вигідніший один запит.
Як перевірити в продакшені
- У трасуванні OpenTelemetry N+1 виглядає як драбинка однакових
SELECTусередині одного HTTP-запиту. MultipleCollectionIncludeWarningу логах і кількість прочитаних рядків у плані виконання видають вибухJOIN.
Підсумок: N+1 б’є по затримці, спільний JOIN — по обсягу, split query — по узгодженості; проєкція обходить усі три, коли дані потрібні для читання.
using Microsoft.EntityFrameworkCore;
using var db = new ShopContext();
// Жадібне завантаження: один запит з LEFT JOIN; фільтр усередині Include — з EF Core 5var eager = db.Orders.Include(o => o.Lines.Where(l => l.Quantity > 0));
// Проєкція: лише потрібні стовпці, агрегати рахує база, трекінгу немаєvar totals = db.Orders.Select(o => new{ o.Id, Lines = o.Lines.Count, Total = o.Lines.Sum(l => l.Quantity * l.Price)});Console.WriteLine(totals.ToQueryString());
// N+1: запит за замовленнями й ще по одному на кожне замовлення — так працює ліниве завантаженняasync Task PrintLineCountsSlowly(){ foreach (var order in await db.Orders.ToListAsync()) { await db.Entry(order).Collection(o => o.Lines).LoadAsync(); // +1 запит Console.WriteLine(order.Lines.Count); }}
public class ShopContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");}
public class Order{ public int Id { get; set; } public List<OrderLine> Lines { get; set; } = [];}
public class OrderLine{ public int Id { get; set; } public int OrderId { get; set; } public int Quantity { get; set; } [Precision(18, 2)] public decimal Price { get; set; }}SQL проєкції (EF Core 10): два корельовані підзапити, з бази приходять три числа на замовлення.
SELECT [o].[Id], ( SELECT COUNT(*) FROM [OrderLine] AS [o0] WHERE [o].[Id] = [o0].[OrderId]) AS [Lines], ( SELECT COALESCE(SUM(CAST([o1].[Quantity] AS decimal(18,2)) * [o1].[Price]), 0.0) FROM [OrderLine] AS [o1] WHERE [o].[Id] = [o1].[OrderId]) AS [Total]FROM [Orders] AS [o]- Чому N+1 важко помітити? — Запити сховані в гетерах і серіалізаторі, видно лише зростання часу відповіді.
- Що таке filtered include? —
Include(o => o.Lines.Where(...))з EF Core 5: завантажується частина колекції. - Чим небезпечний
AsSplitQueryзіSkipіTake? — Запити можуть побачити різні дані; до EF Core 10 потрібне унікальне сортування. - Як завантажується зв’язок «один до одного» в split query? — Завжди через
JOINу тому самому запиті, розділяються лише колекції. - Чи працює ліниве завантаження для сутностей з
AsNoTracking? — З EF Core 8 так, раніше ні.
Як працювати з міграціями в команді та під час розгортання?
Section titled “Як працювати з міграціями в команді та під час розгортання?”Що це
Міграція — крок зміни схеми. EF порівнює модель із попереднім знімком і генерує код: CreateTable, AddColumn, CreateIndex.
Як користуватися
- Змініть модель: додайте властивість або сутність.
dotnet ef migrations add AddOrderCommentстворить файл міграції,.Designer.csі оновитьShopContextModelSnapshot.cs.- Прочитайте згенерований код.
dotnet ef database updateзастосує міграцію до локальної бази.- Закомітьте всі три файли.
Що лежить у базі
Таблиця __EFMigrationsHistory — список застосованих міграцій. За нею EF розуміє, що ще не застосовано.
Типова помилка
Правити або видаляти міграцію, уже застосовану до спільної бази. Вона записана в історії, тому помилку виправляють новою міграцією.
Як влаштовано
- Три файли. Міграція з
UpіDown,.Designer.csз моделлю на її момент і спільнийModelSnapshot— модель після останньої міграції. migrations addпорівнює поточну модель зі знімком і генерує різницю.migrations removeвидаляє останню міграцію й відкочує знімок. Безпечно, доки її не застосували до спільної бази.
Коли що обирати
| Спосіб застосування | Коли |
|---|---|
dotnet ef database update |
локальна розробка |
migrations script --idempotent |
рев’ю й запуск DBA, бази в різному стані |
migrations bundle (EF Core 6+) |
крок CI/CD без SDK і вихідного коду |
Пастки
- Паралельні гілки. A і B додали міграції у своїх гілках; знімок B не знає про зміни A, а Git часто зливає знімок без конфлікту. Скасуйте злиття, приберіть свою міграцію, влийте чужі зміни й створіть міграцію заново. Сортувати файли й правити знімок вручну не можна.
- Перейменування. EF не відрізняє його від видалення й додавання: згенерує
DropColumnіAddColumn, дані зникнуть. Замініть наRenameColumn. - Дані в міграції. Лише
migrationBuilder.Sql(...)абоInsertData/UpdateData, неDbContextі класи сутностей: стара міграція має працювати, навіть коли сутності змінилися.
Правило
Згенеровану міграцію читають як чужий PR, а в продакшен застосовують окремим кроком конвеєра.
Під капотом
Migrate(),database updateі bundle читають__EFMigrationsHistoryі застосовують відсутні міграції по черзі. Із EF Core 9 перед цим береться блокування на рівні бази, і другий екземпляр чекає, а не застосовує ті самі міграції паралельно.- EF Core 9 обгортав усі очікувані міграції в одну транзакцію, EF Core 10 повернув транзакцію на кожну міграцію. Частина DDL не транзакційна, і EF 9+ попереджає, якщо така операція змішана з іншими в одній міграції.
- Із EF Core 9
Migrate()кидає виняток, якщо модель розходиться з останньою міграцією (PendingModelChangesWarning) або якщо його обгорнули у власну транзакцію. - В EF Core 11 (виходить у листопаді 2026) знімок зберігатиме id останньої міграції, і розбіжні гілки дадуть конфлікт злиття в Git.
Чому не Database.Migrate() під час старту
- Застосунку потрібні права на DDL, хоча в продакшені йому вистачає прав на дані.
- SQL ніхто не бачить до виконання, відкат не підготовлено.
- Довга міграція затримує старт усіх екземплярів, а оркестратор може перезапустити под посеред неї.
Компроміси
- Скрипт дає рев’ю й контроль DBA, але застосовується чужими засобами — блокування EF у ньому не бере участі.
- Bundle — один артефакт із CI з блокуванням і сидингом, але його SQL заздалегідь не подивитися. Запускають його з явними
ASPNETCORE_ENVIRONMENTі--connection. - Схема й код розгортаються в різний час, тому міграція сумісна зі старою версією застосунку. Докладно — у темі Проєктування й експлуатація.
Як перевірити в продакшені
__EFMigrationsHistoryзвіряють зdotnet ef migrations list.dotnet ef migrations has-pending-model-changes(EF Core 8+) у CI падає, якщо модель змінили без міграції.
Підсумок: міграції застосовують окремим кроком під окремим обліковим записом; блокування EF Core 9 захищає від гонки, але не замінює рев’ю й сумісність схеми.
using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Migrations;
await using var db = new ShopContext();
// Тест у CI: модель змінили, а міграцію забули (EF Core 8+, база не потрібна)if (db.Database.HasPendingModelChanges()) throw new InvalidOperationException("Add a migration");
// Що ще не застосовано до цієї бази — за таблицею __EFMigrationsHistoryforeach (var id in await db.Database.GetPendingMigrationsAsync()) Console.WriteLine(id); // 20261007120000_RenameCustomerName
// Створено dotnet ef migrations add RenameCustomerName і виправлено вручну.// Атрибути [DbContext] і [Migration] лежать у файлі .Designer.cspublic partial class RenameCustomerName : Migration{ protected override void Up(MigrationBuilder migrationBuilder) { // Згенерувалося DropColumn("Name") + AddColumn("FullName"): дані зникли б migrationBuilder.RenameColumn(name: "Name", table: "Customers", newName: "FullName");
// Дані — через SQL, а не через DbContext: стара міграція не має залежати від сутностей migrationBuilder.Sql("UPDATE Customers SET FullName = LTRIM(RTRIM(FullName))"); }
protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.RenameColumn(name: "FullName", table: "Customers", newName: "Name");}
public class ShopContext : DbContext{ public DbSet<Customer> Customers => Set<Customer>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");}
public class Customer{ public int Id { get; set; } public required string FullName { get; set; }}dotnet ef migrations has-pending-model-changes # CI: чи не забули міграцію (EF Core 8+)dotnet ef migrations script --idempotent -o migrate.sql # скрипт для рев'ю та DBAdotnet ef migrations bundle --self-contained -r linux-x64 -o efbundle./efbundle --connection "$DEPLOY_CONNECTION" # крок розгортання під обліковим записом із правами на схему- Що робити, якщо дві гілки додали міграції? — Скасувати злиття, прибрати свою, влити чужу й створити свою заново.
- Чи можна видалити застосовану міграцію? — Не зі спільної бази: додають виправну міграцію.
- Чим ідемпотентний скрипт відрізняється від звичайного? — Перевіряє
__EFMigrationsHistoryі застосовує лише відсутні міграції. - Що змінилося в
Migrate()в EF Core 9? — Блокування від паралельного застосування й виняток, якщо модель змінено без міграції. - Як зловити забуту міграцію до розгортання? —
dotnet ef migrations has-pending-model-changesу CI абоHasPendingModelChanges()у тесті.
Як зробити EF Core швидким?
Section titled “Як зробити EF Core швидким?”Що це
EF Core повільний не сам по собі, а коли змушує базу робити зайве: тягнути зайві стовпці й рядки або виконувати багато запитів.
Як користуватися
- Проєкція.
Select(o => new OrderRow(o.Id, o.Total))— у SQL лише ці стовпці. AsNoTracking()— для даних тільки для читання.- Фільтр до матеріалізації.
WhereіTake— доToListAsync(), інакше вони спрацюють у пам’яті. - Масові зміни (EF Core 7+).
db.Orders.Where(...).ExecuteDeleteAsync()— одинDELETEзамість завантаження й видалення по одній. - Асинхронні методи у веб-застосунках:
ToListAsync,SaveChangesAsync.
Типова помилка
Завантажувати сутності цілком заради двох полів для списку. З бази приходять усі стовпці, зокрема великі тексти, і все потрапляє в трекер.
Як влаштовано
- Читання. Проєкція й
AsNoTrackingприбирають зайві стовпці, знімки й identity map. Проєкція в DTO не відстежується взагалі. - Запис.
SaveChangesпакетує команди: на SQL Server за замовчуванням до 42 операторів за round trip (MaxBatchSize). Із EF Core 7 вставки використовуютьOUTPUTі стали помітно швидшими. - Масові операції.
ExecuteUpdateAsyncіExecuteDeleteAsyncвиконують одинUPDATEабоDELETEза LINQ-фільтром одразу під час виклику. - Накладні витрати.
AddDbContextPoolперевикористовує контексти,EF.CompileAsyncQueryприбирає розбір дерева виразів.
Коли що обирати
| Завдання | Інструмент |
|---|---|
| Список, звіт, відповідь API | Select у DTO |
| Змінити кілька сутностей за бізнес-правилами | завантажити, змінити, SaveChanges |
| Змінити тисячі рядків за умовою | ExecuteUpdate, ExecuteDelete |
| Вставити сотні тисяч рядків | SqlBulkCopy або COPY у Npgsql, не EF |
Пастки
ExecuteUpdateповз трекер. Завантажені сутності не дізнаються про зміну, і наступнийSaveChangesперезапише її старими значеннями. Concurrency token він не перевіряє, транзакцію на кілька викликів не відкриває.- Тригери на SQL Server. Із EF Core 7 збереження через
OUTPUTпадає на таблицях із тригерами. Таблицю позначаютьHasTrigger(...)або, з EF Core 8,UseSqlOutputClause(false).
Правило
Спершу дивіться SQL і кількість команд на операцію, потім оптимізуйте.
Під капотом
- Виконання запиту починається з пошуку в кеші за формою дерева виразів. У разі промаху EF транслює запит і компілює матеріалізатор; у здоровому застосунку частка влучань швидко сягає 100 %.
AddDbContextPoolтримає до 1024 контекстів (за замовчуванням) і скидає їхній стан під час повернення.OnConfiguringвикликається один раз, тому дані запиту на кшталт tenant id передають через власну фабрику. З пулом з’єднань ADO.NET він не пов’язаний.EF.CompileAsyncQueryповертає потокобезпечний делегат; обмеження — одна модель і прості скалярні параметри.
Ціна
У бенчмарку Microsoft пул скоротив простий запит приблизно з 700 до 350 мкс і алокації з 50 до 5 КБ, скомпільований запит заощадив близько 100 мкс. На тлі мережі й роботи бази це важливо лише на гарячих шляхах.
Компроміси
- Константи в SQL дають базі точний план, параметри — перевикористання плану.
EF.Constant(EF Core 8.0.2+) іEF.Parameter(EF Core 9+) змінюють вибір EF для конкретного значення. EnableThreadSafetyChecks(false)прибирає перевірку паралельного доступу до контексту — лише після тестів, які довели її непотрібність.
Як перевірити в продакшені
- Категорія логів
Microsoft.EntityFrameworkCore.Database.Command— SQL і час кожної команди. - Метрики EF Core 9+ (meter
Microsoft.EntityFrameworkCore) абоdotnet-counters monitor --counters Microsoft.EntityFrameworkCore: запити на секунду, влучання в кеш запитів, активні контексти. - Гарячий шлях міряють BenchmarkDotNet до і після.
Підсумок: спершу форма SQL і round trip, потім трекінг, і лише на гарячих шляхах — пул контекстів і скомпільовані запити.
using Microsoft.EntityFrameworkCore;using Microsoft.EntityFrameworkCore.Infrastructure;using Microsoft.Extensions.Logging;
// Пул контекстів без DI; в ASP.NET Core — AddDbContextPoolvar factory = new PooledDbContextFactory<ShopContext>( new DbContextOptionsBuilder<ShopContext>() .UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True") .LogTo(Console.WriteLine, LogLevel.Information) // SQL і час кожної команди .Options);
await using var db = factory.CreateDbContext();
// Читання: два стовпці, без трекінгуvar page = await db.Orders .Where(o => o.Status == "Paid") .OrderBy(o => o.Id) .Select(o => new OrderRow(o.Id, o.Total)) .Take(50) .ToListAsync();
// Масова зміна: один UPDATE без завантаження сутностей (EF Core 7+), поверне кількість рядківvar expired = await db.Orders .Where(o => o.Status == "New" && o.CreatedAt < DateTime.UtcNow.AddDays(-30)) .ExecuteUpdateAsync(s => s.SetProperty(o => o.Status, "Expired"));
// Гарячий запит: дерево виразів розібране один разvar order = await ShopContext.OrderById(db, 42);
public record OrderRow(int Id, decimal Total);
public class ShopContext(DbContextOptions<ShopContext> options) : DbContext(options){ public DbSet<Order> Orders => Set<Order>();
public static readonly Func<ShopContext, int, Task<Order?>> OrderById = EF.CompileAsyncQuery((ShopContext db, int id) => db.Orders.FirstOrDefault(o => o.Id == id));}
public class Order{ public int Id { get; set; } public required string Status { get; set; } public DateTime CreatedAt { get; set; } [Precision(18, 2)] public decimal Total { get; set; }}- Чому
ExecuteUpdateне можна змішувати з відстежуваними змінами? — Трекер про нього не знає, іSaveChangesперезапише рядки старими значеннями. - Скільки команд
SaveChangesнадсилає за раз на SQL Server? — До 42 за замовчуванням, налаштовуєтьсяMaxBatchSize. - Чим пул контекстів відрізняється від пулу з’єднань? — Перший перевикористовує об’єкти
DbContext, другий — з’єднання ADO.NET; вони незалежні. - Що ламає кеш запитів EF? — Константи в динамічно побудованих деревах: нова форма на кожне значення.
- Коли потрібен compiled model? — Коли модель із сотень сутностей помітно сповільнює перший запит після старту.
EF Core, Dapper чи чистий ADO.NET: коли що обирати?
Section titled “EF Core, Dapper чи чистий ADO.NET: коли що обирати?”Що це
| Інструмент | Що робить |
|---|---|
| ADO.NET | SqlConnection, SqlCommand, DbDataReader: SQL і читання стовпців вручну |
| Dapper | виконує ваш SQL і розкладає стовпці по властивостях об’єкта |
| EF Core | генерує SQL з LINQ, відстежує зміни, веде міграції |
Навіщо три
- EF Core заощаджує код, де багато сутностей, зв’язків і змін.
- Dapper зручний, коли SQL складний і його хочеться контролювати: звіти, пошук.
- ADO.NET потрібен для того, чого немає в обгортках:
SqlBulkCopyдля масової вставки, потокове читання великих BLOB черезCommandBehavior.SequentialAccess.
Типова помилка
Склеювати SQL із рядків: "... WHERE Name = '" + name + "'". Це SQL-ін’єкція; значення передають лише параметрами — і в Dapper, і в EF.
Сирий SQL в EF Core
| API | Що повертає |
|---|---|
FromSql (EF Core 7+, раніше FromSqlInterpolated) |
сутності моделі; якщо SQL компонований, поверх можна Where і Include |
Database.SqlQuery<T> |
скаляри (EF Core 7+) і будь-які DTO (EF Core 8+) |
Database.ExecuteSql |
кількість зачеплених рядків для команд і процедур |
Версії ...Raw приймають готовий рядок — для імен стовпців, які не можна параметризувати. Із EF Core 10 аналізатор попереджає про склеювання рядків у них. Виклик процедури на SQL Server не компонується: після нього одразу AsAsyncEnumerable().
Час життя й потоки
- Два одночасні запити через один контекст дають
InvalidOperationException: A second operation started on this context before a previous operation completed. AddDbContextреєструє контекст як scoped: один на HTTP-запит. У singleton, фонових завданнях і Blazor Server —IDbContextFactory<T>іawait usingна операцію.
Коли що обирати
- Команди з бізнес-правилами — EF і
SaveChanges. - Важкі звіти —
SqlQuery<T>або Dapper на з’єднанні контексту. - Масове завантаження —
SqlBulkCopyабоCOPYу Npgsql.
Компроміси
| EF Core | Dapper, ADO.NET | |
|---|---|---|
| SQL | генерує EF, треба перевіряти | пишете самі |
| Зміни | трекінг, пакети, concurrency tokens | вручну |
| Схема | модель і міграції | окремий інструмент: DbUp, SSDT |
Concurrency tokens
- Властивість з
[Timestamp](на SQL Server —rowversion, у Npgsql —uintповерх системногоxmin) або[ConcurrencyCheck]потрапляє уWHEREкожногоUPDATEіDELETEз вихідним значенням. - Нуль зачеплених рядків —
DbUpdateConcurrencyException. Як розв’язати конфлікт, вирішує бізнес-логіка: перечитати (GetDatabaseValuesAsync), злити, повторити або повернути клієнту 409. ExecuteUpdateтокени не перевіряє: умову пишуть уWhereсамі й перевіряють кількість рядків.
Що нового в EF Core 9 і 10
- EF Core 9: блокування міграцій,
UseSeeding, автоматичний compiled model,EF.Parameter, метрики черезSystem.Diagnostics.Metrics. - EF Core 10 (LTS до листопада 2028):
LeftJoinіRightJoin, іменовані фільтри зIgnoreQueryFilters(["SoftDelete"]),ExecuteUpdateAsyncзі звичайною лямбдою, complex types у JSON і типjsonSQL Server 2025, векторний пошук. - Що помітно під час оновлення до 10:
ids.Contains(...)перекладається в окремі параметри з доповненням до «круглої» кількості замістьOPENJSON, імена параметрів стали коротшими (@city) — база перекомпілює майже всі плани. EF дописуєApplication Nameу рядок підключення без цього параметра: якщо Dapper у тому самомуTransactionScopeвідкриває своє з’єднання з вихідним рядком, вони потраплять у різні пули, і транзакція може стати розподіленою.
Підсумок: EF Core 10 закриває чимало причин іти в сирий SQL; Dapper і ADO.NET лишаються для ручного SQL і масових операцій, а конфлікти правок розв’язують токени конкурентності.
// NuGet: Microsoft.EntityFrameworkCore.SqlServer, Dapperusing Dapper;using Microsoft.EntityFrameworkCore;
await using var db = new ShopContext();var since = new DateTime(2026, 1, 1);
// FromSql: сутності моделі; {since} стане параметром, зверху добудовується LINQvar paid = await db.Orders .FromSql($"SELECT * FROM Orders WHERE CreatedAt >= {since}") .Where(o => o.Status == "Paid") .AsNoTracking() .ToListAsync();
// SqlQuery: DTO поза моделлю (EF Core 8+)var totals = await db.Database.SqlQuery<CustomerTotal>($""" SELECT CustomerId, SUM(Total) AS Total FROM Orders WHERE CreatedAt >= {since} GROUP BY CustomerId """).ToListAsync();
// Dapper на з'єднанні контексту: свій SQL, маппінг за іменами стовпцівvar top = await db.Database.GetDbConnection().QueryAsync<CustomerTotal>( "SELECT TOP (10) CustomerId, SUM(Total) AS Total FROM Orders GROUP BY CustomerId ORDER BY Total DESC");
public record CustomerTotal(int CustomerId, decimal Total);
public class ShopContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");}
public class Order{ public int Id { get; set; } public int CustomerId { get; set; } public required string Status { get; set; } public DateTime CreatedAt { get; set; } [Precision(18, 2)] public decimal Total { get; set; }}using System.ComponentModel.DataAnnotations;using Microsoft.EntityFrameworkCore;
await using var db = new ShopContext();var order = await db.Orders.SingleAsync(o => o.Id == 42);order.Status = "Shipped";
try{ // UPDATE ... WHERE [Id] = @p AND [RowVersion] = <значення під час завантаження> await db.SaveChangesAsync();}catch (DbUpdateConcurrencyException ex){ var entry = ex.Entries.Single(); var current = await entry.GetDatabaseValuesAsync() // null — рядок уже видалили ?? throw new InvalidOperationException("Order was deleted");
// Тут «перемагає останній»: беремо свіжий токен і зберігаємо свої значення entry.OriginalValues.SetValues(current); await db.SaveChangesAsync();}
public class ShopContext : DbContext{ public DbSet<Order> Orders => Set<Order>();
protected override void OnConfiguring(DbContextOptionsBuilder options) => options.UseSqlServer("Server=.;Database=Shop;Trusted_Connection=True");}
public class Order{ public int Id { get; set; } public required string Status { get; set; } [Timestamp] public byte[] RowVersion { get; set; } = []; // rowversion; у Npgsql — uint (xmin)}- Чим
FromSqlвідрізняється відFromSqlRaw? — Перший перетворює інтерполяцію на параметри, другий бере рядок як є. - Чи можна добудувати LINQ поверх виклику процедури? — На SQL Server ні: процедура не компонується, після неї переходять у пам’ять.
- Чому не можна
Task.WhenAllдвох запитів на одному контексті? — Контекст не потокобезпечний; потрібні два контексти. - Як
rowversionпрацює як concurrency token? — База змінює його під час кожного оновлення, EF порівнює вихідне значення уWHERE. - Що першим переписати на Dapper у разі проблем зі швидкістю? — Нічого наосліп: спершу логи SQL, потім точково найважчі запити.