Mert Özen Her Satırda Daha İleri
Veritabanı Entity Framework Core — #19

EF Core'da Inheritance Mapping: TPH, TPT ve Hangisini Ne Zaman Seçmeli

Mert Özen 5 Kas 2026 12 dk 2 görüntülenme
EF Core'da Inheritance Mapping: TPH, TPT ve Hangisini Ne Zaman Seçmeli

Bir class hiyerarşisini EF Core tablolara nasıl çevirir? Discriminator kolonuyla TPH'yi ve tip başına tablo kuran TPT'yi gerçek SQL çıktılarıyla karşılaştırıyor, modele eklenmeyen alt class gibi sessiz tuzakları görüyoruz.

Sürüm notu: Bu yazıdaki kodlar EF Core 6 ile yazılmıştır ve EF Core 10'da da değişmeden çalışır.

Önce Tahmin Et: Üç Kitap Türü Kaç Tablo Eder?

Kitapçımız büyüdü. Artık sadece basılı kitap satmıyoruz; e-kitap ve sesli kitap da var. Üçünün de adı ve fiyatı ortak, ama basılı kitabın sayfa sayısı, e-kitabın dosya boyutu, sesli kitabın da süresi ve seslendireni var. C# tarafında bunu modellemenin en doğal yolu inheritance:

public class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
}

public class PrintedBook : Book
{
    public int PageCount { get; set; }
}

public class EBook : Book
{
    public double FileSizeMb { get; set; }
    public string Format { get; set; } = "";
}

public class AudioBook : Book
{
    public int DurationMinutes { get; set; }
    public string Narrator { get; set; } = "";
}

Şimdi tahmin et: bu dört class'ı DbContext'e verip EnsureCreated çağırdığında veritabanında kaç tablo oluşur? Dört mü, üç mü, bir mi? Cevabı vermeden önce şunu düşün: ilişkisel veritabanında inheritance diye bir kavram yok. Tablo tablodur, birbirinden miras almaz. Yani EF Core'un class hiyerarşisini tablolara çevirmek için bir strateji seçmesi gerekiyor. Bu yazıda EF Core 6'nın sunduğu iki stratejiyi, TPH ve TPT'yi, gerçek SQL çıktılarıyla karşılaştıracağız.

Varsayılan Strateji: TPH ve Discriminator Kolonu

Context'i yazalım. Her tip için bir DbSet açıyoruz ve çalışan SQL'i görmek için komut loglarını açıyoruz:

using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;

public class BookstoreContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();
    public DbSet<PrintedBook> PrintedBooks => Set<PrintedBook>();
    public DbSet<EBook> EBooks => Set<EBook>();
    public DbSet<AudioBook> AudioBooks => Set<AudioBook>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=bookstore.db")
                  .LogTo(Console.WriteLine, new[] { RelationalEventId.CommandExecuted });
}

EnsureCreated'in çalıştırdığı DDL'i db.Database.GenerateCreateScript() ile de görebilirsin. EF Core 6 ve 10'da birebir aynı:

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "Price" TEXT NOT NULL,
    "Discriminator" TEXT NOT NULL,
    "DurationMinutes" INTEGER NULL,
    "Narrator" TEXT NULL,
    "FileSizeMb" REAL NULL,
    "Format" TEXT NULL,
    "PageCount" INTEGER NULL
);

Cevap: tek tablo. Bu stratejinin adı TPH, yani table-per-hierarchy. Bütün hiyerarşi tek bir tabloya yazılır, her alt class'ın property'leri o tabloya kolon olarak eklenir. Satırın hangi tipe ait olduğunu da EF'in kendi eklediği Discriminator kolonu söyler. Bu kolon için class'ında bir property yok; EF onu shadow property olarak tutar. Price'ın TEXT olmasına şaşırma, SQLite'ta decimal tipi olmadığı için EF onu metin olarak saklıyor.

TPH'yi bir öğrenci yurdunun ortak dolabı gibi düşün. Herkesin eşyası aynı dolapta, her rafın üstünde de sahibinin adı yazıyor. Dolabı açmak tek hareket, ama raflardan biri hep yarı boş kalıyor.

Kaydetmek ve Polymorphic Sorgular

Üç farklı kitabı tek AddRange ile ekleyip geri okuyalım:

using var db = new BookstoreContext();
db.Database.EnsureDeleted();
db.Database.EnsureCreated();

