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

Table Splitting and Entity Splitting for Legacy Schemas and Bounded Object Models

Adapt legacy ServiceHub schemas with table splitting and entity splitting while preserving key alignment, concurrency correctness, required fragments, and supported inheritance boundaries.

Intermediate110–140 minuteslegacy split-mapping + validation labEF Core 10.0.11 · .NET 10.0.11SQLite provider 10.0.11 baselineSDK checkpoint: 10.0.400Last reviewed: August 2026

Learning outcomes

Inheritance decides how a type hierarchy maps. Legacy schemas introduce a different mismatch: sometimes two domain-facing entity types must share one physical row, or one entity must be reconstructed from several physical rows. EF Core calls these table splitting and entity splitting. They are powerful adaptation tools, but they impose key, concurrency, optionality, and inheritance restrictions that must be explicit.

01

Configure table splitting with two entity types sharing one table row and aligned primary-key columns.

02

Explain why every table-sharing entity must expose/map the same concurrency token.

03

Reason about optional table-split dependents and their all-null materialization check.

04

Configure entity splitting with SplitToTable and explain required fragments/linking keys.

05

State inheritance restrictions for table/entity splitting.

06

Use these mappings as bounded legacy adapters rather than pretending a difficult schema disappeared.

1. Table splitting: two entity types, one physical row

Assume an inherited ServiceHub database has a wide legacy_work_orders table. The operational model wants a small LegacyWorkOrderHeader plus a diagnostics component with its own tracked entity semantics. Table splitting maps both to the same table and same primary-key column.

csharp · table-split entities
public sealed class LegacyWorkOrderHeader{    public int Id { get; private set; }    public string Number { get; private set; } = string.Empty;    public Guid Revision { get; private set; }    public LegacyWorkOrderDiagnostics Diagnostics { get; private set; } = null!;}public sealed class LegacyWorkOrderDiagnostics{    public int Id { get; private set; }    public string? LastErrorCode { get; private set; }    public string? DiagnosticNotes { get; private set; }    public Guid Revision { get; private set; }    public LegacyWorkOrderHeader WorkOrder { get; private set; } = null!;}

2. Align table, key column, one-to-one relationship, and concurrency token

csharp · table splitting configuration
modelBuilder.Entity<LegacyWorkOrderHeader>(builder =>{    builder.ToTable("legacy_work_orders");    builder.HasKey(x => x.Id);    builder.Property(x => x.Id).HasColumnName("work_order_id");    builder.Property(x => x.Number).HasColumnName("work_order_number").HasMaxLength(40);    builder.Property(x => x.Revision).HasColumnName("revision").IsConcurrencyToken();    builder.HasOne(x => x.Diagnostics)        .WithOne(x => x.WorkOrder)        .HasForeignKey<LegacyWorkOrderDiagnostics>(x => x.Id);    builder.Navigation(x => x.Diagnostics).IsRequired();});modelBuilder.Entity<LegacyWorkOrderDiagnostics>(builder =>{    builder.ToTable("legacy_work_orders");    builder.HasKey(x => x.Id);    builder.Property(x => x.Id).HasColumnName("work_order_id");    builder.Property(x => x.LastErrorCode).HasColumnName("last_error_code").HasMaxLength(50);    builder.Property(x => x.DiagnosticNotes).HasColumnName("diagnostic_notes");    builder.Property(x => x.Revision).HasColumnName("revision").IsConcurrencyToken();});

All sharing types must map the concurrency token; otherwise updating one tracked view of the row could use stale concurrency state from the other. A shadow property is acceptable if you do not want to surface the token on one CLR type.

3. Deliberately broken table sharing: token only on one entity

csharp · incomplete concurrency mapping
// WRONG/incomplete: Header has Revision, Diagnostics maps the same row// but does not map the shared concurrency column.builder.Property(x => x.Revision)    .HasColumnName("revision")    .IsConcurrencyToken();

Model validation detects incompatible table sharing when concurrency-token mappings are inconsistent. Repair by mapping the same token column on every entity type sharing the row. Then inspect generated UPDATE predicates and confirm revision participates regardless of which split entity was modified.

4. Optional table-split dependents have existence semantics

If the dependent is optional and every column EF uses for that dependent is NULL, EF can decide not to create an instance. That check can add query cost and becomes tricky with nested dependents. Mark the navigation required when the schema/domain guarantees the component exists. Do not use “optional” merely because some diagnostic scalar properties themselves are nullable.

Concurrency interaction

A required shared concurrency value also signals that the physical row exists. Model optionality from actual schema/domain semantics and verify materialization with real rows; do not infer it from C# nullable annotations alone.

5. Table splitting plus inheritance has specific limits

EF documents several restrictions: a table-split dependent hierarchy cannot use TPC; with TPT, only the root dependent can table-split; and a TPC principal imposes additional descendant restrictions. These are model-shape limits, not provider folklore. Keep the legacy adapter outside the ServiceTarget hierarchy unless you can prove a supported mapping.

6. Entity splitting: one entity, several required rows

Now invert the shape. A legacy customer record stores identity/name in one table, phone/email in another, and postal address in a third. ServiceHub wants one LegacyCustomerContact entity.

