Ef Core

Seed Data — Початкові Дані (Частина 1)

Стратегії наповнення бази початковими даними в EF Core — HasData() через Fluent API, обмеження і практичні нюанси, Alternate Key у seed data, маппінг Shadow Properties, Data Seeding через Initialization сервіс.

Seed Data: Початкові Дані

Навіщо потрібен Seed Data

Будь-який реальний застосунок потребує певних даних у базі для коректної роботи. Ці дані можна розділити на три категорії.

Довідники (Reference / Lookup data): списки значень, що рідко змінюються і потрібні для роботи бізнес-логіки. Ролі користувачів (Admin, Manager, Customer), категорії товарів (Electronics, Clothing, Food), статуси замовлень, коди країн (ISO 3166), коди валют (ISO 4217). Без цих даних застосунок не може функціонувати — новий замовник не може обрати роль, новий товар не може потрапити до категорії.

Дефолтні налаштування (Default configuration): конфігурація, що має бути в базі і зазвичай не змінюється. Конфігурація email-шаблонів, дефолтні тарифні плани для SaaS, початкові налаштування фіче-флагів.

Демо або тестові дані (Demo / Test data): використовуються при розробці або для демонстрації. Тестові користувачі, замовлення, продукти — щоб розробник міг відразу працювати зі змістовними даними, не вводячи їх вручну.

Правильна стратегія Seed Data:

  • Надійна: якщо запустити двічі — немає дублікатів
  • Ідемпотентна: повторний запуск не ламає стан бази
  • Версіонована: зміни seed data відстежуються (в ідеалі — через міграції)
  • Окремо для середовищ: dev seed ≠ production seed

EF Core пропонує кілька підходів, і вибір між ними залежить від типу даних і середовища.


HasData: Seed через Fluent API і Міграції

HasData — офіційний механізм EF Core для вбудовування seed data у модель. Дані, оголошені через HasData, потрапляють у міграції і застосовуються разом зі схемою бази.

Базовий синтаксис

public class Country
{
    public string Code { get; set; } = string.Empty;  // PK: "UA", "US", "DE"
    public string Name { get; set; } = string.Empty;
    public string ContinentCode { get; set; } = string.Empty;
    public bool IsActive { get; set; } = true;
}
public class CountryConfiguration : IEntityTypeConfiguration<Country>
{
    public void Configure(EntityTypeBuilder<Country> builder)
    {
        builder.HasKey(c => c.Code);
        builder.Property(c => c.Code).HasMaxLength(2).IsUnicode(false);
        builder.Property(c => c.Name).IsRequired().HasMaxLength(100);
        builder.Property(c => c.ContinentCode).HasMaxLength(2).IsUnicode(false);

        // HasData: початкові дані вбудовані у конфігурацію
        builder.HasData(
            new Country { Code = "UA", Name = "Україна",       ContinentCode = "EU" },
            new Country { Code = "US", Name = "США",           ContinentCode = "NA" },
            new Country { Code = "DE", Name = "Німеччина",     ContinentCode = "EU" },
            new Country { Code = "GB", Name = "Велика Британія", ContinentCode = "EU" },
            new Country { Code = "FR", Name = "Франція",       ContinentCode = "EU" },
            new Country { Code = "PL", Name = "Польща",        ContinentCode = "EU" },
            new Country { Code = "JP", Name = "Японія",        ContinentCode = "AS" }
        );
    }
}

Після Add-Migration InitialSeed або dotnet ef migrations add InitialSeed у файлі міграції з'явиться:

// Автоматично генерований код міграції:
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.CreateTable(
        name: "Countries",
        columns: table => new { ... },
        constraints: table => { table.PrimaryKey("PK_Countries", x => x.Code); });

    migrationBuilder.InsertData(
        table: "Countries",
        columns: new[] { "Code", "ContinentCode", "IsActive", "Name" },
        values: new object[,]
        {
            { "DE", "EU", true, "Німеччина" },
            { "FR", "EU", true, "Франція" },
            { "GB", "EU", true, "Велика Британія" },
            { "JP", "AS", true, "Японія" },
            { "PL", "EU", true, "Польща" },
            { "UA", "EU", true, "Україна" },
            { "US", "NA", true, "США" }
        });
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DeleteData(
        table: "Countries",
        keyColumn: "Code",
        keyValues: new object[] { "DE", "FR", "GB", "JP", "PL", "UA", "US" });
}

