Ef Core

Fluent API та Data Annotations — Явна конфігурація моделі

Детальний розбір двох підходів до конфігурації EF Core моделі — Data Annotations і Fluent API. Коли що використовувати, повний перелік атрибутів і методів, організація через IEntityTypeConfiguration, ApplyConfigurationsFromAssembly та практичні патерни.

Fluent API та Data Annotations: Явна конфігурація моделі

Дві мови для одної мети

У попередній статті ми з'ясували, що EF Core «знає» багато без жодної конфігурації — через вбудовані конвенції. Але конвенції покривають тільки «нормальний» стан речей. Щойно ваші вимоги відхиляються від стандарту — назва таблиці не збігається з ім'ям класу, стовпець потребує конкретної точності, зв'язок має нестандартний DeleteBehavior, або ж потрібно налаштувати індекс — вам потрібна явна конфігурація.

EF Core надає для цього два механізми:

Data Annotations — C#-атрибути на класах і властивостях. Виглядають знайомо, бо частина з них ([Required], [MaxLength]) використовується також у валідації ASP.NET Core і Entity Framework Classic. Конфігурація знаходиться безпосередньо в моделі.

Fluent API — ланцюжки методів у OnModelCreating. Конфігурація відокремлена від класів моделі. Більш потужний: деякі речі можна зробити тільки через Fluent API.

Ці два підходи не є взаємовиключними — технічно їх можна змішувати. Але у добре побудованому проєкті зазвичай обирають один із них як основний і дотримуються цього рішення. Ця стаття допоможе зрозуміти, що може кожен підхід і яка стратегія підходить вашій команді.


Data Annotations: конфігурація прямо в класі

Data Annotations — це механізм, успадкований від класичного Entity Framework і частково від System.ComponentModel.DataAnnotations. Частина атрибутів знаходиться у просторі імен System.ComponentModel.DataAnnotations, частина — у System.ComponentModel.DataAnnotations.Schema.

Ключова перевага: конфігурація і модель знаходяться поряд. Ви читаєте клас і одразу бачите все про нього — і структуру, і схему БД. Ключовий недолік: доменна модель починає знати про деталі зберігання — що порушує принцип розділення відповідальності.

Повний перелік атрибутів EF Core

Розберемо кожен атрибут з поясненням, коли він потрібен:

Key — первинний ключ

Потрібен, коли властивість не відповідає конвенції (Id або {ClassName}Id):

public class Author
{
    [Key] // необхідний: "Identifier" не розпізнається як PK за конвенцією
    public Guid Identifier { get; set; }

    public string Name { get; set; } = string.Empty;
}

Для складених ключів — [Key] разом з [Column(Order = n)]:

public class OrderItem
{
    [Key, Column(Order = 0)]
    public int OrderId { get; set; }

    [Key, Column(Order = 1)]
    public int ProductId { get; set; }
}
Складений ключ через атрибути — це deprecated підхід з EF Core 5+. Рекомендується завжди використовувати Fluent API для складених ключів: modelBuilder.Entity<OrderItem>().HasKey(e => new { e.OrderId, e.ProductId }).

Required — NOT NULL

Позначає властивість як обов'язкову. З увімкненими Nullable Reference Types здебільшого не потрібен — EF Core сам визначає обов'язковість за nullable-анотацією. Але може знадобитись для явного контролю:

public class Book
{
    [Required] // NOT NULL у БД + валідація в ASP.NET Core
    public string Title { get; set; } = string.Empty;

    // Сюди не потрібен [Required] — string без ? вже означає NOT NULL з NRT
    public string Isbn { get; set; } = string.Empty;
}

Важлива деталь: [Required] на рядковому полі не тільки впливає на схему БД — він також вмикає валідацію в ModelState ASP.NET Core. Якщо ви використовуєте Data Annotations для EF Core і одночасно робите API, це може бути виправданою синергією.

MaxLength і MinLength — обмеження довжини рядка

public class Author
{
    [MaxLength(200)] // VARCHAR(200) у БД + валідація довжини
    public string Name { get; set; } = string.Empty;

