Ef Core

10.3. Моделювання сутностей (Entity Configuration)

10.3. Моделювання сутностей (Entity Configuration)

Вступ: Як EF Core «розуміє» ваші класи

У попередніх статтях ми створювали прості класи — Book, Author — і EF Core автоматично маппив їх на таблиці. Але як він визначав, що Id — це Primary Key? Що stringNVARCHAR(MAX)? І що AuthorId — це Foreign Key?

EF Core використовує систему конфігурації з трьох рівнів:

  1. Конвенції (Conventions) — автоматичні правила, що працюють «за замовчуванням»
  2. Data Annotations (атрибути) — конфігурація через [Attributes] на класах та властивостях
  3. Fluent API — конфігурація в OnModelCreating() через ланцюжки методів

Кожен наступний рівень перевизначає попередній: Fluent API > Data Annotations > Conventions.

Передумови: 10.2. DbContext та DbSet.

Конвенції (Convention over Configuration)

EF Core аналізує ваші C#-класи та застосовує набір конвенцій для побудови моделі бази даних. Це «магія», яка працює без жодної додаткової конфігурації.

Основні конвенції

C#-конструкціяКонвенція EF CoreРезультат у БД
Клас Book→ ТаблицяBooks (множина)
int Id або int BookId→ Primary Key + IDENTITYId INT IDENTITY(1,1) PK
Guid Id→ Primary Key + автогенераціяId UNIQUEIDENTIFIER DEFAULT NEWSEQUENTIALID()
string Property→ NVARCHAR(MAX) NOT NULLNRT: nullable string? → NVARCHAR(MAX) NULL
int Property→ INT NOT NULLINT NOT NULL
int? Property→ INT NULLINT NULL
bool Property→ BIT NOT NULLBIT NOT NULL
decimal Property→ DECIMAL(18,2)DECIMAL(18,2) NOT NULL
DateTime Property→ DATETIME2(7)DATETIME2(7) NOT NULL
enum Property→ INTЗберігає числове значення
int AuthorId + Author Author→ Foreign KeyAuthorId INT FK → Authors(Id)

Як працює виявлення Primary Key

EF Core шукає Primary Key у такому порядку:

  1. Властивість з ім'ям Id
  2. Властивість з ім'ям {TypeName}Id (наприклад, BookId для класу Book)
  3. Тип повинен бути числовим (int, long, Guid) для автогенерації
// Конвенція 1: Властивість "Id"
public class Book
{
    public int Id { get; set; } // ← PK, IDENTITY(1,1)
}

// Конвенція 2: Властивість "{Type}Id"
public class Author
{
    public int AuthorId { get; set; } // ← PK, IDENTITY(1,1)
}

// Конвенція 3: Guid → автоматичний GUID
public class Order
{
    public Guid Id { get; set; } // ← PK, NEWSEQUENTIALID()
}

Nullable Reference Types (NRT) та конвенції

З увімкненими NRT (за замовчуванням у .NET 8+), EF Core розрізняє nullable та non-nullable:

public class Book
{
    public string Title { get; set; } = "";    // NOT NULL (non-nullable string)
    public string? Subtitle { get; set; }       // NULL (nullable string?)

    public int Year { get; set; }               // NOT NULL (value type)
    public int? PageCount { get; set; }          // NULL (nullable value type)
}
Завжди тримайте NRT увімкненими. Це дозволяє EF Core автоматично визначати nullability стовпців без додаткової конфігурації.

Data Annotations (Атрибути)

Коли конвенцій недостатньо, використовуйте атрибути з System.ComponentModel.DataAnnotations та System.ComponentModel.DataAnnotations.Schema:

Основні атрибути

using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;

[Table("Books")]      // Явне ім'я таблиці (якщо відрізняється від конвенції)
public class Book
{
    [Key]                          // Primary Key (якщо назва не Id/BookId)
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    [Required]                      // NOT NULL (для nullable типів)
    [MaxLength(200)]                // NVARCHAR(200) замість NVARCHAR(MAX)
    public string Title { get; set; } = "";

    [Required]
    [MaxLength(200)]
    public string Author { get; set; } = "";