Down() автоматично видаляє seed-записи. Це одна з переваг HasData — повна версіонованість через міграційний механізм.

HasData з int PK: важливий нюанс

При HasData з int-ідентифікаторами (IDENTITY) обов'язково вказуйте значення PK у seed data. EF Core не може використовувати IDENTITY у seed контексті — значення має бути явним:

public class Role
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string Description { get; set; } = string.Empty;
}
public class RoleConfiguration : IEntityTypeConfiguration<Role>
{
    public void Configure(EntityTypeBuilder<Role> builder)
    {
        builder.HasKey(r => r.Id);
        builder.Property(r => r.Name).IsRequired().HasMaxLength(100);
        builder.Property(r => r.Description).HasMaxLength(500);

        // Id вказано явно! Інакше — помилка
        builder.HasData(
            new Role { Id = 1, Name = "Admin",    Description = "Повний доступ" },
            new Role { Id = 2, Name = "Manager",  Description = "Управління" },
            new Role { Id = 3, Name = "Customer", Description = "Покупець" },
            new Role { Id = 4, Name = "Support",  Description = "Підтримка" }
        );
    }
}
Seed Id і IDENTITY: Якщо у таблиці є IDENTITY, а seed data має фіксовані Id — база потребує SET IDENTITY_INSERT Roles ON при INSERT. EF Core автоматично виконує це для InsertData у міграціях. Але будьте обережні: після seed Id = 1,2,3,4 — наступний IDENTITY буде 5. Якщо seed data прибрати пізніше і додати нову — треба перевіряти, що IDENTITY не конфліктує.

Зміна Seed Data: нова міграція

Якщо потрібно змінити seed (додати нову роль, виправити назву) — просто змінюємо HasData і додаємо нову міграцію:

// Додаємо нову роль:
builder.HasData(
    new Role { Id = 1, Name = "Admin",    Description = "Повний доступ" },
    new Role { Id = 2, Name = "Manager",  Description = "Управління" },
    new Role { Id = 3, Name = "Customer", Description = "Покупець" },
    new Role { Id = 4, Name = "Support",  Description = "Підтримка" },
    new Role { Id = 5, Name = "Auditor",  Description = "Тільки перегляд" } // НОВА
);
dotnet ef migrations add AddAuditorRole

Генерована міграція:

protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.InsertData(
        table: "Roles",
        columns: new[] { "Id", "Description", "Name" },
        values: new object[] { 5, "Тільки перегляд", "Auditor" });
}

protected override void Down(MigrationBuilder migrationBuilder)
{
    migrationBuilder.DeleteData(table: "Roles", keyColumn: "Id", keyValue: 5);
}

EF Core відстежує зміни у HasData — нова міграція містить лише diff, не повний перезапис.


HasData з Foreign Keys: seed залежних записів

Якщо seed-записи мають FK — порядок важливий: спочатку батьківські записи.

public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public int? ParentCategoryId { get; set; }  // само-посилання
    public Category? ParentCategory { get; set; }
    public ICollection<Category> SubCategories { get; set; } = new List<Category>();
}
public class CategoryConfiguration : IEntityTypeConfiguration<Category>
{
    public void Configure(EntityTypeBuilder<Category> builder)
    {
        builder.HasKey(c => c.Id);
        builder.Property(c => c.Name).IsRequired().HasMaxLength(200);

        builder.HasOne(c => c.ParentCategory)
               .WithMany(c => c.SubCategories)
               .HasForeignKey(c => c.ParentCategoryId)
               .OnDelete(DeleteBehavior.Restrict);

        // Спочатку батьківські (ParentCategoryId = null)
        builder.HasData(
            new Category { Id = 1, Name = "Електроніка",    ParentCategoryId = null },
            new Category { Id = 2, Name = "Одяг",            ParentCategoryId = null },
            new Category { Id = 3, Name = "Книги",           ParentCategoryId = null },
            // Потім дочірні (Id батька вже є у seed)
            new Category { Id = 4, Name = "Ноутбуки",        ParentCategoryId = 1 },
            new Category { Id = 5, Name = "Смартфони",       ParentCategoryId = 1 },
            new Category { Id = 6, Name = "Телевізори",      ParentCategoryId = 1 },
            new Category { Id = 7, Name = "Чоловічий одяг",  ParentCategoryId = 2 },
            new Category { Id = 8, Name = "Жіночий одяг",    ParentCategoryId = 2 },
            new Category { Id = 9, Name = "Технічна л-ра",   ParentCategoryId = 3 },
            new Category { Id = 10, Name = "Художня л-ра",   ParentCategoryId = 3 }
        );
    }
}

