Chapter 07 · Inheritance, Table Splitting, Entity Splitting, and Advanced Relational Mapping

TPT Inheritance: Join Costs, Schema Clarity, and Workload Tradeoffs

Map the identical hierarchy with TPT and measure how normalized-looking tables translate into joins, multi-table writes, and index constraints.

Intermediate105–130 minutesTPT join-shape + write-command labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

In the ServiceHub mapping lab, TPH put the entire hierarchy in one table. A common reaction is “normalize it”: put base properties in one table and subtype properties in separate tables. EF Core calls that table-per-type (TPT). The schema can look elegant, but polymorphic reads need relational joins to reconstruct complete objects. This lesson makes those joins visible before anyone labels TPT “cleaner.”

01

Configure TPT explicitly with UseTptMappingStrategy and per-type tables.

02

Explain primary-key/foreign-key alignment between base and derived tables.

03

Inspect generated SQL for base and derived queries, including join growth.

04

Show why indexes/constraints spanning base and derived columns cannot be created as one database object.

05

Measure read/write implications on the disposable SQLite lab.

06

Reject the assumption that normalized-looking schema automatically means lower runtime cost.

1. The same CLR hierarchy, a different relational decomposition

csharp · shared ServiceTarget hierarchy
public abstract class ServiceTarget{    protected ServiceTarget(Guid id, string targetCode, string displayName)    {        Id = id;        TargetCode = targetCode;        DisplayName = displayName;    }    protected ServiceTarget() { }    public Guid Id { get; private set; }    public string TargetCode { get; private set; } = string.Empty;    public string DisplayName { get; private set; } = string.Empty;    public DateTime RegisteredUtc { get; private set; }    public bool IsActive { get; private set; } = true;}public sealed class EquipmentTarget : ServiceTarget{    private EquipmentTarget() { }    public EquipmentTarget(Guid id, string code, string name,        string manufacturer, string serialNumber)        : base(id, code, name)    {        Manufacturer = manufacturer;        SerialNumber = serialNumber;    }    public string Manufacturer { get; private set; } = string.Empty;    public string SerialNumber { get; private set; } = string.Empty;}public sealed class FacilityTarget : ServiceTarget{    private FacilityTarget() { }    public FacilityTarget(Guid id, string code, string name,        string siteCode, string? floor)        : base(id, code, name)    {        SiteCode = siteCode;        Floor = floor;    }    public string SiteCode { get; private set; } = string.Empty;    public string? Floor { get; private set; }}

Keep the exact same Guid keys, sample rows, projections, and predicates used in TPH. Only the mapping strategy changes. That isolation is essential if the final comparison is to say anything useful.

2. Configure TPT on the root

csharp · TPT configuration
modelBuilder.Entity<ServiceTarget>(builder =>{    builder.UseTptMappingStrategy();    builder.ToTable("service_targets_tpt");    builder.HasKey(x => x.Id);    builder.Property(x => x.TargetCode).HasColumnName("target_code").HasMaxLength(40);    builder.Property(x => x.DisplayName).HasColumnName("display_name").HasMaxLength(160);    builder.Property(x => x.RegisteredUtc).HasColumnName("registered_utc");    builder.Property(x => x.IsActive).HasColumnName("is_active");    builder.HasIndex(x => x.TargetCode).HasDatabaseName("ix_service_targets_tpt_code");});modelBuilder.Entity<EquipmentTarget>(builder =>{    builder.ToTable("equipment_targets_tpt");    builder.Property(x => x.Manufacturer).HasColumnName("manufacturer").HasMaxLength(120);    builder.Property(x => x.SerialNumber).HasColumnName("serial_number").HasMaxLength(120);});modelBuilder.Entity<FacilityTarget>(builder =>{    builder.ToTable("facility_targets_tpt");    builder.Property(x => x.SiteCode).HasColumnName("site_code").HasMaxLength(60);    builder.Property(x => x.Floor).HasColumnName("floor").HasMaxLength(40);});

3. Migration evidence: base row plus one derived row

sql · representative SQLite TPT DDL
CREATE TABLE service_targets_tpt (    Id TEXT NOT NULL PRIMARY KEY,    target_code TEXT NOT NULL,    display_name TEXT NOT NULL,    registered_utc TEXT NOT NULL,    is_active INTEGER NOT NULL);CREATE TABLE equipment_targets_tpt (    Id TEXT NOT NULL PRIMARY KEY,    manufacturer TEXT NOT NULL,    serial_number TEXT NOT NULL,    FOREIGN KEY (Id) REFERENCES service_targets_tpt(Id));CREATE TABLE facility_targets_tpt (    Id TEXT NOT NULL PRIMARY KEY,    site_code TEXT NOT NULL,    floor TEXT NULL,    FOREIGN KEY (Id) REFERENCES service_targets_tpt(Id));

An equipment target is now represented by two rows with the same key. Saving one object therefore requires coordinated commands across the base and equipment tables. The database enforces the derived-to-base link through the key/foreign-key relationship.

4. A polymorphic base query reconstructs type through joins