    [MaxLength(2000)]
    public string? Biography { get; set; }

    [MinLength(10)] // тільки валідація, НЕ впливає на DDL
    [MaxLength(100)]
    public string Email { get; set; } = string.Empty;
}

Важлива різниця: [MaxLength] впливає і на DDL (розмір стовпця), і на runtime-валідацію. [MinLength] — тільки на валідацію, жодного впливу на схему БД.

StringLength — альтернатива MaxLength

[StringLength(maximumLength: 200, MinimumLength = 10)]
public string Name { get; set; } = string.Empty;
// → VARCHAR(200) у БД; валідація: мін 10, макс 200

Практична різниця між [MaxLength] і [StringLength]: обидва обмежують максимальну довжину в DDL. [StringLength] більш зручний, якщо потрібно задати мінімум і максимум в одному атрибуті. Для EF Core — еквівалентні для DDL.

Column — кастомна назва стовпця і тип

public class Product
{
    [Column("product_name")] // стовпець у БД: "product_name"
    public string Name { get; set; } = string.Empty;

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

    [Column(Order = 2)] // порядок стовпця у таблиці (підтримується не всіма провайдерами)
    public string Category { get; set; } = string.Empty;
}

Table — кастомна назва таблиці і схема

[Table("tbl_products", Schema = "catalog")]
public class Product
{
    // → CREATE TABLE catalog.tbl_products (...)
}

ForeignKey — явне вказення FK

Потрібен, коли EF Core не може автоматично знайти зовнішній ключ за конвенцією:

public class Book
{
    public int AuthorId { get; set; }

    [ForeignKey("AuthorId")] // пов'язуємо навігаційну властивість з FK
    public Author Author { get; set; } = null!;
}

// Або атрибут на FK-полі:
public class Book
{
    [ForeignKey("Author")] // вказуємо на навігаційну властивість
    public int PrimaryAuthorIdentifier { get; set; } // нестандартна назва

    public Author Author { get; set; } = null!;
}

InverseProperty — для зовнішніх навігацій (множинні зв'язки між одними типами)

Без [InverseProperty] EF Core не знає, яка навігаційна властивість з якою «парується», коли між двома типами є кілька зв'язків:

public class Employee
{
    public int Id { get; set; }

    public int? ManagerId { get; set; }

    [InverseProperty("Subordinates")]
    public Employee? Manager { get; set; }

    [InverseProperty("Manager")]
    public ICollection<Employee> Subordinates { get; set; } = new List<Employee>();
}

NotMapped — виключити властивість з маппінгу

Обчислювані або допоміжні властивості, які не потребують збереження:

public class Author
{
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;

    [NotMapped] // ця властивість не буде стовпцем у БД
    public string FullName => $"{FirstName} {LastName}";

    [NotMapped]
    public int Age => DateTime.Now.Year - BornAt.Year;
}

DatabaseGenerated — стратегія генерації значення

public class Invoice
{
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; } // генерується базою при INSERT

    [DatabaseGenerated(DatabaseGeneratedOption.Computed)]
    public DateTime LastModified { get; set; } // генерується базою при INSERT та UPDATE

    [DatabaseGenerated(DatabaseGeneratedOption.None)]
    public Guid ExternalId { get; set; } // застосунок завжди задає значення сам
}

Index — індекс через атрибут (EF Core 5+)

Раніше індекси можна було задати тільки через Fluent API. EF Core 5 ввів атрибут [Index] на рівні класу:

[Index(nameof(Email), IsUnique = true)]
[Index(nameof(LastName), nameof(FirstName))] // складений індекс
public class Customer
{
    public int Id { get; set; }
    public string FirstName { get; set; } = string.Empty;
    public string LastName { get; set; } = string.Empty;
    public string Email { get; set; } = string.Empty;
}

Precision — точність числа (EF Core 6+)

public class Product
{
    [Precision(10, 2)] // 10 цифр, 2 після коми
    public decimal Price { get; set; }
}

Unicode — явне управління Unicode (EF Core 6+)