EF Core сортує INSERT у правильному порядку в міграції — спочатку записи без FK, потім залежні.


HasData і Shadow Properties

Seed data може також заповнювати Shadow Properties (тіньові властивості) — ті, що не є частиною C#-класу:

public class BlogPost
{
    public int Id { get; set; }
    public string Title { get; set; } = string.Empty;
    public string Content { get; set; } = string.Empty;
    // CreatedAt — shadow property (не у класі)
}
public class BlogPostConfiguration : IEntityTypeConfiguration<BlogPost>
{
    public void Configure(EntityTypeBuilder<BlogPost> builder)
    {
        builder.HasKey(p => p.Id);
        builder.Property(p => p.Title).IsRequired().HasMaxLength(300);

        // Shadow property
        builder.Property<DateTime>("CreatedAt")
               .HasDefaultValueSql("GETUTCDATE()");

        // HasData з Shadow Property через анонімний об'єкт
        builder.HasData(new[]
        {
            new
            {
                Id = 1,
                Title = "Вступ до EF Core",
                Content = "Entity Framework Core — це...",
                CreatedAt = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)
            },
            new
            {
                Id = 2,
                Title = "LINQ-запити в EF Core",
                Content = "LINQ дозволяє писати запити...",
                CreatedAt = new DateTime(2024, 1, 15, 0, 0, 0, DateTimeKind.Utc)
            }
        });
    }
}

Зверніть: при shadow properties у HasData обов'язково використовувати анонімний об'єкт з усіма полями, включно з тіньовими.


HasData: Обмеження та Антипатерни

Попри зручність HasData, у нього є принципові обмеження, які важливо розуміти.

Обмеження 1: Owned Types не підтримуються напряму

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public Address ShippingAddress { get; set; } = null!; // Owned Type
}
// НЕПРАВИЛЬНО — Owned Type через HasData напряму:
builder.HasData(new Product
{
    Id = 1,
    Name = "Laptop",
    ShippingAddress = new Address { Street = "...", City = "Kyiv" } // ← помилка!
});

Owned Types у HasData потребують окремого seed через свій builder:

// ПРАВИЛЬНО: спочатку Product
builder.HasData(new { Id = 1, Name = "Laptop" });

// Потім Address через OwnsOne builder:
builder.OwnsOne(p => p.ShippingAddress).HasData(new
{
    ProductId = 1,  // FK до Product
    Street = "вул. Хрещатик, 1",
    City = "Київ",
    Country = "Україна",
    PostalCode = "01001"
});

Обмеження 2: Великий обсяг даних

HasData з тисячами записів — погана ідея:

  • Вся seed data вбудована у C# код конфігурації → важко редагувати
  • Велика міграція важко читається і займає багато часця у VCS
  • При зміні навіть одного запису — нова міграція, що diff показує зміну у великому масиві

Для великих обсягів seed data (100+ записів) — використовуйте Database Initialization Сервіс (розглянемо далі) або migrationBuilder.Sql().

Обмеження 3: Не підходить для динамічних даних

// НЕПРАВИЛЬНО: динамічні дані у HasData
builder.HasData(
    new Setting { Key = "JwtSecret", Value = Environment.GetEnvironmentVariable("JWT_SECRET")! }
    // ← значення залежне від середовища, не повинно бути у міграції
);

Seed data через HasDataстатична і однакова для всіх середовищ. Конфігурацію, що залежить від середовища, треба вносити через Initialization Service або IConfiguration.

Антипатерн: HasData для тестових даних у Production коді

// АНТИПАТЕРН: тестові дані у production конфігурації
builder.HasData(
    new Customer { Id = 1, Name = "Test User", Email = "test@test.com", Password = "123456" },
    new Customer { Id = 2, Name = "John Doe",  Email = "john@doe.com",  Password = "password" }
);
// Ці дані потраплять у production БД!

