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.
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.
Configure table splitting with two entity types sharing one table row and aligned primary-key columns.
Explain why every table-sharing entity must expose/map the same concurrency token.
Reason about optional table-split dependents and their all-null materialization check.
Configure entity splitting with SplitToTable and explain required fragments/linking keys.
State inheritance restrictions for table/entity splitting.
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.
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
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
// 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.
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.
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;}
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
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
// 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
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
-
Create a
LegacyMappingContextand disposableservicehub-legacy.db. -
Implement the required table-split work-order
header/diagnostics model with the shared
revisionconcurrency token. - Temporarily omit the token on one entity, capture the model-validation failure, then repair it.
- Query/update diagnostics and inspect generated SQL plus concurrency predicate.
-
Implement
LegacyCustomerContactwith twoSplitToTablefragments. - Delete one required fragment row manually in the disposable DB and observe why the mapping no longer represents a complete entity.
-
Attempt entity splitting on the
ServiceTargethierarchy in a scratch branch/file and capture the model-validation failure; then remove it. - Reset both disposable databases/migrations after the lab.
Check your understanding
- What must table-split entity types share?
- Why must a shared concurrency token be mapped on every entity sharing the row?
- What can happen with an optional table-split dependent whose mapped columns are all null?
- What is entity splitting?
- Can entity-split fragments be optional?
- 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
- Advanced table mapping - EF Core — table splitting, entity splitting, concurrency, optional dependents, inheritance limitations
- Inheritance - EF Core — inheritance strategy constraints referenced by table splitting
- Concurrency - EF Core — optimistic concurrency predicates and tokens
- Relationships - EF Core — one-to-one key/foreign-key semantics
- Migrations - EF Core — reviewing schema changes for legacy mappings