Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Inheritance

"The same four C# classes. Three different database schemas. One attribute change."


The Problem With Inheritance in ORMs

Object-oriented programming has inheritance. Relational databases do not. Every ORM must bridge this gap, and the choice of how to map inheritance to tables has significant consequences for performance, storage, and query complexity.

EF Core supports three strategies:

Strategy Tables Query Joins Nullable Columns Polymorphic Queries
TPH (Table Per Hierarchy) 1 0 Yes (derived properties) Fast (single table)
TPT (Table Per Type) N (one per type) N-1 No Slow (joins)
TPC (Table Per Concrete type) N (one per concrete type) 0 No Moderate (union)

Entity.Dsl lets the developer express the inheritance strategy with a single attribute. The same C# class hierarchy maps to any of the three strategies by changing one line.


The Payment Hierarchy

Our marketplace needs payments. A payment is an abstract concept — the concrete types are CreditCardPayment, BankTransferPayment, and WalletPayment. Each has shared properties (Amount, Currency, OrderId) and type-specific properties (CardLastFour, IBAN, WalletProvider).

Diagram

TPH: Table Per Hierarchy

All types stored in a single table with a discriminator column. This is the default and most common strategy.

The Domain Classes

[Entity("Payment")]
[Table("Payments")]
[Inheritance(Strategy = InheritanceStrategy.TPH, DiscriminatorColumn = "PaymentType")]
public abstract class Payment
{
    [PrimaryKey]
    public Guid Id { get; set; }

    [Required]
    [Precision(18, 2)]
    public decimal Amount { get; set; }

    [Required]
    [MaxLength(3)]
    public string Currency { get; set; } = "USD";

    public Guid OrderId { get; set; }

    [Aggregation]
    [HasOne(ForeignKey = "OrderId")]
    public Order Order { get; set; } = null!;

    public DateTimeOffset PaidAt { get; set; }
}

[Entity("CreditCardPayment")]
[Inheritance(Strategy = InheritanceStrategy.TPH, DiscriminatorValue = "CreditCard")]
public class CreditCardPayment : Payment
{
    [MaxLength(4)]
    public string CardLastFour { get; set; } = "";

    [MaxLength(20)]
    public string CardBrand { get; set; } = "";

    [MaxLength(50)]
    public string? AuthorizationCode { get; set; }
}

[Entity("BankTransferPayment")]
[Inheritance(Strategy = InheritanceStrategy.TPH, DiscriminatorValue = "BankTransfer")]
public class BankTransferPayment : Payment
{
    [MaxLength(34)]
    public string IBAN { get; set; } = "";

    [MaxLength(11)]
    public string BIC { get; set; } = "";

    [MaxLength(100)]
    public string? TransferReference { get; set; }
}

[Entity("WalletPayment")]
[Inheritance(Strategy = InheritanceStrategy.TPH, DiscriminatorValue = "Wallet")]
public class WalletPayment : Payment
{
    [Required]
    [MaxLength(50)]
    public string WalletProvider { get; set; } = "";

    [Required]
    [MaxLength(100)]
    public string WalletTransactionId { get; set; } = "";
}

Generated Configuration (TPH)

// In PaymentConfigurationBase.g.cs
protected virtual void ConfigureInheritance(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Payment> builder)
{
    builder.HasDiscriminator<string>("PaymentType")
        .HasValue<global::Marketplace.Domain.Payment>("Payment")
        .HasValue<global::Marketplace.Domain.CreditCardPayment>("CreditCard")
        .HasValue<global::Marketplace.Domain.BankTransferPayment>("BankTransfer")
        .HasValue<global::Marketplace.Domain.WalletPayment>("Wallet");
}

TPH Table Layout

Diagram

Pros: One table, no joins. Polymorphic queries (context.Payments.ToList()) are fast — they scan one table.

Cons: Derived-type columns (CardLastFour, IBAN, WalletProvider) must be nullable even if the domain says they are required for that type. The table gets wide with many derived types.


TPT: Table Per Type

Each type gets its own table. The derived tables have a FK back to the base table.

Changing to TPT

[Entity("Payment")]
[Table("Payments")]
[Inheritance(Strategy = InheritanceStrategy.TPT)]  // ← one attribute change
public abstract class Payment
{
    // ... same properties
}

[Entity("CreditCardPayment")]
[Table("CreditCardPayments")]    // ← each derived type needs its own table name
[Inheritance(Strategy = InheritanceStrategy.TPT)]
public class CreditCardPayment : Payment
{
    // ... same properties
}

[Entity("BankTransferPayment")]
[Table("BankTransferPayments")]
[Inheritance(Strategy = InheritanceStrategy.TPT)]
public class BankTransferPayment : Payment
{
    // ... same properties
}