db.Books.AddRange(
    new PrintedBook { Title = "Dune", Price = 18.50m, PageCount = 612 },
    new EBook { Title = "Clean Code", Price = 9.99m, FileSizeMb = 4.2, Format = "EPUB" },
    new AudioBook { Title = "Sapiens", Price = 14.00m, DurationMinutes = 935, Narrator = "Derek Perkins" });
db.SaveChanges();
db.ChangeTracker.Clear();

foreach (var book in db.Books.ToList())
    Console.WriteLine($"{book.GetType().Name,-12} {book.Title}");

Logda her kitap için bir INSERT görürsün. Sesli kitabınki EF Core 10'da şöyle:

INSERT INTO "Books" ("Discriminator", "DurationMinutes", "Narrator", "Price", "Title")
VALUES (@p0, @p1, @p2, @p3, @p4)
RETURNING "Id";

EF Core 6 aynı INSERT'ten sonra RETURNING yerine SELECT "Id" FROM "Books" WHERE changes() = 1 AND "rowid" = last_insert_rowid(); gönderiyor; sonuç aynı. Discriminator değerini sen vermedin, EF class adından kendisi yazdı. Konsol çıktısı:

AudioBook    Sapiens
EBook        Clean Code
PrintedBook  Dune

İki şey dikkat çekiyor. Birincisi, db.Books üzerinden yaptığımız tek sorgu bize doğru tipte nesneler döndürdü: Book değil, AudioBook, EBook, PrintedBook. Buna polymorphic query denir; EF her satırın discriminator değerine bakıp hangi class'tan nesne üreteceğine karar veriyor. İkincisi, EF aynı tablodaki INSERT'leri kendi belirlediği sırayla gönderdi. Id sırasının AddRange sırasına uyacağına güvenme.

Sadece bir alt tipi istediğinde EF, WHERE'e discriminator koşulunu kendisi ekliyor:

var ebooks = db.EBooks.ToList();
var bigEbooks = db.Books.OfType<EBook>().Where(e => e.FileSizeMb > 1).ToList();
var longAudio = db.Books
    .Where(b => b is AudioBook && ((AudioBook)b).DurationMinutes > 600)
    .ToList();

ToQueryString() ile üç sorgunun SQL'i (EF Core 10):

SELECT "b"."Id", "b"."Discriminator", "b"."Price", "b"."Title", "b"."FileSizeMb", "b"."Format"
FROM "Books" AS "b"
WHERE "b"."Discriminator" = 'EBook'

SELECT "b"."Id", "b"."Discriminator", "b"."Price", "b"."Title", "b"."FileSizeMb", "b"."Format"
FROM "Books" AS "b"
WHERE "b"."Discriminator" = 'EBook' AND "b"."FileSizeMb" > 1.0

SELECT "b"."Id", "b"."Discriminator", "b"."Price", "b"."Title", "b"."DurationMinutes", "b"."Narrator", "b"."FileSizeMb", "b"."Format", "b"."PageCount"
FROM "Books" AS "b"
WHERE "b"."Discriminator" = 'AudioBook' AND "b"."DurationMinutes" > 600

db.EBooks ile OfType<EBook>() aynı SQL'i üretiyor; alt tip için ayrı DbSet açmak sadece kolaylık. C#'taki is operatörü de discriminator karşılaştırmasına çevriliyor. Fark şu: is ile filtrelediğinde sorgunun tipi hâlâ Book olduğu için SELECT listesi bütün kolonları getiriyor. EF Core 6 aynı SQL'i koşulları parantez içine alarak yazıyor, örneğin WHERE ("b"."Discriminator" = 'EBook') AND ("b"."FileSizeMb" > 1.0).

Discriminator bir shadow property olduğu için gerekirse onu da sorguda okuyabilirsin:

var types = db.Books
    .Select(b => new { b.Title, Type = EF.Property<string>(b, "Discriminator") })
    .ToList();

Discriminator'ı Kendin Yönet

Varsayılan değerler class adı olduğu için kullanışlı ama kırılgan. Kolonun adını, tipini ve değerlerini Fluent API ile kendin belirleyebilirsin. Bu sefer discriminator'ı gerçek bir property'ye bağlayalım ve Book'u abstract yapalım, çünkü dükkânda "sadece kitap" diye bir ürün yok:

public enum BookType
{
    Printed,
    Digital,
    Audio
}