    [Range(1450, 2100)]             // Валідація (не впливає на SQL)
    public int Year { get; set; }

    [Required]
    [MaxLength(20)]
    [Column("ISBN")]                 // Явне ім'я стовпця
    public string Isbn { get; set; } = "";

    [Column(TypeName = "decimal(10,2)")]  // Точний SQL-тип
    public decimal Price { get; set; }

    public bool IsAvailable { get; set; } = true;

    [NotMapped]                     // НЕ маппити на стовпець
    public string DisplayTitle => $"{Title} ({Year})";

    [Timestamp]                     // Для оптимістичної конкурентності
    public byte[] RowVersion { get; set; } = null!;
}

Розбір атрибутів:

  • Рядок 4: [Table("Books")] — явно задає ім'я таблиці. Корисно, якщо конвенція дає небажаний результат.
  • Рядки 12-13: [Required] + [MaxLength(200)]NVARCHAR(200) NOT NULL. Без MaxLength було б NVARCHAR(MAX).
  • Рядок 20: [Range] — валідація на рівні додатку, не створює SQL constraint.
  • Рядок 25: [Column("ISBN")] — стовпець у БД називається ISBN, а не Isbn.
  • Рядок 28: [Column(TypeName = "decimal(10,2)")] — точний SQL-тип замість дефолтного DECIMAL(18,2).
  • Рядок 32: [NotMapped] — обчислювана властивість, яка не має стовпця в БД.
  • Рядок 34: [Timestamp] — для оптимістичної конкурентності (розглянемо в статті про CRUD).

Складові ключі та індекси

// Складовий Primary Key (Data Annotations не підтримують — лише Fluent API)
// Для індексів використовуйте атрибут [Index]:

[Index(nameof(Isbn), IsUnique = true)]          // UNIQUE INDEX
[Index(nameof(Author), nameof(Year))]           // Складений індекс
public class Book
{
    public int Id { get; set; }
    public string Author { get; set; } = "";
    public int Year { get; set; }
    public string Isbn { get; set; } = "";
}

Fluent API: Повний контроль

Fluent API — найпотужніший спосіб конфігурації. Все, що можна зробити через атрибути, можна зробити через Fluent API, але не навпаки. Деякі функції (складові ключі, TPH discriminator, query filters) доступні тільки через Fluent API.

Конфігурація в OnModelCreating

public class LibraryContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>(entity =>
        {
            // Таблиця
            entity.ToTable("Books");

            // Primary Key
            entity.HasKey(b => b.Id);

            // Властивості
            entity.Property(b => b.Title)
                .IsRequired()
                .HasMaxLength(200);

            entity.Property(b => b.Author)
                .IsRequired()
                .HasMaxLength(200);

            entity.Property(b => b.Isbn)
                .IsRequired()
                .HasMaxLength(20)
                .HasColumnName("ISBN");

            entity.Property(b => b.Price)
                .HasColumnType("decimal(10,2)")
                .HasDefaultValue(0m);

            entity.Property(b => b.IsAvailable)
                .HasDefaultValue(true);

            // Індекси
            entity.HasIndex(b => b.Isbn)
                .IsUnique();

            entity.HasIndex(b => new { b.Author, b.Year });

            // Ігнорувати обчислювану властивість
            entity.Ignore(b => b.DisplayTitle);
        });
    }
}

Розбір коду:

  • Рядок 7: modelBuilder.Entity<Book>(entity => { ... }) — конфігурація для типу Book.
  • Рядки 16-18: Property.IsRequired().HasMaxLength(200) — ланцюжок викликів (fluent interface). Кожен метод повертає builder для наступного виклику.
  • Рядки 30-31: HasColumnType + HasDefaultValue — точний SQL-тип та дефолтне значення.
  • Рядки 38-39: HasIndex.IsUnique() — створює UNIQUE INDEX.
  • Рядок 41: HasIndex(b => new { b.Author, b.Year })складений індекс на два стовпці.

IEntityTypeConfiguration — виніс конфігурації