[Entity("WalletPayment")]
[Table("WalletPayments")]
[Inheritance(Strategy = InheritanceStrategy.TPT)]
public class WalletPayment : Payment
{
    // ... same properties
}

Generated Configuration (TPT)

// In PaymentConfigurationBase.g.cs
protected virtual void ConfigureInheritance(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Payment> builder)
{
    builder.UseTptMappingStrategy();
}

// In CreditCardPaymentConfigurationBase.g.cs
protected virtual void ConfigureTable(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.CreditCardPayment> builder)
{
    builder.ToTable("CreditCardPayments");
}

// In BankTransferPaymentConfigurationBase.g.cs
protected virtual void ConfigureTable(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.BankTransferPayment> builder)
{
    builder.ToTable("BankTransferPayments");
}

// In WalletPaymentConfigurationBase.g.cs
protected virtual void ConfigureTable(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.WalletPayment> builder)
{
    builder.ToTable("WalletPayments");
}

TPT Table Layout

Payments (base table)
├── Id (PK)
├── Amount
├── Currency
├── OrderId
└── PaidAt

CreditCardPayments (derived table)
├── Id (PK, FK → Payments.Id)
├── CardLastFour
├── CardBrand
└── AuthorizationCode

BankTransferPayments (derived table)
├── Id (PK, FK → Payments.Id)
├── IBAN
├── BIC
└── TransferReference

WalletPayments (derived table)
├── Id (PK, FK → Payments.Id)
├── WalletProvider
└── WalletTransactionId

Pros: Normalized schema. No nullable columns. Each table only has the columns that belong to that type. Schema is clear and self-documenting.

Cons: Polymorphic queries require joins. context.Payments.ToList() generates a LEFT JOIN across all derived tables. For 10 derived types, that is 10 joins. Performance degrades with hierarchy depth.


TPC: Table Per Concrete Type

Each concrete (non-abstract) type gets its own table with all columns — both base and derived. No shared base table.

Changing to TPC

[Entity("Payment")]
[Inheritance(Strategy = InheritanceStrategy.TPC)]  // ← no [Table] on abstract base
public abstract class Payment
{
    // ... same properties
}

[Entity("CreditCardPayment")]
[Table("CreditCardPayments")]
[Inheritance(Strategy = InheritanceStrategy.TPC)]
public class CreditCardPayment : Payment
{
    // ... same properties
}

// ... same for BankTransferPayment and WalletPayment

Generated Configuration (TPC)

// In PaymentConfigurationBase.g.cs
protected virtual void ConfigureInheritance(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Payment> builder)
{
    builder.UseTpcMappingStrategy();
}

TPC Table Layout

CreditCardPayments (complete table)
├── Id (PK)
├── Amount
├── Currency
├── OrderId
├── PaidAt
├── CardLastFour
├── CardBrand
└── AuthorizationCode

BankTransferPayments (complete table)
├── Id (PK)
├── Amount
├── Currency
├── OrderId
├── PaidAt
├── IBAN
├── BIC
└── TransferReference

WalletPayments (complete table)
├── Id (PK)
├── Amount
├── Currency
├── OrderId
├── PaidAt
├── WalletProvider
└── WalletTransactionId

Pros: No joins. No nullable columns. Each table is self-contained. Queries on a specific type are fast — they hit one table with no joins.

Cons: Polymorphic queries use UNION ALL across all concrete tables. Base properties (Amount, Currency) are duplicated in every table. Schema changes to the base type require modifying all tables.


The Trade-Off Matrix

Criterion TPH TPT TPC
Polymorphic query perf Best (1 table) Worst (N joins) Good (UNION ALL)
Single-type query perf Good Good Best (1 table, no joins)
Storage efficiency Poor (nullable columns) Best (normalized) Poor (duplicated columns)
Schema clarity Poor (wide table) Best (clean tables) Good (self-contained)
Migration complexity Low (1 table) Medium (FK constraints) Medium (multiple tables)
Adding derived types Easy (add column + value) Easy (add table) Easy (add table)
Adding base properties Easy (1 table) Easy (1 table) Hard (N tables)
Null constraints Cannot enforce on derived Full enforcement Full enforcement
EF Core version All All 7.0+

When To Use Which

  • TPH: Default choice. Few derived types (< 5), many polymorphic queries, few type-specific columns. Our marketplace uses TPH for payments — three types, frequent "show all payments for this order" queries.

  • TPT: Many derived types, each with many columns, rare polymorphic queries. Example: a CMS with 20 content types — Article, Video, Podcast, Gallery — each with 10+ unique fields.

  • TPC: Performance-critical, many single-type queries, rare polymorphic queries. Example: a financial system where each transaction type (Trade, Dividend, Interest, Fee) is queried independently.


Querying the Hierarchy

Regardless of strategy, EF Core provides the same LINQ API:

// Polymorphic: all payments for an order
var payments = await _uow.Payments.FindWhereAsync(p => p.OrderId == orderId);

// Type-specific: only credit card payments
var ccPayments = await _uow.Payments.Query
    .OfType<CreditCardPayment>()
    .Where(p => p.CardBrand == "Visa")
    .ToListAsync();

// Pattern matching in application code
foreach (var payment in payments)
{
    var description = payment switch
    {
        CreditCardPayment cc => $"Card ending {cc.CardLastFour} ({cc.CardBrand})",
        BankTransferPayment bt => $"Bank transfer {bt.TransferReference ?? bt.IBAN}",
        WalletPayment w => $"{w.WalletProvider} #{w.WalletTransactionId}",
        _ => $"Payment {payment.Id}"
    };
}

The beauty of Entity.Dsl's approach: the C# code is identical regardless of which strategy attribute is applied. Switching from TPH to TPT changes the generated configuration and requires a migration — but zero application code changes.


Linking Payments to Orders

The Order aggregate gains a collection of payments:

// Added to Order class
public List<Payment> Payments { get; set; } = new();

With the relationship configured from the Payment side:

// In PaymentConfigurationBase.g.cs
protected virtual void ConfigureOrder(
    Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Payment> builder)
{
    builder.HasOne(e => e.Order)
        .WithMany(e => e.Payments)
        .HasForeignKey(e => e.OrderId)
        .OnDelete(DeleteBehavior.Restrict);
}

[Aggregation] produces Restrict — you cannot delete an Order that has Payments. Financial records must be preserved.


The Generated File Count

With inheritance, the file count grows because each type in the hierarchy gets its own configuration files:

Type Files Generated
Payment (base) 3 (ConfigurationBase, Configuration, Registration)
CreditCardPayment 3
BankTransferPayment 3
WalletPayment 3
Payment repository 2 (interface + implementation)

The repository is generated for the base type only. IPaymentRepository returns Payment instances, and the application uses OfType<T>() or pattern matching to work with specific types.


Migration Implications

Each strategy produces different migration code. Understanding this helps choose the right strategy for your project.

TPH Migration

// One table, all columns
migrationBuilder.CreateTable(
    name: "Payments",
    columns: table => new
    {
        Id = table.Column<Guid>(),
        Amount = table.Column<decimal>(precision: 18, scale: 2),
        Currency = table.Column<string>(maxLength: 3),
        OrderId = table.Column<Guid>(),
        PaidAt = table.Column<DateTimeOffset>(),
        PaymentType = table.Column<string>(maxLength: 21),  // discriminator
        // CreditCardPayment columns (nullable)
        CardLastFour = table.Column<string>(maxLength: 4, nullable: true),
        CardBrand = table.Column<string>(maxLength: 20, nullable: true),
        AuthorizationCode = table.Column<string>(maxLength: 50, nullable: true),
        // BankTransferPayment columns (nullable)
        IBAN = table.Column<string>(maxLength: 34, nullable: true),
        BIC = table.Column<string>(maxLength: 11, nullable: true),
        TransferReference = table.Column<string>(maxLength: 100, nullable: true),
        // WalletPayment columns (nullable)
        WalletProvider = table.Column<string>(maxLength: 50, nullable: true),
        WalletTransactionId = table.Column<string>(maxLength: 100, nullable: true),
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_Payments", x => x.Id);
        table.ForeignKey("FK_Payments_Orders_OrderId", x => x.OrderId, "Orders", "Id");
    });

Adding a new payment type (e.g., CryptoPayment) adds nullable columns to the existing table. No data migration needed.

TPT Migration

// Base table
migrationBuilder.CreateTable(name: "Payments", columns: table => new
{
    Id = table.Column<Guid>(),
    Amount = table.Column<decimal>(precision: 18, scale: 2),
    Currency = table.Column<string>(maxLength: 3),
    OrderId = table.Column<Guid>(),
    PaidAt = table.Column<DateTimeOffset>(),
});

// Derived table — FK to base
migrationBuilder.CreateTable(name: "CreditCardPayments", columns: table => new
{
    Id = table.Column<Guid>(),  // PK + FK to Payments
    CardLastFour = table.Column<string>(maxLength: 4),
    CardBrand = table.Column<string>(maxLength: 20),
    AuthorizationCode = table.Column<string>(maxLength: 50, nullable: true),
},
constraints: table =>
{
    table.PrimaryKey("PK_CreditCardPayments", x => x.Id);
    table.ForeignKey("FK_CreditCardPayments_Payments_Id", x => x.Id, "Payments", "Id",
        onDelete: ReferentialAction.Cascade);
});

// ... same for BankTransferPayments, WalletPayments

Adding a new payment type creates a new table. Existing tables are untouched. The FK constraint to the base table maintains referential integrity.