public abstract class Book
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public decimal Price { get; set; }
    public BookType Type { get; set; }
}

// PrintedBook ve EBook aynı; AudioBook'a da bir Format ekledik
public class AudioBook : Book
{
    public int DurationMinutes { get; set; }
    public string Narrator { get; set; } = "";
    public string Format { get; set; } = "";
}
public class BookstoreContext : DbContext
{
    public DbSet<Book> Books => Set<Book>();

    protected override void OnConfiguring(DbContextOptionsBuilder options)
        => options.UseSqlite("Data Source=bookstore.db");

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Book>()
            .HasDiscriminator(b => b.Type)
            .HasValue<PrintedBook>(BookType.Printed)
            .HasValue<EBook>(BookType.Digital)
            .HasValue<AudioBook>(BookType.Audio);

        modelBuilder.Entity<Book>()
            .Property(b => b.Type)
            .HasConversion<string>()
            .HasMaxLength(16);

        // iki kardeş tip aynı kolonu paylaşsın
        modelBuilder.Entity<EBook>().Property(e => e.Format).HasColumnName("Format");
        modelBuilder.Entity<AudioBook>().Property(a => a.Format).HasColumnName("Format");
    }
}

Üç şey değişti. HasDiscriminator(b => b.Type) discriminator olarak Type property'sini kullanıyor; HasValue çağrıları da her tipin hangi değerle yazılacağını söylüyor. HasConversion<string>() enum'u veritabanında okunabilir metin olarak tutuyor; value converter'ları bir sonraki yazıda ayrıntılı göreceğiz. Son iki satır da EBook ve AudioBook'taki Format property'lerini aynı kolona bağlıyor. Ayrıca bu sefer yalnız DbSet<Book> var; alt tipler HasValue çağrıları sayesinde modele girdi. Oluşan tablo:

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY AUTOINCREMENT,
    "Title" TEXT NOT NULL,
    "Price" TEXT NOT NULL,
    "Type" TEXT NOT NULL,
    "DurationMinutes" INTEGER NULL,
    "Narrator" TEXT NULL,
    "Format" TEXT NULL,
    "FileSizeMb" REAL NULL,
    "PageCount" INTEGER NULL
);

Format tek kolon olarak duruyor. db.Books.OfType<AudioBook>() sorgusu da artık WHERE "b"."Type" = 'Audio' ile filtreliyor. Okuduğun nesnede Type property'si de dolu geliyor, yani kodda switch (book.Type) yazabilirsin. Yine de çoğu yerde is ya da pattern matching daha okunaklıdır; enum'u daha çok raporlama ve dışarıya veri verirken işe yarar bulursun.

TPH'nin Bedeli: NULL Olmak Zorunda Kalan Kolonlar

DDL'e tekrar bak. AudioBook.Narrator C# tarafında non-nullable bir string, ama kolon Narrator TEXT NULL. PageCount bir int, kolon yine NULL kabul ediyor. Sebep basit: tablo bütün tiplerin ortak tablosu. Bir e-kitap satırının PageCount'u olamaz, yani kolon NULL'a izin vermek zorunda.

Bunun pratik sonucu şu: "sesli kitabın seslendireni zorunlu" kuralını veritabanı artık zorlayamaz. Kural yalnız senin C# kodunda ve EF'in modelinde yaşıyor. Aynı tabloya başka bir uygulama ya da elle yazılmış bir SQL ile erişiliyorsa, seslendireni boş bir sesli kitap kaydetmek mümkün. DBA'ların TPH'ye mesafeli durmasının ana sebebi bu.

Diğer bedel genişlik. Hiyerarşi büyüdükçe tablo da genişler ve satırların çoğu kolonların çoğunda NULL taşır. Beş alt tip, her birinde sekiz property demek, her satırda en az otuz iki boş kolon demek. Buna karşılık her sorgu tek tabloya gidiyor, hiç JOIN yok. Okuma performansı açısından TPH'yi yenmek zor.

TPT: Her Tipe Ayrı Tablo

EF Core 5 ile gelen ikinci strateji TPT, yani table-per-type. Modeli aynen bırakıp her tipi kendi tablosuna eşliyoruz:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.Entity<Book>().ToTable("Books");
    modelBuilder.Entity<PrintedBook>().ToTable("PrintedBooks");
    modelBuilder.Entity<EBook>().ToTable("EBooks");
    modelBuilder.Entity<AudioBook>().ToTable("AudioBooks");
}