Тестові/demo дані — лише для dev середовища. Виноси їх у окремий seed-сервіс, що активується лише для Development environment.


Database Initialization Service: Seed поза міграціями

Для складнішого seeding — окремий сервіс, що виконується при старті застосунку. Цей підхід дає:

  • Умовний seed (тільки якщо записів ще немає)
  • Середовищ-залежний seed (dev / staging / prod)
  • Підтримку великих обсягів даних
  • Seed з бізнес-логікою (хешування паролів, генерація UUID тощо)

Базова структура Initialization Service

public interface IDatabaseInitializer
{
    Task InitializeAsync(CancellationToken cancellationToken = default);
}

public class DatabaseInitializer : IDatabaseInitializer
{
    private readonly AppDbContext _context;
    private readonly ILogger<DatabaseInitializer> _logger;

    public DatabaseInitializer(AppDbContext context, ILogger<DatabaseInitializer> logger)
    {
        _context = context;
        _logger = logger;
    }

    public async Task InitializeAsync(CancellationToken cancellationToken = default)
    {
        // 1. Застосувати міграції (якщо є)
        await _context.Database.MigrateAsync(cancellationToken);

        // 2. Засіяти дані
        await SeedCountriesAsync(cancellationToken);
        await SeedRolesAsync(cancellationToken);
        await SeedDefaultSettingsAsync(cancellationToken);
    }

    private async Task SeedCountriesAsync(CancellationToken ct)
    {
        // Ідемпотентна перевірка: вже є хоч один запис?
        if (await _context.Countries.AnyAsync(ct))
        {
            _logger.LogInformation("Countries already seeded, skipping.");
            return;
        }

        var countries = new[]
        {
            new Country { Code = "UA", Name = "Україна",   ContinentCode = "EU" },
            new Country { Code = "US", Name = "США",        ContinentCode = "NA" },
            new Country { Code = "DE", Name = "Німеччина",  ContinentCode = "EU" },
            new Country { Code = "PL", Name = "Польща",     ContinentCode = "EU" },
        };

        await _context.Countries.AddRangeAsync(countries, ct);
        await _context.SaveChangesAsync(ct);

        _logger.LogInformation("Seeded {Count} countries.", countries.Length);
    }

    private async Task SeedRolesAsync(CancellationToken ct)
    {
        var existingRoleNames = await _context.Roles
            .Select(r => r.Name)
            .ToHashSetAsync(ct);

        // Додаємо лише нові ролі, не перезаписуємо
        var rolesToSeed = new[]
        {
            new Role { Name = "Admin",    Description = "Повний доступ" },
            new Role { Name = "Manager",  Description = "Управління" },
            new Role { Name = "Customer", Description = "Покупець" },
        };

        var newRoles = rolesToSeed
            .Where(r => !existingRoleNames.Contains(r.Name))
            .ToList();

        if (newRoles.Count == 0) return;

        await _context.Roles.AddRangeAsync(newRoles, ct);
        await _context.SaveChangesAsync(ct);

        _logger.LogInformation("Seeded {Count} new roles.", newRoles.Count);
    }

    private async Task SeedDefaultSettingsAsync(CancellationToken ct)
    {
        // Upsert: вставити якщо немає, оновити якщо є і значення змінилось
        var defaults = new Dictionary<string, string>
        {
            ["app.name"]             = "MyStore",
            ["app.support_email"]    = "support@mystore.com",
            ["app.max_file_size_mb"] = "10",
            ["app.default_currency"] = "UAH",
        };

        foreach (var (key, value) in defaults)
        {
            var setting = await _context.Settings.FindAsync([key], ct);

            if (setting is null)
            {
                await _context.Settings.AddAsync(new Setting { Key = key, Value = value }, ct);
            }
            // Не оновлюємо якщо вже існує — адмін міг змінити налаштування
        }

        await _context.SaveChangesAsync(ct);
    }
}

Реєстрація і запуск у Program.cs

// Program.cs
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddScoped<IDatabaseInitializer, DatabaseInitializer>();

var app = builder.Build();