public class Product
{
    [Unicode(false)] // varchar замість nvarchar (SQL Server)
    public string Sku { get; set; } = string.Empty; // артикул — ASCII-only

    [Unicode(true)] // nvarchar (за замовчуванням для SQL Server)
    public string Name { get; set; } = string.Empty;
}

DeleteBehavior — поведінка при видаленні (EF Core 7+)

public class Order
{
    [DeleteBehavior(DeleteBehavior.Restrict)]
    public Customer Customer { get; set; } = null!;
}

Fluent API: повна міць конфігурації

Fluent API — це підхід, де вся конфігурація зосереджена у методі OnModelCreating DbContext. Назва «Fluent» походить від стилю ланцюжка методів: кожен метод повертає builder-об'єкт, що дозволяє продовжити конфігурацію.

Fluent API завжди має вищий пріоритет ніж Data Annotations і конвенції. Він також надає доступ до налаштувань, яких взагалі немає в Data Annotations — наприклад, складені ключі, TPH-дискримінатор, Owned Types, Global Query Filters, Table Splitting.

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

Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Конфігурація конкретної сутності
    modelBuilder.Entity<Author>(entity =>
    {
        // --- Таблиця ---
        entity.ToTable("Authors", "catalog");

        // --- Первинний ключ ---
        entity.HasKey(a => a.Id);

        // --- Властивості ---
        entity.Property(a => a.Name)
              .IsRequired()
              .HasMaxLength(200)
              .HasComment("Повне ім'я автора");

        entity.Property(a => a.Biography)
              .HasMaxLength(5000)
              .IsRequired(false);

        entity.Property(a => a.BornAt)
              .HasColumnName("born_at")
              .HasColumnType("date"); // тільки дата, без часу

        // --- Зв'язки ---
        entity.HasMany(a => a.Books)
              .WithOne(b => b.Author)
              .HasForeignKey(b => b.AuthorId)
              .OnDelete(DeleteBehavior.Cascade);

        // --- Індекси ---
        entity.HasIndex(a => a.Name)
              .HasDatabaseName("IX_Authors_Name");

        entity.HasIndex(a => a.Email)
              .IsUnique()
              .HasDatabaseName("UX_Authors_Email");
    });
}

Методи конфігурації Entity

ToTable / ToView — іменування:

entity.ToTable("Authors");                   // проста таблиця
entity.ToTable("Authors", "catalog");        // схема catalog
entity.ToTable("Authors", t => t.HasComment("Таблиця авторів")); // з коментарем
entity.ToView("AuthorSummaries");            // маппінг на view (read-only)
entity.ToSqlQuery("SELECT * FROM Authors WHERE IsActive = 1"); // кастомний SQL

HasKey — первинний ключ:

entity.HasKey(a => a.Id);                              // простий
entity.HasKey(e => new { e.OrderId, e.ProductId });    // складений
entity.HasKey(a => a.Id).HasName("PK_Authors"); // кастомна назва constraint

HasAlternateKey — альтернативний ключ (унікальний, але не PK):

// Альтернативний ключ: унікальний і може бути target для FK
entity.HasAlternateKey(a => a.Email);
entity.HasAlternateKey(a => new { a.FirstName, a.LastName, a.BornAt });

HasQueryFilter — глобальний фільтр (розглядається в статті 15):

// Всі запити до Author автоматично включатимуть цей фільтр
entity.HasQueryFilter(a => !a.IsDeleted && a.TenantId == _tenantId);

Методи конфігурації Property

Метод Property(e => e.PropertyName) повертає PropertyBuilder, що має великий набір методів:

entity.Property(a => a.Name)
    // --- Обов'язковість ---
    .IsRequired()           // NOT NULL
    .IsRequired(false)      // NULL

    // --- Розмір і точність ---
    .HasMaxLength(200)      // VARCHAR(200)
    .HasPrecision(18, 4)    // DECIMAL(18,4) — для decimal

    // --- Назва стовпця ---
    .HasColumnName("author_name")
    .HasColumnType("varchar(200)") // явний SQL-тип

    // --- Значення за замовчуванням ---
    .HasDefaultValue("Unknown")
    .HasDefaultValueSql("CURRENT_TIMESTAMP")   // SQL-вираз
    .HasComputedColumnSql("FirstName + ' ' + LastName") // computed column
    .HasComputedColumnSql("...", stored: true)  // stored computed (EF Core 5+)

    // --- Генерація значення ---
    .ValueGeneratedNever()           // ніколи не генерується БД
    .ValueGeneratedOnAdd()           // генерується при INSERT (IDENTITY)
    .ValueGeneratedOnUpdate()        // генерується при UPDATE (rowversion)
    .ValueGeneratedOnAddOrUpdate()   // при обох

    // --- Паралельний доступ ---
    .IsConcurrencyToken()     // token для optimistic concurrency

    // --- Unicode ---
    .IsUnicode()              // nvarchar (SQL Server)
    .IsUnicode(false)         // varchar

    // --- Коментар ---
    .HasComment("Повне ім'я автора у форматі 'Ім'я Прізвище'")

    // --- Конвертер значень ---
    .HasConversion<string>()  // enum → string
    .HasConversion(
        v => v.ToString(),    // C# → БД
        v => Enum.Parse<Status>(v)); // БД → C#

Методи конфігурації зв'язків

Fluent API надає найповніший контроль над зв'язками:

// One-to-Many
modelBuilder.Entity<Book>()
    .HasOne(b => b.Author)          // у Book є один Author
    .WithMany(a => a.Books)         // у Author є багато Books
    .HasForeignKey(b => b.AuthorId) // FK-поле
    .IsRequired()                   // FK NOT NULL
    .OnDelete(DeleteBehavior.Cascade)
    .HasConstraintName("FK_Books_Authors");

// One-to-One
modelBuilder.Entity<Author>()
    .HasOne(a => a.ContactInfo)
    .WithOne(c => c.Author)
    .HasForeignKey<ContactInfo>(c => c.AuthorId)
    .OnDelete(DeleteBehavior.Cascade);

// Many-to-Many (implicit join entity, EF Core 5+)
modelBuilder.Entity<Book>()
    .HasMany(b => b.Tags)
    .WithMany(t => t.Books)
    .UsingEntity(j => j.ToTable("BookTags")); // назва join-таблиці

// Many-to-Many (explicit join entity з payload)
modelBuilder.Entity<Book>()
    .HasMany(b => b.Authors)
    .WithMany(a => a.Books)
    .UsingEntity<BookAuthor>(
        ba => ba.HasOne(x => x.Author).WithMany().HasForeignKey(x => x.AuthorId),
        ba => ba.HasOne(x => x.Book).WithMany().HasForeignKey(x => x.BookId),
        ba =>
        {
            ba.HasKey(x => new { x.BookId, x.AuthorId });
            ba.Property(x => x.Role).HasMaxLength(100);
        });

Тільки Fluent API: можливості без аналогів в Annotations

Це ключовий аргумент на користь Fluent API — ряд можливостей просто недоступні через атрибути:

// 1. Owned Types (Value Objects)
modelBuilder.Entity<Customer>()
    .OwnsOne(c => c.Address, addr =>
    {
        addr.Property(a => a.Street).HasMaxLength(200);
        addr.Property(a => a.City).HasMaxLength(100);
    });

// 2. Table Splitting (кілька сутностей → одна таблиця)
modelBuilder.Entity<Order>().ToTable("Orders");
modelBuilder.Entity<OrderDetails>().ToTable("Orders"); // та сама таблиця!

// 3. TPH Discriminator (успадкування)
modelBuilder.Entity<Notification>()
    .HasDiscriminator<string>("Type")
    .HasValue<EmailNotification>("email")
    .HasValue<SmsNotification>("sms");

// 4. Global Query Filter
modelBuilder.Entity<Post>()
    .HasQueryFilter(p => !p.IsDeleted);

// 5. Sequence
modelBuilder.HasSequence<int>("OrderNumbers")
    .StartsAt(1000)
    .IncrementsBy(5);

modelBuilder.Entity<Order>()
    .Property(o => o.OrderNumber)
    .HasDefaultValueSql("NEXT VALUE FOR OrderNumbers");