csharp · TPT base query
var query = db.Set<ServiceTarget>()    .AsNoTracking()    .Where(x => x.IsActive)    .OrderBy(x => x.TargetCode);Console.WriteLine(query.ToQueryString());
sql · representative TPT base-query shape
SELECT b.Id, b.target_code, b.display_name, b.registered_utc, b.is_active,       e.manufacturer, e.serial_number,       f.site_code, f.floor,       CASE         WHEN e.Id IS NOT NULL THEN 'equipment'         WHEN f.Id IS NOT NULL THEN 'facility'       END AS discriminatorFROM service_targets_tpt AS bLEFT JOIN equipment_targets_tpt AS e ON b.Id = e.IdLEFT JOIN facility_targets_tpt AS f ON b.Id = f.IdWHERE b.is_active = 1ORDER BY b.target_code;

Exact SQL is provider/version dependent, but the relational mechanism is stable: EF must join enough derived tables to know which subtype and subtype values exist.

5. Derived-only queries still join the base table

csharp · equipment-only query
var equipment = db.Set<EquipmentTarget>()    .AsNoTracking()    .Where(x => x.Manufacturer == "Fabrikam")    .Select(x => new { x.TargetCode, x.Manufacturer, x.SerialNumber });Console.WriteLine(equipment.ToQueryString());
sql · representative derived SQL shape
SELECT b.target_code, e.manufacturer, e.serial_numberFROM service_targets_tpt AS bINNER JOIN equipment_targets_tpt AS e ON b.Id = e.IdWHERE e.manufacturer = 'Fabrikam';

TPT does not allow EF to skip the base table when the projection needs base properties. Deeper hierarchies add more joins along the inheritance chain.

6. Deliberately wrong assumption: “normalized means faster”

Normalization reduces certain update anomalies and duplication, but TPT inheritance is an ORM mapping shape, not a performance theorem. A report that touches every subtype can pay for several joins per query. Conversely, a narrow subtype-specific query on well-indexed tables may be perfectly acceptable. The only defensible answer comes from workload-specific plans and timings.

sql · SQLite plan evidence
EXPLAIN QUERY PLANSELECT b.target_code, e.manufacturer, e.serial_numberFROM service_targets_tpt AS bJOIN equipment_targets_tpt AS e ON b.Id = e.IdWHERE e.manufacturer = 'Fabrikam';

7. Cross-table index limitations matter

Because inherited and derived properties live in different tables, a single database index cannot cover a key made from ServiceTarget.TargetCode plus EquipmentTarget.Manufacturer. EF’s inheritance documentation explicitly warns that composite foreign keys/indexes spanning inherited and declared properties cannot be created as one relational constraint/index under TPT. Design indexes per table and inspect the resulting plan.

Need TPH TPT
Index target_kind + target_code One table can hold both No discriminator column; subtype table identity comes from join
Index target_code + manufacturer Possible in one wide table Cannot be one physical index across base + derived tables
Subtype-only manufacturer index Possible but sparse/filtered considerations Natural on equipment table
Polymorphic query One table Join base to derived tables

8. Write path: one entity can mean multiple SQL commands

csharp · observe a new equipment insert
var equipment = new EquipmentTarget(    Guid.Parse("22222222-2222-2222-2222-222222222222"),    "EQ-220", "Chiller 220", "Fabrikam", "SN-220");db.Add(equipment);Console.WriteLine(db.ChangeTracker.DebugView.LongView);await db.SaveChangesAsync(ct);

Enable EF command logging and observe one insert into the base table and another into the equipment table within the save operation. This is not “bad”; it is simply the relational consequence you must account for in write-heavy workloads and migrations.

9. Hands-on lab: hold everything except mapping constant

  1. Create TptMappingContext against servicehub-tpt.db.
  2. Use the same hierarchy and deterministic seed keys as Lesson 1.
  3. Generate/review a separate migration in Data/Migrations/Tpt.
  4. Capture base, equipment-only, and projected queries with ToQueryString().
  5. Run EXPLAIN QUERY PLAN for each and save the raw output.
  6. Enable command logging and record how many INSERT commands one new EquipmentTarget produces.
  7. Add a scratch third-level subtype only in the lab and observe join growth; remove/reset it afterward.
  8. Compare TPH/TPT schema files and explain which cost moved from row width to joins/write coordination.

Check your understanding

  1. Where are base properties stored in TPT?
  2. How is a derived row linked to its base row?
  3. Why does a base query usually join derived tables?
  4. Can one physical TPT index span a base-table property and a derived-table property?
  5. Why can one insert produce multiple SQL commands?
  6. Is TPT automatically preferable because the schema looks normalized?
Review the answers

In the table mapped to the base type.

The derived table uses the same primary-key value and a foreign key to the base row.

EF must reconstruct subtype identity/properties across the separate tables.

No; those properties live in different physical tables.

A derived entity is represented by a base-table row plus a derived-table row.

No. TPT is workload-sensitive and often costs more joins; measure real query/write patterns.

10. Production judgment and bridge

TPT can fit legacy schemas or domains where subtype tables already exist and polymorphic reads are limited. Avoid choosing it just because the schema mirrors the class diagram. Review join counts, row counts, indexes, transaction/write volume, and migration impact. Lesson 3 tests the opposite decomposition: TPC removes base-table joins by duplicating inherited columns into every concrete table—and creates a different key-generation/FK problem.

Authoritative references

Keep knowledge open

Help the academy stay free and grow.

If these tutorials save you time, a small donation supports new lessons, technical review, diagrams, examples, and long-term maintenance.

ETHEthereum / ERC-20 only
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0

Send only Ethereum or ERC-20 compatible assets to this address.

\n