// Запуск ініціалізатора при старті
using (var scope = app.Services.CreateScope())
{
    var initializer = scope.ServiceProvider.GetRequiredService<IDatabaseInitializer>();
    await initializer.InitializeAsync();
}

app.Run();

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

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

Завдання 1.1: HasData для довідника

Реалізуйте seed для Currency (Code varchar(3) PK, Name, Symbol, DecimalPlaces int) через HasData у конфігурації. Засійте мінімум 10 валют (UAH, USD, EUR, GBP, JPY, CHF, CAD, AUD, PLN, CZK). Перевірте в міграції, що генерується InsertData.

Завдання 1.2: Ієрархічний seed

Department (Id, Name, ParentDepartmentId?) має само-посилання. Засійте ієрархію:

  • IT (Id=1)
    • Backend Development (Id=2, Parent=1)
    • Frontend Development (Id=3, Parent=1)
    • DevOps (Id=4, Parent=1)
  • Marketing (Id=5)
    • Digital Marketing (Id=6, Parent=5)
    • PR (Id=7, Parent=5)

Перевірте порядок InsertData у міграції — спочатку батьківські чи дочірні?

Завдання 1.3: Seed з Shadow Properties

Article (Id, Title, Body) має shadow properties CreatedAt і ViewCount (int, default 0). Запишіть через HasData 3 статті з явними значеннями CreatedAt. Що відбудеться, якщо не вказати ViewCount у seed — яке значення буде у БД?

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

Завдання 2.1: Ідемпотентний Initialization Service

Реалізуйте DatabaseInitializer для магазину:

  • SeedCategoriesAsync: засіяти дерево категорій (Electronics → Laptops, Phones; Clothing → Men, Women)
  • SeedDefaultAdminAsync: якщо жодного Admin-користувача немає — створити дефолтного (email з env variable, хешований пароль)
  • SeedSettingsAsync: upsert для налаштувань без перезапису існуючих

При повторному запуску — жодних помилок і жодних дублікатів.

Завдання 2.2: Середовищ-залежний Seed

Extend DatabaseInitializer для підтримки dev-seed:

if (env.IsDevelopment())
{
    await SeedDemoCustomersAsync(ct);   // 20 тестових клієнтів
    await SeedDemoProductsAsync(ct);   // 50 тестових продуктів
    await SeedDemoOrdersAsync(ct);     // 100 тестових замовлень з рядками
}

Використайте Bogus (бібліотека генерації fake-даних) для реалістичних тестових даних.

Рівень 3 — Архітектура

Завдання 3.1: Seeding Framework

Реалізуйте власний мінімальний seeding framework:

  • ISeeder interface: int Order { get; } (пріоритет виконання), Task SeedAsync(AppDbContext ctx, CancellationToken ct)
  • CountriesSeeder : ISeeder (Order = 1)
  • RolesSeeder : ISeeder (Order = 2)
  • DefaultUsersSeeder : ISeeder (Order = 3, залежить від Roles)
  • CategoriesSeeder : ISeeder (Order = 4)

DatabaseInitializer знаходить всі ISeeder через DI (зареєстровані як IEnumerable<ISeeder>), сортує за Order і виконує послідовно. Новий seeder — просто реалізуй інтерфейс і зареєструй у DI.


Підсумок частини 1

У першій половині статті ми охопили:

  • Типи seed data: довідники (reference data), дефолтні налаштування, демо-дані — і відповідні стратегії для кожного типу.
  • HasData(): декларативний seed у Fluent API конфігурації. Вбудовується у міграції, генерує InsertData і DeleteData у Up()/Down(). Відстежує diff між міграціями.
  • Ключові нюанси: int PK з IDENTITY — Id обов'язковий, Shadow Properties — анонімний об'єкт, Owned Types — окремий HasData через OwnsOne(...).HasData(...).
  • Обмеження HasData: не для великих обсягів, не для динамічних/environment-залежних даних, не для тестових даних у production коді.
  • Database Initialization Service: повний контроль — ідемпотентність, середовищ-залежність, хешування, бізнес-логіка.

У другій частині розглянемо: seed через migrationBuilder.Sql(), seeding великих обсягів через CSV/JSON, EnsureDeleted/EnsureCreated для тестів, Bogus для генерації fake-даних і порівняльна матриця всіх підходів.