// 6. Keyless Entity (для Views, raw SQL results)
modelBuilder.Entity<OrderSummary>()
    .HasNoKey()
    .ToView("vw_OrderSummaries");

// 7. Shadow Properties
modelBuilder.Entity<Product>()
    .Property<DateTime>("LastUpdated");

// 8. Backing Fields
modelBuilder.Entity<Author>()
    .Property(a => a.Name)
    .HasField("_name");

Організація: IEntityTypeConfiguration<T>

Коли OnModelCreating зростає разом із кількістю сутностей — він перетворюється на сотні рядків нечитабельного коду. Правильне рішення — окремі класи конфігурації для кожної сутності через інтерфейс IEntityTypeConfiguration<T>:

Configurations/AuthorConfiguration.cs
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;

public class AuthorConfiguration : IEntityTypeConfiguration<Author>
{
    public void Configure(EntityTypeBuilder<Author> builder)
    {
        builder.ToTable("Authors", "catalog");

        builder.HasKey(a => a.Id);

        builder.Property(a => a.Name)
               .IsRequired()
               .HasMaxLength(200)
               .HasComment("Повне ім'я автора");

        builder.Property(a => a.Biography)
               .HasMaxLength(5000);

        builder.Property(a => a.BornAt)
               .HasColumnType("date");

        builder.HasIndex(a => a.Email)
               .IsUnique()
               .HasDatabaseName("UX_Authors_Email");

        builder.HasMany(a => a.Books)
               .WithOne(b => b.Author)
               .HasForeignKey(b => b.AuthorId)
               .OnDelete(DeleteBehavior.Restrict);
    }
}
Configurations/BookConfiguration.cs
public class BookConfiguration : IEntityTypeConfiguration<Book>
{
    public void Configure(EntityTypeBuilder<Book> builder)
    {
        builder.ToTable("Books", "catalog");

        builder.HasKey(b => b.Id);

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

        builder.Property(b => b.Isbn)
               .IsRequired()
               .HasMaxLength(17)
               .IsUnicode(false)
               .HasComment("ISBN-13 формат: 978-x-xx-xxxxx-x");

        builder.Property(b => b.Price)
               .HasPrecision(10, 2);

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

Тепер OnModelCreating стає мінімалістичним і масштабованим:

Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Варіант 1: Ручна реєстрація кожного класу конфігурації
    modelBuilder.ApplyConfiguration(new AuthorConfiguration());
    modelBuilder.ApplyConfiguration(new BookConfiguration());
    modelBuilder.ApplyConfiguration(new OrderConfiguration());
    // ...
}

ApplyConfigurationsFromAssembly: автоматичне сканування

Якщо у вас десятки конфігурацій — реєструвати кожну вручну стомлює. ApplyConfigurationsFromAssembly автоматично знаходить і реєструє всі класи, що реалізують IEntityTypeConfiguration<T> у вказаній асемблі:

Data/AppDbContext.cs
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    // Сканує поточну асемблю і реєструє всі IEntityTypeConfiguration<T>
    modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);

    // Або з фільтром за namespace:
    modelBuilder.ApplyConfigurationsFromAssembly(
        assembly: typeof(AppDbContext).Assembly,
        predicate: type => type.Namespace?.StartsWith("MyApp.Data.Configurations") == true);
}

Це рекомендований підхід для всіх проєктів крім найменших. Нова конфігурація — новий файл у папці Configurations/ — і вона автоматично підхоплюється без жодних змін у DbContext.

Структура папок при Fluent API

Data/
├── AppDbContext.cs
├── Configurations/
│   ├── AuthorConfiguration.cs
│   ├── BookConfiguration.cs
│   ├── OrderConfiguration.cs
│   ├── OrderItemConfiguration.cs
│   ├── CustomerConfiguration.cs
│   └── ProductConfiguration.cs

Кожен файл відповідає за одну сутність. Зміни у конфігурації однієї сутності не зачіпають інші файли.


Порівняння: Data Annotations vs Fluent API