csharp · entity-split class
public sealed class LegacyCustomerContact{    public int Id { get; private set; }    public string Name { get; private set; } = string.Empty;    public string? Phone { get; private set; }    public string? Email { get; private set; }    public string Street { get; private set; } = string.Empty;    public string City { get; private set; } = string.Empty;}
csharp · SplitToTable mapping
modelBuilder.Entity<LegacyCustomerContact>(builder =>{    builder.ToTable("legacy_customers");    builder.HasKey(x => x.Id);    builder.Property(x => x.Id).HasColumnName("customer_id");    builder.Property(x => x.Name).HasColumnName("name");    builder.SplitToTable("legacy_customer_channels", tableBuilder =>    {        tableBuilder.Property(x => x.Id).HasColumnName("customer_id");        tableBuilder.Property(x => x.Phone).HasColumnName("phone");        tableBuilder.Property(x => x.Email).HasColumnName("email");    });    builder.SplitToTable("legacy_customer_addresses", tableBuilder =>    {        tableBuilder.Property(x => x.Id).HasColumnName("customer_id");        tableBuilder.Property(x => x.Street).HasColumnName("street");        tableBuilder.Property(x => x.City).HasColumnName("city");    });});

7. Entity-split fragments are not optional

sql · representative split-table schema
CREATE TABLE legacy_customers (  customer_id INTEGER NOT NULL PRIMARY KEY,  name TEXT NOT NULL);CREATE TABLE legacy_customer_channels (  customer_id INTEGER NOT NULL PRIMARY KEY,  phone TEXT NULL,  email TEXT NULL,  FOREIGN KEY (customer_id) REFERENCES legacy_customers(customer_id));CREATE TABLE legacy_customer_addresses (  customer_id INTEGER NOT NULL PRIMARY KEY,  street TEXT NOT NULL,  city TEXT NOT NULL,  FOREIGN KEY (customer_id) REFERENCES legacy_customers(customer_id));

For every row in the main table, EF requires a corresponding row in each split table; entity splitting does not model “maybe the address row exists.” If the legacy schema permits missing fragments, model separate related entities or repair/normalize data before using this mapping.

8. Entity splitting cannot be applied to an inheritance hierarchy

csharp · unsupported combination
// Do not try to split ServiceTarget (root of TPH/TPT/TPC hierarchy):modelBuilder.Entity<ServiceTarget>()    .SplitToTable("service_target_details", tb =>    {        tb.Property(x => x.Id);        tb.Property(x => x.DisplayName);    }); // entity splitting is not supported for hierarchy entity types

Keep these concerns separate: inheritance strategy and entity splitting solve different schema mismatches, and this combination is explicitly unsupported.

9. Observe updates and transaction ordering

csharp · modify fields stored in different fragments
var contact = await db.Set<LegacyCustomerContact>()    .SingleAsync(x => x.Id == 42, ct);// Assume domain methods update these private-setter-backed values.db.Entry(contact).Property(x => x.Phone).CurrentValue = "+994-12-555-0100";db.Entry(contact).Property(x => x.City).CurrentValue = "Baku";Console.WriteLine(db.ChangeTracker.DebugView.LongView);await db.SaveChangesAsync(ct);

Command logging should show updates to the physical fragment tables that own the changed columns. One entity-state entry can therefore result in commands against multiple tables. Preserve transaction semantics and test partial-failure behavior on the production-like provider.

10. Hands-on lab: adapt two awkward legacy shapes

  1. Create a LegacyMappingContext and disposable servicehub-legacy.db.
  2. Implement the required table-split work-order header/diagnostics model with the shared revision concurrency token.
  3. Temporarily omit the token on one entity, capture the model-validation failure, then repair it.
  4. Query/update diagnostics and inspect generated SQL plus concurrency predicate.
  5. Implement LegacyCustomerContact with two SplitToTable fragments.
  6. Delete one required fragment row manually in the disposable DB and observe why the mapping no longer represents a complete entity.
  7. Attempt entity splitting on the ServiceTarget hierarchy in a scratch branch/file and capture the model-validation failure; then remove it.
  8. Reset both disposable databases/migrations after the lab.

Check your understanding

  1. What must table-split entity types share?
  2. Why must a shared concurrency token be mapped on every entity sharing the row?
  3. What can happen with an optional table-split dependent whose mapped columns are all null?
  4. What is entity splitting?
  5. Can entity-split fragments be optional?
  6. Can entity splitting be used on ServiceTarget while it is in an inheritance hierarchy?
Review the answers

The same table, aligned primary-key columns, and a relationship linking those shared rows.

Otherwise one entity can hold a stale token while another updates the same physical row, breaking concurrency correctness.

EF may materialize no dependent instance; the existence check can add cost/ambiguity.

One EF entity is mapped across multiple physical tables/rows linked by the same key.

No. EF requires a corresponding row in each split fragment.

No. Entity splitting is not supported for entity types in hierarchies.

11. Production judgment and bridge

Use table/entity splitting as an explicit compatibility layer for a schema you must live with, not as a way to make relational complexity invisible. Document key alignment, required fragments, concurrency columns, write ordering, migration ownership, and provider constraints. The final lesson compares TPH/TPT/TPC with the same workload and forces the mapping choice to be defended with SQL, plans, and operational costs instead of class-diagram preference.

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