TPC Migration

// Each concrete type gets ALL columns
migrationBuilder.CreateTable(name: "CreditCardPayments", columns: table => new
{
    Id = table.Column<Guid>(),
    Amount = table.Column<decimal>(precision: 18, scale: 2),
    Currency = table.Column<string>(maxLength: 3),
    OrderId = table.Column<Guid>(),
    PaidAt = table.Column<DateTimeOffset>(),
    CardLastFour = table.Column<string>(maxLength: 4),
    CardBrand = table.Column<string>(maxLength: 20),
    AuthorizationCode = table.Column<string>(maxLength: 50, nullable: true),
});

// ... same structure for BankTransferPayments, WalletPayments (all include base columns)

Adding a new payment type creates a new table. Adding a property to the base type requires adding a column to every concrete table — the main downside of TPC.

Switching Strategies

Switching from TPH to TPT (or vice versa) requires a data migration — rows must be moved between tables. This is not trivial for production databases with millions of rows. Choose the strategy early and change it only when performance data demands it.

Entity.Dsl makes the code change trivial (one attribute). The migration is still the developer's responsibility.


Polymorphic Repository Usage

The generated IPaymentRepository works with the base type:

public class PaymentService
{
    private readonly IMarketplaceDbContextUnitOfWork _uow;

    public PaymentService(IMarketplaceDbContextUnitOfWork uow) => _uow = uow;

    // Get all payments for an order (polymorphic)
    public async Task<IReadOnlyList<Payment>> GetPaymentsForOrderAsync(Guid orderId)
        => await _uow.Payments.FindWhereAsync(p => p.OrderId == orderId);

    // Get only credit card payments
    public async Task<IReadOnlyList<CreditCardPayment>> GetCreditCardPaymentsAsync()
        => await _uow.Payments.Query
            .OfType<CreditCardPayment>()
            .ToListAsync();

    // Get total paid amount by payment type
    public async Task<Dictionary<string, decimal>> GetTotalByTypeAsync(Guid orderId)
    {
        var payments = await _uow.Payments.FindWhereAsync(p => p.OrderId == orderId);
        return payments
            .GroupBy(p => p.GetType().Name)
            .ToDictionary(g => g.Key, g => g.Sum(p => p.Amount));
    }

    // Process refund — works regardless of payment type
    public async Task<Payment> RefundAsync(Guid paymentId, decimal amount)
    {
        var original = await _uow.Payments.FindByIdAsync(paymentId);
        if (original == null)
            throw new InvalidOperationException("Payment not found");

        // Create a negative payment of the same type
        Payment refund = original switch
        {
            CreditCardPayment cc => new CreditCardPayment
            {
                Amount = -amount,
                Currency = cc.Currency,
                OrderId = cc.OrderId,
                CardLastFour = cc.CardLastFour,
                CardBrand = cc.CardBrand,
                PaidAt = DateTimeOffset.UtcNow
            },
            BankTransferPayment bt => new BankTransferPayment
            {
                Amount = -amount,
                Currency = bt.Currency,
                OrderId = bt.OrderId,
                IBAN = bt.IBAN,
                BIC = bt.BIC,
                TransferReference = $"REFUND-{bt.TransferReference}",
                PaidAt = DateTimeOffset.UtcNow
            },
            WalletPayment w => new WalletPayment
            {
                Amount = -amount,
                Currency = w.Currency,
                OrderId = w.OrderId,
                WalletProvider = w.WalletProvider,
                WalletTransactionId = $"REFUND-{w.WalletTransactionId}",
                PaidAt = DateTimeOffset.UtcNow
            },
            _ => throw new InvalidOperationException($"Unknown payment type: {original.GetType().Name}")
        };

        _uow.Payments.Add(refund);
        await _uow.SaveChangesAsync();
        return refund;
    }
}

The polymorphic repository and C# pattern matching work together seamlessly. The persistence strategy (TPH/TPT/TPC) is invisible to this code — it works identically regardless of which strategy is configured.


Summary

Entity.Dsl's inheritance support follows one principle: the domain model does not change when the persistence strategy changes. The same four C# classes — Payment, CreditCardPayment, BankTransferPayment, WalletPayment — map to any of the three strategies by changing one attribute value.

Strategy Attribute Generated Output
TPH InheritanceStrategy.TPH HasDiscriminator<string>().HasValue<T>()
TPT InheritanceStrategy.TPT UseTptMappingStrategy() + ToTable() per type
TPC InheritanceStrategy.TPC UseTpcMappingStrategy()

For our marketplace, we use TPH — three payment types, frequent polymorphic queries ("show all payments for this order"), and the nullable column trade-off is acceptable for three derived types.

In the next part, we explore what happens when the generator is not enough — customization hooks, escape hatches, and advanced patterns.