Програмний продукт — живий організм. Він народжується з простою схемою: кілька таблиць, кілька стовпців. Але з кожним тижнем вимоги змінюються. Менеджер просить додати поле «знижка» до продукту. Аналітик хоче окремий журнал дій. Дизайнер вирішує що категорія має бути ієрархічною. Бізнес вимагає зберігати адреси доставки.
Кожна з цих змін означає зміну схеми бази даних. І тут виникає фундаментальне питання: як синхронізувати код і базу даних на всіх середовищах — локальній машині розробника, тестовому стенді, staging, production?
Це питання — не технічне, а архітектурне. І воно мало різні відповіді в різні епохи розробки ПЗ.
У 2000-х роках найпоширеніший підхід виглядав так. DBA (Database Administrator) писав SQL-скрипт вручну. Розробники отримували його поштою або через файловий сервер. Хтось запускав на тестовому середовищі. Хтось забував. Хтось запускав двічі. Продакшн оновлювався «за вікном» з молитвою.
-- v1.2.3_add_discount_to_products.sql
-- Запустити ВРУЧНУ на prod перед деплоєм v1.2.3!
ALTER TABLE Products ADD Discount DECIMAL(5,2) NULL;
ALTER TABLE Products ADD DiscountType NVARCHAR(20) NULL;
-- Якщо вже запускали раніше — закоментуйте ці рядки!
Проблеми цього підходу очевидні з першого погляду:
Відсутність версіонування: Хто і коли запустив цей скрипт? Запускали його на конкретному середовищі чи ні? Немає записів — немає відповідей.
Залежність від людини: DBA може бути у відпустці. Розробник може забути запустити. Новий член команди не знає з якого скрипту починати.
Ризик подвійного виконання: ALTER TABLE Products ADD Discount упаде з помилкою якщо стовпець вже є. Доводиться додавати IF NOT EXISTS — і скрипт ускладнюється.
Відсутність можливості відкоту: Якщо деплой пішов шиворот-навиворіт — скрипт відкату треба також писати вручну. І він теж може мати помилки.
Приблизно в 2010-х з'явився новий клас інструментів — міграційні фреймворки. Їх ідея проста і геніальна: кожна зміна схеми — це пронумерований файл. Фреймворк сам відстежує які файли вже застосовано. Запустив команду — застосовуються лише нові, у правильному порядку, рівно один раз.
DbUp
Flyway
V1__init.sql, V2__add_discount.sql). Потужний, enterprise-рівня, з підтримкою repair, validate, baseline.Liquibase
EF Core Migrations
Ключова відмінність EF Core Migrations — автоматична генерація змін. Ви не пишете SQL ALTER TABLE — ви змінюєте C# клас. EF Core сам визначає що змінилось в моделі, порівнює з збереженим snapshot і генерує міграцію.
Це дає величезну перевагу: розробник думає про модель предметної області, а не про DDL-синтаксис конкретної СУБД. Той самий C# код генерує правильний SQL для SQL Server, PostgreSQL або SQLite.
migrationBuilder.Sql() для кастомних SQL-операцій.Перш ніж зануритись у команди, важливо зрозуміти що таке міграція концептуально.
Міграція — це опис різниці між двома версіями схеми бази даних. Вона має:
Up() — операції для переходу до нової версії (додати таблицю, стовпець, індекс)Down() — операції для повернення до попередньої версії (відповідні DROP операції)Набір міграцій формує ланцюг: кожна наступна міграція залежить від попередньої. Застосовуючи їх послідовно від початку — отримуємо актуальну схему. Скасовуючи у зворотному порядку — повертаємось до будь-якої попередньої версії.
__EFMigrationsHistory — спеціальна таблиця що EF Core автоматично створює у вашій базі. Вона зберігає список вже застосованих міграцій. Перед кожним database update — EF Core читає цю таблицю, порівнює з файлами міграцій і виконує лише ті що ще не застосовано.
Міграції генеруються через CLI-інструмент dotnet ef. Він встановлюється як глобальний .NET-інструмент:
Або як локальний інструмент проєкту (рекомендовано для команд):
Переваги локального інструменту: версія зафіксована у .config/dotnet-tools.json → всі члени команди використовують одну версію → відтворюваність CI/CD.
<!-- YourProject.csproj -->
<ItemGroup>
<!-- EF Core для вашого провайдера -->
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="9.0.*" />
<!-- АБО PostgreSQL: -->
<!-- <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.*" /> -->
<!-- Design-time пакет: ЛИШЕ для проєкту де є DbContext -->
<!-- Потрібен для dotnet ef команд, не потрапляє в runtime -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.*">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
</ItemGroup>
Microsoft.EntityFrameworkCore.Design — обов'язковий для CLI-команд. Без нього dotnet ef migrations add завершиться з помилкою No DbContext was found. Атрибут <PrivateAssets>all</PrivateAssets> гарантує що пакет не потрапить у публіковану збірку.Давайте пройдемо весь процес від початку. Маємо просту модель:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
public class Category
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public ICollection<Product> Products { get; set; } = new List<Product>();
}
public class AppDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseSqlServer("Server=.;Database=ShopDb;Trusted_Connection=True");
}
Виконуємо першу міграцію:
Що відбулось «під капотом»:
dotnet ef збирає проєкт щоб отримати актуальну версію вашої C# моделі. Без цього він не знатиме який «поточний стан» моделі.
Якщо це не перша міграція — читає файл Migrations/AppDbContextModelSnapshot.cs який описує попередній стан моделі. Якщо snapshot ще не існує — вважає що попередній стан «пустий».
EF Core порівнює поточну C# модель зі snapshot і визначає різницю: нові таблиці, нові стовпці, змінені типи, нові індекси.
Створює два файли:
Migrations/{timestamp}_{Name}.cs — сам файл міграції (Up + Down)Migrations/AppDbContextModelSnapshot.cs — оновлений snapshot поточного стануРезультат — три нових файли у папці Migrations/:
Migrations/
├── 20250329120000_InitialCreate.cs ← файл міграції
├── 20250329120000_InitialCreate.Designer.cs ← метадані (не редагувати!)
└── AppDbContextModelSnapshot.cs ← snapshot поточного стану моделі
Відкриємо згенерований файл і розберемо кожен елемент:
// 20250329120000_InitialCreate.cs
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YourApp.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration // ← клас успадковує Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder) // Up: → нова версія
{
// CREATE TABLE Categories
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"), // IDENTITY(1,1)
Name = table.Column<string>(type: "nvarchar(max)", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
});
// CREATE TABLE Products (залежить від Categories — тому після!)
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(type: "nvarchar(max)", nullable: false),
Price = table.Column<decimal>(type: "decimal(18,2)", nullable: false),
CategoryId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
table.ForeignKey( // FOREIGN KEY
name: "FK_Products_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
// CREATE INDEX на FK стовпці (EF Core робить це автоматично)
migrationBuilder.CreateIndex(
name: "IX_Products_CategoryId",
table: "Products",
column: "CategoryId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder) // Down: ← попередня версія
{
// DROP TABLE у зворотному порядку (спочатку залежні!)
migrationBuilder.DropTable(name: "Products");
migrationBuilder.DropTable(name: "Categories");
}
}
}
Файл {Name}.Designer.cs — автоматично згенерований і ніколи не редагується вручну. Він містить BuildTargetModel — повний опис моделі станом на цю конкретну міграцію. EF Core використовує його для:
// 20250329120000_InitialCreate.Designer.cs (фрагмент)
[DbContext(typeof(AppDbContext))]
[Migration("20250329120000_InitialCreate")]
partial class InitialCreate
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
modelBuilder
.HasAnnotation("ProductVersion", "9.0.3")
.HasAnnotation("Relational:MaxIdentifierLength", 128);
modelBuilder.Entity("YourApp.Category", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd()...;
b.Property<string>("Name").IsRequired()...;
b.HasKey("Id");
b.ToTable("Categories");
});
// ... опис Products entity
}
}
AppDbContextModelSnapshot.cs — найважливіший файл у папці Migrations. Він зберігає повний опис поточного стану моделі що EF Core «пам'ятає». Саме з цим файлом порівнюється ваша C# модель при кожному migrations add.
// AppDbContextModelSnapshot.cs
[DbContext(typeof(AppDbContext))]
partial class AppDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
// Повне описання ПОТОЧНОГО стану всієї моделі:
// - всі entity
// - всі властивості з типами
// - всі зв'язки
// - всі індекси
// - всі обмеження
// Це «фотографія» того, як EF Core розуміє вашу поточну схему БД
}
}
Видалення або пошкодження ModelSnapshot призведе до катастрофи:
migrations add — згенерує міграцію що намагається створити ВСІ таблиці зновуdatabase update впаде з помилкою «таблиця вже існує»ModelSnapshot — єдине джерело правди про попередній стан моделі. Він має бути у системі контролю версій (Git) і ніколи не редагуватись вручну.
ModelSnapshot. Це нормально і очікувано. Вирішення: після merge вручну запустити dotnet ef migrations add для синхронізації. Або використовувати dotnet ef migrations bundle для CI/CD.Коли ви виконуєте dotnet ef migrations add MyMigration:
Поточна C# модель (рефлексія)
↓
[EF Core Model Builder]
↓
Поточна EF модель (IMutableModel)
↓
[Порівняємо з]
↓
ModelSnapshot (попередній стан)
↓
[MigrationsModelDiffer]
↓
Список операцій: [AddColumn, CreateTable, CreateIndex, ...]
↓
[C# Code Generator]
↓
Файл міграції (Up + Down)
Оновлений ModelSnapshot
MigrationsModelDiffer — внутрішній клас EF Core що виконує семантичне порівняння двох моделей. Він розуміє не просто «поле зникло» — а «стовпець перейменовано» (якщо змінилось [Column]), «тип змінено» (якщо string став nvarchar(100)).
Після створення міграції — час застосувати її до реальної бази:
Що відбувається послідовно:
EF Core виконує SELECT OBJECT_ID('[__EFMigrationsHistory]') — чи існує таблиця. Якщо ні — створює її автоматично.
SELECT MigrationId FROM __EFMigrationsHistory — отримує список вже виконаних міграцій.
Порівнює список у БД з файлами у папці Migrations/. Визначає які файли ще не застосовані.
Для кожної нової міграції (у хронологічному порядку) виконує Up() методи у транзакції.
Після успішного виконання кожної міграції — додає запис у таблицю: INSERT INTO __EFMigrationsHistory VALUES ('20250329120000_InitialCreate', '9.0.3').
# Застосувати до конкретної міграції (не обов'язково останньої)
dotnet ef database update AddDiscount
# Повернутись до конкретної міграції (виконає Down() для наступних)
dotnet ef database update InitialCreate
# Повністю скасувати всі міграції (виконає Down() для всіх)
dotnet ef database update 0
dotnet ef database update 0 виконає Down() для всіх міграцій — видалить усі таблиці! Використовуйте лише у dev-середовищі для скидання схеми.Додамо нове поле до Product:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
// Нові поля:
public decimal? Discount { get; set; } // nullable → NULL у БД
public bool IsActive { get; set; } = true; // default value
public DateTime CreatedAt { get; set; }
}
Генеруємо міграцію:
Згенерований файл буде містити:
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<decimal>(
name: "Discount",
table: "Products",
type: "decimal(18,2)",
nullable: true); // ← nullable: true, бо decimal?
migrationBuilder.AddColumn<bool>(
name: "IsActive",
table: "Products",
type: "bit",
nullable: false,
defaultValue: false); // ← default false, EF Core додасть для існуючих рядків
migrationBuilder.AddColumn<DateTime>(
name: "CreatedAt",
table: "Products",
type: "datetime2",
nullable: false,
defaultValue: new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified));
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Discount", table: "Products");
migrationBuilder.DropColumn(name: "IsActive", table: "Products");
migrationBuilder.DropColumn(name: "CreatedAt", table: "Products");
}
Є два різних поняття «скасувати міграцію»:
remove — видаляє останню згенеровану міграцію (файл .cs та .Designer.cs) і відновлює ModelSnapshot до попереднього стану. Використовується коли ви зробили помилку у моделі і хочете починати знову.
migrations removeне виконує Down() міграції у базі! Він лише видаляє файл. Якщо міграцію вже застосовано до БД (database update), спочатку потрібно відкотити БД: dotnet ef database update {PreviousMigration}, і тільки потім migrations remove.Щоб відкотити вже застосовану міграцію у базі — вказуємо ім'я міграції до якої хочемо повернутись:
# Повернутись до стану після InitialCreate (виконає Down() для AddProductFields)
dotnet ef database update InitialCreate
Таблиця порівняння:
| Команда | Що робить | Зі змінами у БД | Без змін у БД |
|---|---|---|---|
migrations remove | Видаляє файл міграції | ❌ Помилка | ✅ Так |
database update {prev} | Виконує Down() у БД | ✅ Так | ❌ Нічого не робить |
# 1. Відкотити БД до попередньої міграції
dotnet ef database update InitialCreate
# 2. Видалити файл міграції що не потрібен
dotnet ef migrations remove
# 3. Виправити модель і створити нову правильну міграцію
# ... (змінили C# клас) ...
dotnet ef migrations add AddProductFieldsFixed
dotnet ef database update
Ім'я міграції — не технічна деталь, а документація вашої схеми. Хороше ім'я через 2 роки пояснить колезі чому ця зміна відбулась.
dotnet ef migrations add InitialCreate
dotnet ef migrations add AddDiscountToProducts
dotnet ef migrations add CreateOrdersAndLineItemsTables
dotnet ef migrations add AddUniqueIndexOnUserEmail
dotnet ef migrations add RenameProductDescriptionColumn
dotnet ef migrations add AddSoftDeleteToProducts
dotnet ef migrations add CreateAuditLogTable
dotnet ef migrations add Migration1
dotnet ef migrations add Update
dotnet ef migrations add Fix
dotnet ef migrations add test123
dotnet ef migrations add ChangesSomeStuff
dotnet ef migrations add v2
Хороше ім'я міграції має відповідати на питання: «що саме змінено у схемі?»
# Список всіх міграцій та їх статус (Applied/Pending)
dotnet ef migrations list
Створіть новий проєкт з AppDbContext що містить Product, Category, Tag (many-to-many через implicit join). Виконайте:
dotnet ef migrations add InitialCreate — перевірте згенерований файл.Designer.cs — знайдіть BuildTargetModel і прочитайте опис моделіdotnet ef database update — перевірте __EFMigrationsHistory через SSMS/psqlDescription до Product → нова міграція → updateДля вашого проєкту відпрацюйте повний cycle:
CreatedAt/UpdatedAt до Product → migrations add AddAuditFieldsdatabase update → перевірте стовпці у БДTimestamp замість двох полів:
database update InitialCreate (відкат)migrations remove (видалити файл)migrations add AddTimestampToProductdatabase updateВідкрийте AppDbContextModelSnapshot.cs:
migrations add → знайдіть де snapshot оновивсяДля кожної операції в Up() перевірте що Down() є коректним зворотнім:
CreateTable → DropTable з правильним порядком (залежні першими у Down)AddColumn NOT NULL без default → DropColumn у Down. Питання: що станеться з існуючими рядками при AddColumn NOT NULL?CreateIndex → DropIndexDown() — що станеться при database update {previous}?Симулюйте командний конфлікт:
feature/discount: додає Discount до Product → міграціяfeature/tags: додає Tag entity → міграціяmigrations add після merge)Реалізуйте MigrationHealthCheck : IHealthCheck що:
Pending міграції (context.Database.GetPendingMigrationsAsync())Healthy якщо Pending = 0, Degraded якщо є Pending міграціїservices.AddHealthChecks().AddCheck<MigrationHealthCheck>("migrations")Перша частина заклала повний концептуальний фундамент міграцій:
__EFMigrationsHistory = таблиця-журнал застосованих міграційdotnet ef migrations add: збирає проєкт → читає snapshot → порівнює → генерує Up/Down + оновлює snapshotUp() і Down() методи, CreateTable/AddColumn/CreateIndex операції. .Designer.cs з BuildTargetModel — не редагувати!database update: читає __EFMigrationsHistory → знаходить Pending → виконує Up() у транзакції → записує у журналmigrations remove = видалити файл (не торкає БД). database update {prev} = виконати Down() у БДУ другій частині — SQL-скрипти для production deployment, Idempotent Scripts (--idempotent), Migrations Bundles для автономного виконання, та стратегії автоматичного застосування міграцій у CI/CD pipelines.
Конкурентність — Дедлоки та Queue Processing (Частина 2)
Lock Escalation і дедлоки в EF Core — як вони виникають і як їх уникати. SELECT FOR UPDATE SKIP LOCKED для надійного queue processing. Monitoring конкурентних проблем у production. Concurrency Patterns для реальних систем.
Міграції в EF Core — Основи (Частина 2)
SQL-скрипти для production deployment, idempotent scripts, Migrations Bundle — self-contained виконуваний файл. Стратегії автоматичного застосування в CI/CD, Database.MigrateAsync, програмне управління міграціями. Workflow від коду до продакшн БД.