EF Core 10'un ürettiği DDL'den iki tablo:

CREATE TABLE "Books" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_Books" PRIMARY KEY,
    "Title" TEXT NOT NULL,
    "Price" TEXT NOT NULL
);

CREATE TABLE "EBooks" (
    "Id" INTEGER NOT NULL CONSTRAINT "PK_EBooks" PRIMARY KEY,
    "FileSizeMb" REAL NOT NULL,
    "Format" TEXT NOT NULL,
    CONSTRAINT "FK_EBooks_Books_Id" FOREIGN KEY ("Id") REFERENCES "Books" ("Id") ON DELETE CASCADE
);

Ortak kolonlar Books'ta, her alt tipin kendi kolonları kendi tablosunda. Alt tablonun primary key'i aynı zamanda Books'a giden foreign key. Discriminator kolonu yok ve kolonlar artık NOT NULL. EF Core 6 burada Id kolonlarına AUTOINCREMENT de yazıyor; değer zaten Books'tan geldiği için pratikte bir etkisi yok.

Bir e-kitap eklediğinde iki INSERT gidiyor (EF Core 10 logu):

INSERT INTO "Books" ("Price", "Title")
VALUES (@p0, @p1)
RETURNING "Id";

INSERT INTO "EBooks" ("Id", "FileSizeMb", "Format")
VALUES (@p2, @p3, @p4);

Silerken de önce EBooks'tan, sonra Books'tan iki DELETE gidiyor. Asıl fark sorguda. db.Books.ToList() artık şöyle bir SQL üretiyor:

SELECT "b"."Id", "b"."Price", "b"."Title", "a"."DurationMinutes", "a"."Narrator", "e"."FileSizeMb", "e"."Format", "p"."PageCount", CASE
    WHEN "p"."Id" IS NOT NULL THEN 'PrintedBook'
    WHEN "e"."Id" IS NOT NULL THEN 'EBook'
    WHEN "a"."Id" IS NOT NULL THEN 'AudioBook'
END AS "Discriminator"
FROM "Books" AS "b"
LEFT JOIN "AudioBooks" AS "a" ON "b"."Id" = "a"."Id"
LEFT JOIN "EBooks" AS "e" ON "b"."Id" = "e"."Id"
LEFT JOIN "PrintedBooks" AS "p" ON "b"."Id" = "p"."Id"

Discriminator kolonu yok ama EF'in yine de tip bilgisine ihtiyacı var. Onu her alt tabloya LEFT JOIN yapıp hangisinde eşleşen satır olduğuna bakarak, CASE ile kendisi üretiyor. Üç alt tip üç JOIN demek; on alt tip on JOIN. db.EBooks gibi tek bir alt tipi istediğinde ise tek bir INNER JOIN "EBooks" yetiyor. İki strateji de EF Core 6 ve 10'da aynı SQL'i üretiyor.

Hangisi Ne Zaman?

Kısa cevap: varsayılan olarak TPH ile başla. EF dokümantasyonu da ölçümlere dayanarak TPT'nin performans açısından çoğu durumda geride kaldığını söylüyor; oradaki benchmark'ta yedi tipli bir hiyerarşinin bütün satırlarını yüklemek TPH ile 149 ms, TPT ile 313 ms sürüyor. Sorgular tek tabloya gider, polymorphic sorgu JOIN istemez, insert ve delete tek komuttur.

TPT'yi şu durumlarda düşün. Veritabanı şemasının kendi başına anlamlı olması gerekiyorsa, örneğin raporlama ekibi tabloları doğrudan okuyorsa ve NOT NULL kısıtlarının veritabanında durması şartsa. Ya da zaten tip başına tablo olan mevcut bir veritabanına bağlanıyorsan. Bedelini de bilerek seç: base tip üzerinden yapılan her sorgu, alt tip sayısı kadar JOIN taşır ve hiyerarşi büyüdükçe bu belirgin şekilde yavaşlar.

Kafandaki karar sorusu şu olsun: kodun çoğunlukla base tip üzerinden mi sorgulama yapıyor, yoksa her zaman belirli bir alt tipi mi istiyor? İlkiyse TPH açık ara önde. İkincisi için üçüncü bir strateji var: TPC, yani table-per-concrete-type. Her somut tipin bütün kolonlarıyla kendi tablosu olur, base tablo hiç olmaz. TPC ve onu açıkça seçen UseTpcMappingStrategy(), TPT için de açık bir UseTptMappingStrategy() metodu EF Core 7 ile geldi; sürüm yazılarında göreceğiz.

