Наслідування — один із фундаментальних принципів об'єктно-орієнтованого програмування. Ієрархії класів дозволяють описувати «є підтипом» (is-a) відносини: Dog є Animal, CreditCard є PaymentMethod, Manager є Employee. У C# це природний спосіб організації коду.
Але реляційні бази даних не мають поняття «наслідування». Таблиця — це просто набір рядків з фіксованим набором стовпців. Як помістити ієрархію класів у пласку таблиці? Це класична проблема Object-Relational Impedance Mismatch, і для її вирішення існують різні стратегії.
EF Core підтримує три основних стратегії маппінгу ієрархії успадкування:
Але перед тим як зануритися у ці стратегії — поговоримо про сценарій, що зустрічається частіше будь-якої з них: простий абстрактний базовий клас для спільних полів.
Один з найпоширеніших патернів у реальних проєктах — абстрактний базовий клас не як предмет для поліморфного запиту, а як технічний засіб DRY: винести спільні поля (Id, CreatedAt, UpdatedAt) у базовий клас, щоб не дублювати у кожній entity.
// Абстрактний базовий клас: лише спільні поля
public abstract class BaseEntity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? UpdatedAt { get; set; }
}
// Конкретні entity наслідують базовий клас
public class Product : BaseEntity
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public string CategoryCode { get; set; } = string.Empty;
}
public class Customer : BaseEntity
{
public string FullName { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
public string Phone { get; set; } = string.Empty;
}
public class Order : BaseEntity
{
public string OrderNumber { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
public int CustomerId { get; set; }
public Customer Customer { get; set; } = null!;
}
Як це поводиться в EF Core? Тут все залежить від того, чи зареєстрований базовий клас у DbContext.
Якщо BaseEntity з'являється у DbContext через DbSet<BaseEntity> або конфігурацію — EF Core застосовує TPH і очікує одну таблицю для всієї ієрархії з discriminator. Це майже завжди не те, чого ми хочемо.
// НЕ РОБІТЬ ТАК:
public class AppDbContext : DbContext
{
public DbSet<BaseEntity> BaseEntities => Set<BaseEntity>(); // ← проблема!
public DbSet<Product> Products => Set<Product>();
public DbSet<Customer> Customers => Set<Customer>();
}
// EF Core спробує застосувати TPH: одна таблиця BaseEntities з discriminator
// Products, Customers, Orders — всі в одній таблиці. Катастрофа.
Щоб BaseEntity слугував лише технічним базовим класом без впливу на схему — НЕ додавайте DbSet<BaseEntity> і НЕ конфігуруйте його окремо. EF Core автоматично «розуміє», що abstract клас без реєстрації — це спільні поля, що мають потрапити до таблиць нащадків.
// ПРАВИЛЬНО:
public class AppDbContext : DbContext
{
// BaseEntity відсутній у DbContext — EF Core не знає про нього як entity
public DbSet<Product> Products => Set<Product>();
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.ApplyConfigurationsFromAssembly(typeof(AppDbContext).Assembly);
}
}
Результат: три окремих таблиці, кожна зі своїми полями плюс спільними полями базового класу:
CREATE TABLE [Products] (
[Id] INT NOT NULL IDENTITY,
[CreatedAt] DATETIME2 NOT NULL,
[UpdatedAt] DATETIME2 NULL,
[Name] NVARCHAR(200) NOT NULL,
[Price] DECIMAL(12, 2) NOT NULL,
[CategoryCode] NVARCHAR(50) NOT NULL,
CONSTRAINT [PK_Products] PRIMARY KEY ([Id])
);
CREATE TABLE [Customers] (
[Id] INT NOT NULL IDENTITY,
[CreatedAt] DATETIME2 NOT NULL,
[UpdatedAt] DATETIME2 NULL,
[FullName] NVARCHAR(200) NOT NULL,
[Email] NVARCHAR(320) NOT NULL,
[Phone] NVARCHAR(30) NOT NULL,
CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
);
-- Аналогічно для Orders
Три окремі таблиці, ніякого discriminator, жодного TPH — саме те, чого ми хочемо.
Спільні поля можна конфігурувати один раз у базовому класі конфігурації:
// Базова конфігурація для всіх entity, що наслідують BaseEntity
public abstract class BaseEntityConfiguration<T> : IEntityTypeConfiguration<T>
where T : BaseEntity
{
public virtual void Configure(EntityTypeBuilder<T> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.CreatedAt)
.HasDefaultValueSql("GETUTCDATE()")
.ValueGeneratedOnAdd()
.IsRequired();
builder.Property(e => e.UpdatedAt)
.ValueGeneratedOnUpdate();
}
}
// Конкретні конфігурації наслідують базову
public class ProductConfiguration : BaseEntityConfiguration<Product>
{
public override void Configure(EntityTypeBuilder<Product> builder)
{
base.Configure(builder); // спільна конфігурація!
builder.Property(p => p.Name)
.IsRequired()
.HasMaxLength(200);
builder.Property(p => p.Price)
.HasPrecision(12, 2)
.IsRequired();
builder.Property(p => p.CategoryCode)
.IsRequired()
.HasMaxLength(50)
.IsUnicode(false);
}
}
public class CustomerConfiguration : BaseEntityConfiguration<Customer>
{
public override void Configure(EntityTypeBuilder<Customer> builder)
{
base.Configure(builder);
builder.Property(c => c.FullName).IsRequired().HasMaxLength(200);
builder.Property(c => c.Email).IsRequired().HasMaxLength(320);
builder.Property(c => c.Phone).HasMaxLength(30);
builder.HasIndex(c => c.Email).IsUnique();
}
}
Це потужна техніка: base.Configure(builder) застосовує всі спільні правила, а нащадок лише додає специфічні. Якщо потрібно змінити CreatedAt для всіх entity — змінити тільки BaseEntityConfiguration.
Часто CreatedAt і UpdatedAt потрібно заповнювати автоматично при збереженні. Абстрактний базовий клас + Interceptor — ідеальна комбінація:
public class TimestampInterceptor : SaveChangesInterceptor
{
public override InterceptionResult<int> SavingChanges(
DbContextEventData eventData, InterceptionResult<int> result)
{
SetTimestamps(eventData.Context!);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
SetTimestamps(eventData.Context!);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void SetTimestamps(DbContext context)
{
var now = DateTime.UtcNow;
foreach (var entry in context.ChangeTracker.Entries<BaseEntity>())
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = now;
}
if (entry.State is EntityState.Added or EntityState.Modified)
{
entry.Entity.UpdatedAt = now;
}
}
}
}
// Реєстрація Interceptor у DbContext
services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(connectionString)
.AddInterceptors(new TimestampInterceptor()));
Тепер кожного разу при SaveChangesAsync() — CreatedAt і UpdatedAt заповнюються автоматично для всіх entity, що наслідують BaseEntity. Жодного дублювання коду.
Базовий клас не зобов'язаний використовувати int як PK. Популярна альтернатива — Guid:
public abstract class GuidEntity
{
public Guid Id { get; set; } = Guid.NewGuid(); // генерується у C#
public DateTime CreatedAt { get; set; }
public string? CreatedBy { get; set; }
}
public abstract class GuidEntityConfiguration<T> : IEntityTypeConfiguration<T>
where T : GuidEntity
{
public virtual void Configure(EntityTypeBuilder<T> builder)
{
builder.HasKey(e => e.Id);
builder.Property(e => e.Id)
.ValueGeneratedNever(); // ми самі генеруємо у C#
builder.Property(e => e.CreatedAt)
.IsRequired()
.ValueGeneratedOnAdd()
.HasDefaultValueSql("GETUTCDATE()");
builder.Property(e => e.CreatedBy)
.HasMaxLength(200);
}
}
abstract базовий клас у EF Core НЕ стає entity: EF Core автоматично ігнорує abstract класи, якщо вони не зареєстровані в DbContext напряму і не є частиною зареєстрованої ієрархії наслідування. Тому abstract class BaseEntity — це безпечний спосіб виноси спільних полів без побічних ефектів на схему.Абстрактний базовий клас — це технічний прийом, не архітектурне рішення щодо ієрархії. «Справжнє» наслідування у контексті EF Core означає наявність поліморфного запиту: «дай мені всі PaymentMethod, незалежно від того, CreditCard це чи BankTransfer».
Саме для таких ієрархій існують TPH, TPT і TPC. Розглянемо мотивуючий приклад, на якому будемо демонструвати всі три стратегії.
// Абстрактний базовий клас ієрархії
public abstract class Publication
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string AuthorName { get; set; } = string.Empty;
public DateTime PublishedAt { get; set; }
public decimal Price { get; set; }
}
// Конкретні типи
public class Book : Publication
{
public string Isbn { get; set; } = string.Empty;
public int PageCount { get; set; }
public string Publisher { get; set; } = string.Empty;
public string Genre { get; set; } = string.Empty;
}
public class Article : Publication
{
public string Url { get; set; } = string.Empty;
public int WordCount { get; set; }
public string? Journal { get; set; }
}
public class Podcast : Publication
{
public string RssUrl { get; set; } = string.Empty;
public int DurationMinutes { get; set; }
public string HostName { get; set; } = string.Empty;
}
Поліморфний запит: «Покажи мені всі публікації автора John Doe», незалежно від типу. Без стратегії наслідування це неможливо в одному SQL-запиті.
TPH (Table-Per-Hierarchy) — стратегія за замовчуванням у EF Core. Вся ієрархія класів зберігається в одній таблиці. Щоб розрізняти, яким C#-типом є кожний рядок, додається спеціальний стовпець — discriminator (розрізнювач).
Таблиця: Publications
┌────┬──────────────────┬───────────────┬─────────────┬──────────┬──────────┬──────────┬─── ...
│ Id │ Discriminator │ Title │ AuthorName │ Isbn │ WordCount│ RssUrl │
├────┼──────────────────┼───────────────┼─────────────┼──────────┼──────────┼──────────┤
│ 1 │ Book │ Clean Code │ Robert M. │ 978-... │ NULL │ NULL │
│ 2 │ Article │ SOLID Refresh │ John Doe │ NULL │ 2500 │ NULL │
│ 3 │ Podcast │ Dev Stories │ Alice Smith │ NULL │ NULL │ rss://.. │
└────┴──────────────────┴───────────────┴─────────────┴──────────┴──────────┴──────────┘
Стовпці специфічні для Book (Isbn, PageCount, Publisher, Genre) — NULL для рядків Article і Podcast. І навпаки. Це означає: чим більше нащадків і чим більше унікальних полів — тим більше NULL-стовпців у таблиці.
EF Core застосовує TPH автоматично, якщо зареєстрований базовий клас. Конфігурація мінімальна:
public class AppDbContext : DbContext
{
// Реєструємо базовий клас — EF Core виявляє всю ієрархію
public DbSet<Publication> Publications => Set<Publication>();
// Можна також реєструвати нащадків окремо
public DbSet<Book> Books => Set<Book>();
public DbSet<Article> Articles => Set<Article>();
public DbSet<Podcast> Podcasts => Set<Podcast>();
}
Без жодної конфігурації EF Core генерує:
CREATE TABLE [Publications] (
[Id] INT NOT NULL IDENTITY,
[Discriminator] NVARCHAR(8) NOT NULL, -- "Book", "Article", "Podcast"
[Title] NVARCHAR(MAX) NOT NULL,
[AuthorName] NVARCHAR(MAX) NOT NULL,
[PublishedAt] DATETIME2 NOT NULL,
[Price] DECIMAL(18,2) NOT NULL,
-- Book-специфічні (NULL для не-Book рядків)
[Isbn] NVARCHAR(MAX) NULL,
[PageCount] INT NULL,
[Publisher] NVARCHAR(MAX) NULL,
[Genre] NVARCHAR(MAX) NULL,
-- Article-специфічні
[Url] NVARCHAR(MAX) NULL,
[WordCount] INT NULL,
[Journal] NVARCHAR(MAX) NULL,
-- Podcast-специфічні
[RssUrl] NVARCHAR(MAX) NULL,
[DurationMinutes] INT NULL,
[HostName] NVARCHAR(MAX) NULL,
CONSTRAINT [PK_Publications] PRIMARY KEY ([Id])
);
За замовчуванням discriminator — рядок з назвою C#-типу. Налаштуємо точніше:
public class PublicationConfiguration : IEntityTypeConfiguration<Publication>
{
public void Configure(EntityTypeBuilder<Publication> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Title)
.IsRequired()
.HasMaxLength(500);
builder.Property(p => p.AuthorName)
.IsRequired()
.HasMaxLength(200);
builder.Property(p => p.Price)
.HasPrecision(10, 2);
// Конфігурація discriminator:
// - власна назва стовпця
// - власні значення для кожного типу
builder.HasDiscriminator<string>("PublicationType")
.HasValue<Book>("book")
.HasValue<Article>("article")
.HasValue<Podcast>("podcast");
// Discriminator стовпець: фіксована довжина для ефективності
builder.Property<string>("PublicationType")
.HasMaxLength(10)
.IsUnicode(false);
}
}
public class BookConfiguration : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
builder.Property(b => b.Isbn)
.IsRequired()
.HasMaxLength(20)
.IsUnicode(false);
builder.Property(b => b.PageCount)
.IsRequired();
builder.Property(b => b.Publisher)
.IsRequired()
.HasMaxLength(200);
builder.Property(b => b.Genre)
.HasMaxLength(100);
// Унікальний індекс по ISBN
builder.HasIndex(b => b.Isbn).IsUnique();
}
}
public class ArticleConfiguration : IEntityTypeConfiguration<Article>
{
public void Configure(EntityTypeBuilder<Article> builder)
{
builder.Property(a => a.Url)
.IsRequired()
.HasMaxLength(2000)
.IsUnicode(false);
builder.Property(a => a.WordCount)
.IsRequired();
builder.Property(a => a.Journal)
.HasMaxLength(200);
}
}
public class PodcastConfiguration : IEntityTypeConfiguration<Podcast>
{
public void Configure(EntityTypeBuilder<Podcast> builder)
{
builder.Property(p => p.RssUrl)
.IsRequired()
.HasMaxLength(2000)
.IsUnicode(false);
builder.Property(p => p.DurationMinutes)
.IsRequired();
builder.Property(p => p.HostName)
.IsRequired()
.HasMaxLength(200);
}
}
Оновлений DDL:
CREATE TABLE [Publications] (
[Id] INT NOT NULL IDENTITY,
[PublicationType] VARCHAR(10) NOT NULL, -- кастомний discriminator
[Title] NVARCHAR(500) NOT NULL,
[AuthorName] NVARCHAR(200) NOT NULL,
[PublishedAt] DATETIME2 NOT NULL,
[Price] DECIMAL(10,2) NOT NULL,
[Isbn] VARCHAR(20) NULL,
[PageCount] INT NULL,
[Publisher] NVARCHAR(200) NULL,
[Genre] NVARCHAR(100) NULL,
[Url] VARCHAR(2000) NULL,
[WordCount] INT NULL,
[Journal] NVARCHAR(200) NULL,
[RssUrl] VARCHAR(2000) NULL,
[DurationMinutes] INT NULL,
[HostName] NVARCHAR(200) NULL,
CONSTRAINT [PK_Publications] PRIMARY KEY ([Id])
);
CREATE UNIQUE INDEX [IX_Publications_Isbn] ON [Publications] ([Isbn]) WHERE [Isbn] IS NOT NULL;
// Поліморфний запит: ВСІ публікації
var all = await context.Publications
.OrderBy(p => p.PublishedAt)
.ToListAsync();
// SQL: SELECT Id, PublicationType, Title, AuthorName, ..., Isbn, Url, RssUrl, ...
// FROM Publications ORDER BY PublishedAt
// EF Core матеріалізує правильний C#-тип залежно від PublicationType
// Типово-фільтрований запит: тільки Books
var books = await context.Books
.Where(b => b.Genre == "Programming")
.ToListAsync();
// SQL: SELECT ... FROM Publications WHERE PublicationType='book' AND Genre='Programming'
// Або через OfType<T>() від базового DbSet
var articles = await context.Publications
.OfType<Article>()
.Where(a => a.WordCount > 2000)
.ToListAsync();
// SQL: SELECT ... FROM Publications WHERE PublicationType='article' AND WordCount > 2000
// Pattern matching у C# після завантаження
var publication = await context.Publications.FindAsync(id);
var description = publication switch
{
Book book => $"Книга: {book.PageCount} сторінок, ISBN: {book.Isbn}",
Article article => $"Стаття: {article.WordCount} слів",
Podcast podcast => $"Подкаст: {podcast.DurationMinutes} хвилин",
_ => "Невідомий тип"
};
Рядкові discriminator-значення зручні для читання, але числові — ефективніші для індексування та ORDER BY:
public enum PublicationKind { Book = 1, Article = 2, Podcast = 3 }
builder.HasDiscriminator<PublicationKind>("PublicationKind")
.HasValue<Book>(PublicationKind.Book)
.HasValue<Article>(PublicationKind.Article)
.HasValue<Podcast>(PublicationKind.Podcast);
builder.Property<PublicationKind>("PublicationKind")
.HasConversion<int>(); // зберігаємо як int
DDL:
[PublicationKind] INT NOT NULL, -- 1=Book, 2=Article, 3=Podcast
Числові discriminator займають менше місця, швидше порівнюються і краще індексуються, але втрачають читабельність у SELECT * запитах.
Ключова проблема TPH — NULL explosion: зі збільшенням кількості нащадків і їх специфічних полів таблиця стає все більш «розрідженою». Рядок Book має NULL у стовпцях Url, WordCount, RssUrl, DurationMinutes, HostName — п'ять зайвих стовпців у кожному рядку.
Впливи:
Isbn має враховувати NULL (WHERE Isbn IS NOT NULL)Переваги TPH:
JOIN не потрібен — всі дані в одній таблиціПравило: TPH підходить, якщо ієрархія невелика (3-5 типів),
нащадки мають мало унікальних полів (< 5-7 полів кожен),
і поліморфні запити є основним сценарієм.
Один зі способів посилити схемну валідацію у TPH — HasCheckConstraint:
public class BookConfiguration : IEntityTypeConfiguration<Book>
{
public void Configure(EntityTypeBuilder<Book> builder)
{
// Гарантуємо: якщо PublicationType = 'book', то Isbn NOT NULL
builder.ToTable(tb => tb.HasCheckConstraint(
"CK_Publications_Book_Isbn",
"[PublicationType] != 'book' OR [Isbn] IS NOT NULL"
));
}
}
Це не ідеальна заміна справжнього NOT NULL, але хоч якась захист на рівні бази.
Завдання 1.1: Абстрактний базовий клас
Реалізуйте abstract class AuditableEntity з полями: Id (int), CreatedAt (DateTime), UpdatedAt (DateTime?), CreatedBy (string?). Нехай від нього наслідуються Invoice, Contract, Report — кожен зі своїми 3-4 специфічними полями.
Налаштуйте BaseEntityConfiguration<T> так, щоб:
CreatedAt мав HasDefaultValueSql("GETUTCDATE()")Завдання 1.2: TPH для транспорту
Реалізуйте ієрархію Vehicle (Id, Make, Model, Year, PricePerDay) з нащадками:
Car (Doors, Transmission)Truck (PayloadCapacityTons, IsRefrigerated)Motorcycle (EngineCC, HasSidecar)Налаштуйте TPH з discriminator VehicleType (рядок, varchar(15)). Напишіть запити:
OfTypeЗавдання 1.3: Numeric discriminator
Для ієрархії Notification (Id, UserId, Message, CreatedAt) з нащадками EmailNotification (Subject, FromEmail), SmsNotification (PhoneNumber), PushNotification (DeviceToken, Platform) — налаштуйте TPH з числовим enum discriminator. Чому числовий швидший? Перевірте з EXPLAIN або SQL Profiler.
Завдання 2.1: Check Constraints у TPH
Для ієрархії PaymentMethod (Id, OwnerId, IsDefault) з нащадками:
CreditCard (CardNumber, ExpiryMonth, ExpiryYear, CardholderName)BankAccount (Iban, BankName, AccountHolderName)Додайте HasCheckConstraint, що гарантує:
Завдання 2.2: Фільтрований TPH
Для Publication (TPH) реалізуйте Global Query Filter:
IsPublished (bool, Shadow Property)IgnoreQueryFilters() для адміністраторівPublicationsЗавдання 3.1: Динамічна ієрархія з TPH
Система підтримки задач: WorkItem (Id, Title, Status, AssigneeId, CreatedAt) з нащадками Bug (Severity, Steps, AffectedVersion), Feature (Priority, StoryPoints, EpicId?), Task (DueDate, EstimatedHours).
Реалізуйте:
IWorkItemRepository<T> where T : WorkItemGetByAssigneeAsync(int userId) — повертає всі WorkItem незалежно від типуGetBugsAsync(Severity minSeverity) — тільки Bug з фільтромУ цій частині ми розглянули два рівні «наслідування» в EF Core:
abstract class BaseEntity без реєстрації у DbContext. Результат — окремі таблиці з спільними полями, без discriminator. Поєднується з BaseEntityConfiguration<T> і TimestampInterceptor.HasDiscriminator. Переваги: швидкі поліморфні запити. Недоліки: NULL explosion, неможливість NOT NULL для специфічних полів.У другій частині розглянемо TPT (Table-Per-Type) і TPC (Table-Per-Concrete-Class) — коли і чому їх обирати — та зробимо порівняльну таблицю всіх трьох стратегій.
JSON Columns — Value Comparers, Індекси, Провайдери (Частина 2)
Value Comparers для JSON Columns, індексування JSON-полів, відмінності PostgreSQL JSONB vs SQL Server JSON vs SQLite, обмеження JSON Columns в EF Core, стратегії версіонування JSON-схеми та матриця вибору «JSON vs нормалізована таблиця».
Успадкування — TPT, TPC та Порівняння Стратегій (Частина 2)
Table-Per-Type (TPT) і Table-Per-Concrete-Class (TPC) в EF Core — конфігурація, продуктивність, міграції, вибір стратегії генерації ID. Фінальна порівняльна таблиця всіх трьох підходів та матриця рішень.