Розберемо одну і ту саму ситуацію — конфігурацію класу Product — через обидва підходи, щоб наочно відчути різницю.

Models/Product.cs
[Table("products", Schema = "catalog")]
[Index(nameof(Sku), IsUnique = true, Name = "UX_Products_Sku")]
[Index(nameof(Name), nameof(Category), Name = "IX_Products_NameCategory")]
public class Product
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }

    [Required]
    [MaxLength(500)]
    public string Name { get; set; } = string.Empty;

    [Required]
    [MaxLength(50)]
    [Unicode(false)]
    [Column("sku_code")]
    public string Sku { get; set; } = string.Empty;

    [Precision(10, 2)]
    [Column(TypeName = "decimal(10,2)")]
    public decimal Price { get; set; }

    [MaxLength(100)]
    public string? Category { get; set; }

    [Required]
    public bool IsActive { get; set; } = true;

    [NotMapped]
    public string DisplayLabel => $"[{Sku}] {Name}";
}

Плюси цього підходу: всі деталі одразу видно поруч із властивостями. Мінуси: клас стає перевантаженим атрибутами, доменна модель знає про деталі схеми БД, і частина критично важливих речей (Fluent API-only) все одно піде в OnModelCreating.


Матриця: що може кожен з підходів

МожливістьData AnnotationsFluent API
Назва таблиці[Table]ToTable()
Схема[Table(Schema)]ToTable("t", "schema")
Первинний ключ[Key]HasKey()
Складений PK⚠️ deprecatedHasKey(e => new {...})
Альтернативний ключHasAlternateKey()
NOT NULL[Required]IsRequired()
MaxLength[MaxLength]HasMaxLength()
Точність decimal[Precision]HasPrecision()
Назва стовпця[Column]HasColumnName()
Тип стовпця[Column(TypeName)]HasColumnType()
Unicode[Unicode]IsUnicode()
Ignore[NotMapped]Ignore()
FK explicit[ForeignKey]HasForeignKey()
DeleteBehavior[DeleteBehavior] EF7+OnDelete()
Indeks[Index] EF5+HasIndex()
Unique Index[Index(IsUnique)]IsUnique()
КоментарHasComment()
Global Query FilterHasQueryFilter()
Owned TypesOwnsOne(), OwnsMany()
Table SplittingToTable() на багатьох сутностях
TPH Discriminator❌ (частково)HasDiscriminator()
Shadow PropertiesProperty<T>("Name")
Backing FieldsHasField()
Value ConvertersHasConversion()
SequenceHasSequence()
Keyless EntityHasNoKey()
DB Function mappingHasDbFunction()

Як видно з таблиці — Fluent API є надмножиною Data Annotations. Все, що можна зробити через атрибути, можна зробити і через Fluent API. Зворотнє — хибне.


Що обрати: практичні рекомендації

Питання «Data Annotations чи Fluent API» часто породжує дискусії в командах. Ось структурований погляд на цей вибір:


Реальний приклад: конфігурація складної доменної моделі

Розглянемо конфігурацію системи замовлень із кількома сутностями через рекомендований підхід:

Зверніть увагу: OnModelCreating містить рівно один рядок. Вся логіка конфігурації — у відповідальних файлах. Кожен файл конфігурації відповідає рівно за одну сутність і є самодостатнім.


Типові помилки при конфігурації


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

Рівень 1: Перетворення підходів

Завдання 1.1 — Візьміть наступний клас із Data Annotations і перепишіть його повністю через Fluent API у окремому IEntityTypeConfiguration<T>. Видаліть усі атрибути з класу:

[Table("blog_posts", Schema = "content")]
[Index(nameof(Slug), IsUnique = true)]
public class BlogPost
{
    [Key]
    public int Id { get; set; }

    [Required, MaxLength(300)]
    public string Title { get; set; } = string.Empty;

    [Required, MaxLength(300), Unicode(false)]
    public string Slug { get; set; } = string.Empty;

    [MaxLength(10000)]
    public string? Content { get; set; }

    [Precision(5, 2)]
    public decimal? Rating { get; set; }