Коли OnModelCreating стає занадто великим, виносіть конфігурацію кожної сутності в окремий клас:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> builder)
    {
        builder.ToTable("Books");
        builder.HasKey(b => b.Id);

        builder.Property(b => b.Title)
            .IsRequired()
            .HasMaxLength(200);

        builder.Property(b => b.Author)
            .IsRequired()
            .HasMaxLength(200);

        builder.Property(b => b.Isbn)
            .IsRequired()
            .HasMaxLength(20)
            .HasColumnName("ISBN");

        builder.HasIndex(b => b.Isbn)
            .IsUnique();
    }
}

public class AuthorConfiguration : IEntityTypeConfiguration<Author>
{
    public void Configure(EntityTypeBuilder<Author> builder)
    {
        builder.ToTable("Authors");
        builder.HasKey(a => a.Id);

        builder.Property(a => a.Name)
            .IsRequired()
            .HasMaxLength(200);

        builder.Property(a => a.Country)
            .HasMaxLength(100);
    }
}

Реєстрація в OnModelCreating:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Варіант 1: Кожну окремо
    modelBuilder.ApplyConfiguration(new BookConfiguration());
    modelBuilder.ApplyConfiguration(new AuthorConfiguration());

    // Варіант 2: Усі з поточної збірки (рекомендовано)
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(LibraryContext).Assembly);
}
ApplyConfigurationsFromAssembly — найзручніший підхід. EF Core автоматично знайде всі класи, що реалізують IEntityTypeConfiguration<T>, та застосує їх. Додаєте нову сутність — додаєте лише конфігураційний клас, і він підхоплюється автоматично.

Value Conversions (Перетворення значень)

Value Conversion дозволяє перетворювати значення при збереженні та читанні з бази. Найтиповіший приклад — зберігання enum як рядка:

Enum → String

public enum BookStatus
{
    Available,
    Borrowed,
    Reserved,
    Lost
}

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public BookStatus Status { get; set; } // За замовчуванням зберігає як INT
}

Без конфігурації BookStatus.Available зберігається як 0 (число). З конфігурацією:

builder.Property(b => b.Status)
    .HasConversion<string>()     // Зберігає як 'Available', 'Borrowed' тощо
    .HasMaxLength(20);

Тепер у базі буде NVARCHAR(20) зі значеннями 'Available', 'Borrowed' замість 0, 1.

Custom Value Conversion

// DateTime → завжди зберігати в UTC
builder.Property(b => b.CreatedAt)
    .HasConversion(
        v => v.ToUniversalTime(),           // C# → SQL
        v => DateTime.SpecifyKind(v, DateTimeKind.Utc)  // SQL → C#
    );

// List<string> → JSON (зберігання колекції в одному стовпці)
builder.Property(b => b.Tags)
    .HasConversion(
        v => JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
        v => JsonSerializer.Deserialize<List<string>>(v, (JsonSerializerOptions?)null) ?? new()
    );

Owned Types (Value Objects)

Owned Types дозволяють маппити Value Objects на стовпці тієї ж таблиці (або окремої):

// Value Object — не має власного Id
public class Address
{
    public string Street { get; set; } = "";
    public string City { get; set; } = "";
    public string PostalCode { get; set; } = "";
    public string Country { get; set; } = "";
}

public class Publisher
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public Address MainAddress { get; set; } = new();  // Owned Type
}

Конфігурація:

builder.OwnsOne(p => p.MainAddress, address =>
{
    address.Property(a => a.Street).HasMaxLength(200).HasColumnName("Address_Street");
    address.Property(a => a.City).HasMaxLength(100).HasColumnName("Address_City");
    address.Property(a => a.PostalCode).HasMaxLength(10).HasColumnName("Address_PostalCode");
    address.Property(a => a.Country).HasMaxLength(100).HasColumnName("Address_Country");
});

Результат у БД — одна таблиця Publishers:

| Id | Name     | Address_Street | Address_City | Address_PostalCode | Address_Country |
|----|----------|----------------|--------------|--------------------|-----------------|
| 1  | O'Reilly | 1005 Gravenstein | Sebastopol | 95472              | USA             |

Shadow Properties

Shadow Properties — властивості, які існують тільки в моделі EF Core (у C#-класі їх немає):

// У C#-класі немає LastUpdated
public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
}

// Але EF Core знає про нього
builder.Property<DateTime>("LastUpdated");

