У попередній статті ми з'ясували, що EF Core «знає» багато без жодної конфігурації — через вбудовані конвенції. Але конвенції покривають тільки «нормальний» стан речей. Щойно ваші вимоги відхиляються від стандарту — назва таблиці не збігається з ім'ям класу, стовпець потребує конкретної точності, зв'язок має нестандартний DeleteBehavior, або ж потрібно налаштувати індекс — вам потрібна явна конфігурація.
EF Core надає для цього два механізми:
Data Annotations — C#-атрибути на класах і властивостях. Виглядають знайомо, бо частина з них ([Required], [MaxLength]) використовується також у валідації ASP.NET Core і Entity Framework Classic. Конфігурація знаходиться безпосередньо в моделі.
Fluent API — ланцюжки методів у OnModelCreating. Конфігурація відокремлена від класів моделі. Більш потужний: деякі речі можна зробити тільки через Fluent API.
Ці два підходи не є взаємовиключними — технічно їх можна змішувати. Але у добре побудованому проєкті зазвичай обирають один із них як основний і дотримуються цього рішення. Ця стаття допоможе зрозуміти, що може кожен підхід і яка стратегія підходить вашій команді.
Data Annotations — це механізм, успадкований від класичного Entity Framework і частково від System.ComponentModel.DataAnnotations. Частина атрибутів знаходиться у просторі імен System.ComponentModel.DataAnnotations, частина — у System.ComponentModel.DataAnnotations.Schema.
Ключова перевага: конфігурація і модель знаходяться поряд. Ви читаєте клас і одразу бачите все про нього — і структуру, і схему БД. Ключовий недолік: доменна модель починає знати про деталі зберігання — що порушує принцип розділення відповідальності.
Розберемо кожен атрибут з поясненням, коли він потрібен:
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; }
}
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 — це підхід, де вся конфігурація зосереджена у методі OnModelCreating DbContext. Назва «Fluent» походить від стилю ланцюжка методів: кожен метод повертає builder-об'єкт, що дозволяє продовжити конфігурацію.
Fluent API завжди має вищий пріоритет ніж Data Annotations і конвенції. Він також надає доступ до налаштувань, яких взагалі немає в Data Annotations — наприклад, складені ключі, TPH-дискримінатор, Owned Types, Global Query Filters, Table Splitting.
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");
});
}
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(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 — ряд можливостей просто недоступні через атрибути:
// 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");
Коли OnModelCreating зростає разом із кількістю сутностей — він перетворюється на сотні рядків нечитабельного коду. Правильне рішення — окремі класи конфігурації для кожної сутності через інтерфейс IEntityTypeConfiguration<T>:
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);
}
}
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 стає мінімалістичним і масштабованим:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Варіант 1: Ручна реєстрація кожного класу конфігурації
modelBuilder.ApplyConfiguration(new AuthorConfiguration());
modelBuilder.ApplyConfiguration(new BookConfiguration());
modelBuilder.ApplyConfiguration(new OrderConfiguration());
// ...
}
Якщо у вас десятки конфігурацій — реєструвати кожну вручну стомлює. ApplyConfigurationsFromAssembly автоматично знаходить і реєструє всі класи, що реалізують IEntityTypeConfiguration<T> у вказаній асемблі:
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.
Data/
├── AppDbContext.cs
├── Configurations/
│ ├── AuthorConfiguration.cs
│ ├── BookConfiguration.cs
│ ├── OrderConfiguration.cs
│ ├── OrderItemConfiguration.cs
│ ├── CustomerConfiguration.cs
│ └── ProductConfiguration.cs
Кожен файл відповідає за одну сутність. Зміни у конфігурації однієї сутності не зачіпають інші файли.
Розберемо одну і ту саму ситуацію — конфігурацію класу Product — через обидва підходи, щоб наочно відчути різницю.
[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.
// Чистий POCO — жодного атрибуту!
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Sku { get; set; } = string.Empty;
public decimal Price { get; set; }
public string? Category { get; set; }
public bool IsActive { get; set; } = true;
[NotMapped] // єдиний атрибут — і це OK
public string DisplayLabel => $"[{Sku}] {Name}";
}
public class ProductConfiguration : IEntityTypeConfiguration<Product>
{
public void Configure(EntityTypeBuilder<Product> builder)
{
builder.ToTable("products", "catalog");
builder.HasKey(p => p.Id);
builder.Property(p => p.Name)
.IsRequired()
.HasMaxLength(500);
builder.Property(p => p.Sku)
.IsRequired()
.HasMaxLength(50)
.IsUnicode(false)
.HasColumnName("sku_code");
builder.Property(p => p.Price)
.HasPrecision(10, 2);
builder.Property(p => p.Category)
.HasMaxLength(100);
builder.Ignore(p => p.DisplayLabel); // або просто не маппити get-only
builder.HasIndex(p => p.Sku)
.IsUnique()
.HasDatabaseName("UX_Products_Sku");
builder.HasIndex(p => new { p.Name, p.Category })
.HasDatabaseName("IX_Products_NameCategory");
}
}
Плюси: модель чиста, все про схему — в одному місці, легко знайти і змінити конфігурацію. Мінуси: трохи більше файлів, потрібно перемикатись між файлами для розуміння повної картини.
| Можливість | Data Annotations | Fluent API |
|---|---|---|
| Назва таблиці | [Table] | ToTable() |
| Схема | [Table(Schema)] | ToTable("t", "schema") |
| Первинний ключ | [Key] | HasKey() |
| Складений PK | ⚠️ deprecated | HasKey(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 Filter | ❌ | HasQueryFilter() |
| Owned Types | ❌ | OwnsOne(), OwnsMany() |
| Table Splitting | ❌ | ToTable() на багатьох сутностях |
| TPH Discriminator | ❌ (частково) | HasDiscriminator() |
| Shadow Properties | ❌ | Property<T>("Name") |
| Backing Fields | ❌ | HasField() |
| Value Converters | ❌ | HasConversion() |
| Sequence | ❌ | HasSequence() |
| Keyless Entity | ❌ | HasNoKey() |
| DB Function mapping | ❌ | HasDbFunction() |
Як видно з таблиці — Fluent API є надмножиною Data Annotations. Все, що можна зробити через атрибути, можна зробити і через Fluent API. Зворотнє — хибне.
Питання «Data Annotations чи Fluent API» часто породжує дискусії в командах. Ось структурований погляд на цей вибір:
Чистота доменної моделі. POCO-класи залишаються без жодної залежності від EF Core. Якщо вирішите замінити ORM — моделі не треба чіпати. Це відповідає принципу Dependency Inversion.
Централізація конфігурації. Вся схема в одному місці — в Configurations/. Якщо треба знайти, як налаштований стовпець — знаєш, де дивитись.
Повний функціонал. Fluent API дозволяє все, що вміє EF Core. Не буде ситуації, де «хочу щось зробити, а через атрибути не виходить».
Кращий рефакторинг. Перейменування класу або властивості при атрибутах вимагає перевіряти [Table], [Column] по всьому класу (і вони можуть залишитись зі старими значеннями). При Fluent API конфігурація в окремому файлі — легко знайти і оновити.
Командна узгодженість. Один стиль для всього проєкту — простіше code review.
Видимість прямо в моделі. Читаєш клас — одразу бачиш, що [MaxLength(200)], що [Required]. Не треба перемикатись між файлами.
Синергія з ASP.NET Core валідацією. [Required] і [MaxLength] використовуються одночасно для схеми БД і ModelState-валідації у Controllers. Одна анотація — два ефекти.
Менше файлів у маленьких проєктах. Для CRUD-застосунку з 5-10 сутностями Fluent API конфігурація у окремих файлах може бути надмірністю.
Знайомість. Розробники, що знають Entity Framework Classic або ASP.NET Core, вже знають більшість атрибутів.
Деякі команди обирають змішаний підхід: атрибути для простих речей ([Required], [MaxLength]), Fluent API для складних (зв'язки, індекси, конвертери).
Проблема: при code review треба перевіряти обидва місця. Нещодавно доданий розробник не знає, де шукати конфігурацію — в класі чи в Configurations. Ризик конфлікту (Fluent API перекриває атрибут без помітки).
Рекомендація: оберіть один підхід і дотримуйтесь його в межах проєкту. Для будь-якого проєкту крім найдрібнішого — Fluent API з IEntityTypeConfiguration<T> і ApplyConfigurationsFromAssembly.
Розглянемо конфігурацію системи замовлень із кількома сутностями через рекомендований підхід:
Зверніть увагу: OnModelCreating містить рівно один рядок. Вся логіка конфігурації — у відповідальних файлах. Кожен файл конфігурації відповідає рівно за одну сутність і є самодостатнім.
Fluent API перекриває атрибути, але не з'являється попередження — конфлікт тихий:
// ❌ Конфлікт: [MaxLength(200)] і HasMaxLength(500) — виграє Fluent API (500)
// Але інший розробник читає клас і бачить "200" — помилкове очікування
[MaxLength(200)]
public string Name { get; set; } = string.Empty;
// У конфігурації:
builder.Property(a => a.Name).HasMaxLength(500); // перекриє атрибут тихо
Рішення: оберіть один підхід і видаліть конфігурацію з іншого.
EF Core спробує зберегти get-only властивість і кине exception при міграції, якщо вона не є стовпцем:
// ❌ EF Core спробує зробити цю властивість стовпцем!
public string FullName => $"{FirstName} {LastName}";
// ✅ Варіант 1: атрибут
[NotMapped]
public string FullName => $"{FirstName} {LastName}";
// ✅ Варіант 2: Fluent API
builder.Ignore(a => a.FullName);
Насправді, get-only властивості (без setter) EF Core зазвичай ігнорує автоматично. Проблема виникає з властивостями, що мають тіло виразу => але також мають setter.
При перевизначенні OnModelCreating в ланцюжку успадкування важливо викликати base.OnModelCreating:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder); // ← важливо! ASP.NET Identity та деякі провайдери потребують цього
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
Особливо критично при використанні ASP.NET Core Identity (IdentityDbContext) — якщо не викликати base, схема Identity-таблиць не буде створена.
Ви написали IEntityTypeConfiguration<T>, але не зареєстрували його — конфігурація просто не застосовується без жодної помилки:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// ❌ ProductConfiguration написана, але не зареєстрована:
modelBuilder.ApplyConfiguration(new AuthorConfiguration()); // тільки Author!
// ProductConfiguration ніколи не викликається
// ✅ Правильно: сканування автоматично підхопить всі конфігурації
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
Рівень 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.
IEntityTypeConfiguration<T>)IEntityTypeConfiguration<T> — правильний спосіб організації Fluent API: окремий файл на кожну сутністьApplyConfigurationsFromAssembly — автоматичне сканування і реєстрація всіх конфігурацій без перерахування вручнуIEntityTypeConfiguration<T> для всіх проєктів крім найменшихНаступна стаття — Зв'язки: One-to-One, One-to-Many — детальний розбір основних типів зв'язків: principal/dependent ролі, конфігурація Required і Optional навігацій, самореферентні зв'язки, та SQL-схеми, що вони генерують.
Конвенції EF Core — Магія без конфігурації
Повний розбір вбудованих конвенцій EF Core — первинні ключі, зовнішні ключі, типи стовпців, Nullable Reference Types, навігаційні властивості, ієрархія конфігурації та кастомні конвенції.
Зв'язки — One-to-One та One-to-Many
Глибокий розбір зв'язків в Entity Framework Core — Principal і Dependent, конфігурація Required та Optional навігацій, однонаправлені та двонаправлені зв'язки, самореферентні відносини, всі варіанти DeleteBehavior з реальними прикладами.