    [NotMapped]
    public bool IsPublished => PublishedAt.HasValue;
}

Завдання 1.2 — Поверніть зворотнє перетворення: візьміть клас OrderItem з OrderItemConfiguration.cs з прикладу вище і перепишіть всю конфігурацію через Data Annotations, розмістивши атрибути просто в класі. Що ви не зможете перенести через атрибути? Список цих речей — ваша відповідь.

Завдання 1.3 — Налаштуйте ApplyConfigurationsFromAssembly у своєму проєкті. Через логування перевірте, що при першому зверненні до DbContext всі ваші конфігурації справді застосовуються: виведіть context.Model.GetEntityTypes().Select(e => e.Name) і порівняйте зі списком конфігураційних файлів.

Рівень 2: Складна конфігурація

Завдання 2.1 — Реалізуйте складну Many-to-Many зв'язок між Student і Course через explicit join entity Enrollment з додатковими полями Grade (decimal?), EnrolledAt (DateTime). Налаштуйте через Fluent API з складеним PK {StudentId, CourseId}, щоб не можна було двічі записати студента на той самий курс.

Завдання 2.2 — Реалізуйте клас Product, де є обчислюваний стовпець TotalValue = Quantity * UnitPrice (stored computed column через HasComputedColumnSql). Провайдер: PostgreSQL (Quantity * "UnitPrice") або SQL Server ([Quantity] * [UnitPrice]). Переконайтесь у міграції, що стовпець є GENERATED ALWAYS AS.

Завдання 2.3 — Написіть конфігурацію, де Customer.Email є унікальним, але тільки серед не-видалених клієнтів (filtered unique index). Додайте Global Query Filter !IsDeleted. Перевірте: чи можна після soft delete зберегти нового Customer з тим самим Email?

Рівень 3: Архітектурний підхід

Завдання 3.1 — Automatic конфігурація через базовий клас: Реалізуйте абстрактний BaseEntityConfiguration<T> де T : BaseEntity, що автоматично: (а) налаштовує аудит-поля CreatedAt з HasDefaultValueSql, UpdatedAt через ValueGeneratedOnAddOrUpdate, (б) додає Shadow Property "RowVersion" як IsConcurrencyToken, (в) встановлює HasQueryFilter для soft delete. Всі конкретні конфігурації успадковують від нього.

Завдання 3.2 — Value Object конфігурація: Вам є 5 різних сутностей, кожна з яких має Address (Owned Type із полями Street, City, Country, PostalCode). Напишіть extension method HasAddress(this EntityTypeBuilder<T> builder, string prefix = ""), що додає OwnsOne<Address> з усіма необхідними налаштуваннями. Зробіть так, щоб у різних сутностях стовпці мали різні префікси: Shipping_Street, Billing_Street.


Підсумок

Ключові думки цієї статті:
  • EF Core надає два механізми явної конфігурації: Data Annotations (атрибути в класі) і Fluent API (методи у OnModelCreating або в IEntityTypeConfiguration<T>)
  • Ієрархія пріоритетів: Конвенції < Data Annotations < Fluent API
  • Fluent API є надмножиною: все, що робить Annotations, робить і Fluent API — але не навпаки (Owned Types, Global Filters, Shadow Properties, Value Converters — тільки Fluent API)
  • IEntityTypeConfiguration<T> — правильний спосіб організації Fluent API: окремий файл на кожну сутність
  • ApplyConfigurationsFromAssembly — автоматичне сканування і реєстрація всіх конфігурацій без перерахування вручну
  • Рекомендований підхід: Fluent API з IEntityTypeConfiguration<T> для всіх проєктів крім найменших
  • Уникайте змішування: обирайте один підхід і дотримуйтесь його в проєкті
  • Для маленьких проєктів і синергії з ASP.NET Core валідацією — Data Annotations є виправданим вибором

Наступна стаття — Зв'язки: One-to-One, One-to-Many — детальний розбір основних типів зв'язків: principal/dependent ролі, конфігурація Required і Optional навігацій, самореферентні зв'язки, та SQL-схеми, що вони генерують.