Читання та запис через Change Tracker:

// Запис
context.Entry(book).Property("LastUpdated").CurrentValue = DateTime.UtcNow;

// Читання
var lastUpdated = context.Entry(book).Property<DateTime>("LastUpdated").CurrentValue;

// Використання в LINQ
var recent = context.Books
    .Where(b => EF.Property<DateTime>(b, "LastUpdated") > DateTime.UtcNow.AddDays(-7))
    .ToList();

Автоматичне оновлення через SaveChanges

Практичний приклад — автоматичний LastUpdated при кожному збереженні:

public class LibraryContext : DbContext
{
    public override int SaveChanges()
    {
        foreach (var entry in ChangeTracker.Entries<Book>())
        {
            if (entry.State == EntityState.Added || entry.State == EntityState.Modified)
            {
                entry.Property("LastUpdated").CurrentValue = DateTime.UtcNow;
            }
        }
        return base.SaveChanges();
    }
}

Порівняння підходів

МожливістьConventionsData AnnotationsFluent API
Primary Key✅ (за ім'ям)[Key]HasKey()
Складовий PK
Required✅ (NRT)[Required]IsRequired()
MaxLength[MaxLength]HasMaxLength()
Ім'я стовпця✅ (за властивістю)[Column]HasColumnName()
Ім'я таблиці✅ (множина класу)[Table]ToTable()
Індекси[Index]HasIndex()
Value ConversionHasConversion()
Owned TypesOwnsOne()
Shadow PropertiesProperty<T>()
Query FiltersHasQueryFilter()
Рекомендація: Використовуйте Fluent API як основний спосіб конфігурації. Data Annotations — для простих випадків (Required, MaxLength). Конвенції — для стандартних сценаріїв без явної конфігурації.

Практичні завдання

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

Завдання 1.1: Конфігурація через атрибути

Створіть клас Product з атрибутами:

  1. Id — Primary Key.
  2. Name — обов'язкове, макс. 150 символів.
  3. Description — необов'язкове, макс. 2000 символів.
  4. Pricedecimal(10,2), дефолт 0.
  5. Sku — обов'язкове, унікальний індекс.
  6. DisplayInfo[NotMapped], повертає $"{Name} - {Price:C}".

Завдання 1.2: Конфігурація через Fluent API

Перенесіть конфігурацію Product у ProductConfiguration : IEntityTypeConfiguration<Product>. Видаліть усі атрибути з класу.

Рівень 2: Складніше

Завдання 2.1: Value Conversion

  1. Створіть enum ProductCategory { Electronics, Books, Clothing, Food }.
  2. Збережіть як рядок через HasConversion<string>().
  3. Додайте List<string> Tags і збережіть як JSON через Value Conversion.

Завдання 2.2: Owned Type

  1. Створіть Money (Amount, Currency) як Value Object.
  2. Зробіть Product.Price типу Money.
  3. Налаштуйте OwnsOne з кастомними іменами стовпців.

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

Завдання 3.1: Shadow Properties

  1. Додайте CreatedAt та UpdatedAt як Shadow Properties.
  2. Автоматично оновлюйте їх у перевизначеному SaveChanges().
  3. Виведіть CreatedAt через EF.Property<DateTime>() у LINQ.

Завдання 3.2: Повна модель

Створіть модель інтернет-магазину з окремими конфігураціями:

  1. Product (Name, Price, Category, Sku).
  2. Customer (Name, Email, Address як Owned Type).
  3. Order (OrderDate, Status як enum→string, TotalAmount).
  4. Зареєструйте через ApplyConfigurationsFromAssembly.

Резюме

Conventions

Автоматичні правила: Id → PK, string → NVARCHAR(MAX), AuthorId → FK. Працюють «із коробки».

Data Annotations

Атрибути на класах: Required, MaxLength, Table, Index. Прості та видимі.

Fluent API

Найпотужніший: HasConversion, OwnsOne, Shadow Properties, Query Filters. Рекомендований як основний підхід.

IEntityTypeConfiguration

Окремі класи конфігурації для кожної сутності. ApplyConfigurationsFromAssembly для автореєстрації.
Copyright © 2026