Ef Core

Успадкування — Абстрактні класи та TPH (Частина 1)

Наслідування в EF Core — від простого абстрактного базового класу до Table-Per-Hierarchy (TPH). Discriminator column, конфігурація, продуктивність, NULL-стовпці та антипатерни.

Успадкування: Абстрактні класи та TPH

Наслідування в ООП та реляційних базах: impedance mismatch

Наслідування — один із фундаментальних принципів об'єктно-орієнтованого програмування. Ієрархії класів дозволяють описувати «є підтипом» (is-a) відносини: Dog є Animal, CreditCard є PaymentMethod, Manager є Employee. У C# це природний спосіб організації коду.

Але реляційні бази даних не мають поняття «наслідування». Таблиця — це просто набір рядків з фіксованим набором стовпців. Як помістити ієрархію класів у пласку таблиці? Це класична проблема Object-Relational Impedance Mismatch, і для її вирішення існують різні стратегії.

EF Core підтримує три основних стратегії маппінгу ієрархії успадкування:

  • TPH (Table-Per-Hierarchy): одна таблиця для всієї ієрархії з discriminator-стовпцем
  • TPT (Table-Per-Type): окрема таблиця для кожного типу
  • TPC (Table-Per-Concrete-Class): окрема таблиця для кожного конкретного (non-abstract) типу

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


Простий абстрактний базовий клас: спільні поля без ієрархії у БД

Один з найпоширеніших патернів у реальних проєктах — абстрактний базовий клас не як предмет для поліморфного запиту, а як технічний засіб 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.

Варіант A: BaseEntity зареєстрований у 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 — всі в одній таблиці. Катастрофа.

Варіант B: BaseEntity як MappedSuperclass (правильно)

Щоб 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.

Автоматичне заповнення спільних полів через Interceptor

Часто 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. Жодного дублювання коду.

Приклад з Guid-ідентифікатором

Базовий клас не зобов'язаний використовувати 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

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-стовпців у таблиці.

TPH за замовчуванням

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])
);

Повна конфігурація TPH через Fluent API

За замовчуванням 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;

Запити з TPH

// Поліморфний запит: ВСІ публікації
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} хвилин",
    _               => "Невідомий тип"
};

Numeric discriminator

Рядкові 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-стовпці

Ключова проблема TPH — NULL explosion: зі збільшенням кількості нащадків і їх специфічних полів таблиця стає все більш «розрідженою». Рядок Book має NULL у стовпцях Url, WordCount, RssUrl, DurationMinutes, HostName — п'ять зайвих стовпців у кожному рядку.

Впливи:

  • Розмір рядка: NULL-стовпці займають місце (навіть якщо СУБД оптимізує storage — metadata залишається)
  • Читабельність схеми: таблиця з 40+ стовпцями складно аналізувати
  • Валідація: поля, обов'язкові для конкретного типу (Isbn для Book), не можуть бути NOT NULL на рівні бази — бо вони NULL для не-Book рядків
  • Індексування: індекс по Isbn має враховувати NULL (WHERE Isbn IS NOT NULL)

Переваги TPH:

  • Один JOIN не потрібен — всі дані в одній таблиці
  • Поліморфний запит — один SELECT
  • Швидші вставки та оновлення (один INSERT/UPDATE)
  • Простіші міграції (нова властивість нащадка = один новий стовпець)
Правило: TPH підходить, якщо ієрархія невелика (3-5 типів),
нащадки мають мало унікальних полів (< 5-7 полів кожен),
і поліморфні запити є основним сценарієм.

Check Constraints для TPH

Один зі способів посилити схемну валідацію у 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 — Базовий

Завдання 1.1: Абстрактний базовий клас

Реалізуйте abstract class AuditableEntity з полями: Id (int), CreatedAt (DateTime), UpdatedAt (DateTime?), CreatedBy (string?). Нехай від нього наслідуються Invoice, Contract, Report — кожен зі своїми 3-4 специфічними полями.

Налаштуйте BaseEntityConfiguration<T> так, щоб:

  • CreatedAt мав HasDefaultValueSql("GETUTCDATE()")
  • Перевірте у DDL: три окремі таблиці без жодного discriminator-стовпця

Завдання 1.2: TPH для транспорту

Реалізуйте ієрархію Vehicle (Id, Make, Model, Year, PricePerDay) з нащадками:

  • Car (Doors, Transmission)
  • Truck (PayloadCapacityTons, IsRefrigerated)
  • Motorcycle (EngineCC, HasSidecar)

Налаштуйте TPH з discriminator VehicleType (рядок, varchar(15)). Напишіть запити:

  • Всі автомобілі дешевше 1500 грн/день
  • Причепи (IsRefrigerated = true)
  • Всі транспортні засоби 2020+ року через 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 — Логіка

Завдання 2.1: Check Constraints у TPH

Для ієрархії PaymentMethod (Id, OwnerId, IsDefault) з нащадками:

  • CreditCard (CardNumber, ExpiryMonth, ExpiryYear, CardholderName)
  • BankAccount (Iban, BankName, AccountHolderName)

Додайте HasCheckConstraint, що гарантує:

  • Якщо discriminator = 'credit_card', то CardNumber NOT NULL і ExpiryMonth між 1 і 12
  • Якщо discriminator = 'bank_account', то Iban NOT NULL

Завдання 2.2: Фільтрований TPH

Для Publication (TPH) реалізуйте Global Query Filter:

  • Фільтр за IsPublished (bool, Shadow Property)
  • Функція IgnoreQueryFilters() для адміністраторів
  • Запит, що показує тільки опубліковані Books і Articles через один DbSet Publications

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

Завдання 3.1: Динамічна ієрархія з TPH

Система підтримки задач: WorkItem (Id, Title, Status, AssigneeId, CreatedAt) з нащадками Bug (Severity, Steps, AffectedVersion), Feature (Priority, StoryPoints, EpicId?), Task (DueDate, EstimatedHours).

Реалізуйте:

  • Поліморфний репозиторій IWorkItemRepository<T> where T : WorkItem
  • Метод GetByAssigneeAsync(int userId) — повертає всі WorkItem незалежно від типу
  • Метод GetBugsAsync(Severity minSeverity) — тільки Bug з фільтром
  • Сортування за пріоритетом (для Feature) або severity (для Bug) з поліморфним ORDER BY
  • Pattern matching для формування DTO з різними полями залежно від типу

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

У цій частині ми розглянули два рівні «наслідування» в EF Core:

  • Абстрактний базовий клас (MappedSuperclass): технічний прийом DRY — abstract class BaseEntity без реєстрації у DbContext. Результат — окремі таблиці з спільними полями, без discriminator. Поєднується з BaseEntityConfiguration<T> і TimestampInterceptor.
  • TPH (Table-Per-Hierarchy): заomovchуванням стратегія EF Core для справжніх ієрархій. Одна таблиця з HasDiscriminator. Переваги: швидкі поліморфні запити. Недоліки: NULL explosion, неможливість NOT NULL для специфічних полів.

У другій частині розглянемо TPT (Table-Per-Type) і TPC (Table-Per-Concrete-Class) — коли і чому їх обирати — та зробимо порівняльну таблицю всіх трьох стратегій.