Son bir not: strateji bir şema kararıdır. Canlıdaki bir projede TPH'den TPT'ye geçmek, tablo yapısını değiştiren ve veriyi taşıyan bir migration demek. Kararı ilk migration'dan önce ver.

Sık Düşülen Tuzaklar

Birinci tuzak, modele eklemeyi unuttuğun alt class. Çocuk kitapları için yeni bir tip yazdın ama ne DbSet açtın ne de OnModelCreating'de ondan bahsettin:

public class KidsBook : Book
{
    public int MinAge { get; set; }
}
db.Books.Add(new KidsBook { Title = "Matilda", MinAge = 8 });
db.SaveChanges();
db.ChangeTracker.Clear();

var matilda = db.Books.Single(b => b.Title == "Matilda");
Console.WriteLine($"{matilda.GetType().Name} / {db.Entry(matilda).Property("Discriminator").CurrentValue}");

Bir exception bekliyorsun, değil mi? EF Core 6 ve 10'da hiçbir hata çıkmıyor. EF nesneyi modeldeki en yakın tip olan Book olarak takip ediyor ve INSERT'te MinAge kolonu hiç yok: INSERT INTO "Books" ("Discriminator", "Price", "Title"). Konsola Book / Book yazıyor. MinAge sessizce kayboldu. Yeni bir alt tip eklediğinde onu DbSet ya da modelBuilder.Entity<KidsBook>() ile modele aldığından emin ol ve bir migration üretip farkı gör.

İkinci tuzak, class adını değiştirmek. Varsayılan discriminator değeri class adı olduğu için PrintedBook'u PaperBook diye yeniden adlandırırsan veritabanındaki eski satırlar hâlâ 'PrintedBook' der. Bunu tersinden simüle edelim:

db.Database.ExecuteSqlRaw("UPDATE Books SET Discriminator = 'PaperBook' WHERE Title = 'Dune'");
db.ChangeTracker.Clear();
var all = db.Books.ToList();
System.InvalidOperationException: Unable to materialize entity instance of type 'Book'. No discriminators matched the discriminator value 'PaperBook'.

Tek bir satır yüzünden bütün liste okunamaz hâle geliyor. Discriminator değerlerini HasValue ile sabitlemek bu yüzden iyi bir alışkanlık; class adı değişse bile veritabanındaki değer değişmez.

Üçüncü tuzak, Book abstract değilse bir "düz kitap" satırının da var olabilmesi. Varsayılan ayarla discriminator değeri 'Book' olan satırlar geçerli sayılır. İş kuralında öyle bir ürün yoksa base class'ı abstract yap. Son olarak, TPH'de alt tipin required property'leri için veritabanı seviyesinde güvence olmadığını unutma; doğrulamayı kendi kodunda yap.

Küçük Bir Deneme

★☆☆ TPH modeline string? Publisher property'si olan bir Magazine : Book ekle ve DbSet açmadan modelBuilder.Entity<Magazine>() ile modele al. GenerateCreateScript() çıktısında yeni kolonu bul.

★★☆ Aynı dört class'ı önce TPH, sonra TPT ile çalıştır. Her ikisinde de db.Books.ToList(), db.EBooks.ToList() ve bir e-kitap silme işleminin logunu karşılaştır. Hangi işlemde kaç komut gittiğini bir tabloya yaz.

★★★ TPH modelinde Narrator'ı boş bırakılmış bir sesli kitabı ExecuteSqlRaw ile doğrudan tabloya yaz (Narrator NULL). Sonra db.AudioBooks.ToList() ile oku ve EF Core 6 ile 10'da ne olduğuna bak. Bu sonuç, required property'nin veritabanında değil modelde yaşadığı hakkında sana ne söylüyor?

Bu yazıda bir kitabın kendi alt tipine göre farklı kolonlar taşıdığı durumu çözdük. Peki ya bir siparişin teslimat adresi gibi, kendi kimliği olmayan ama birden fazla kolondan oluşan değerler? Ya da veritabanında metin olarak durması gereken bir enum, kendi tipine sarılmış bir Id? Bir sonraki yazıda owned type'lara ve value converter'lara bakacağız.