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).
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; } = "";
}[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");
}// 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
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
}[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");
}// 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
└── WalletTransactionIdPayments (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
└── WalletTransactionIdPros: 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[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 WalletPaymentGenerated Configuration (TPC)
// In PaymentConfigurationBase.g.cs
protected virtual void ConfigureInheritance(
Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<global::Marketplace.Domain.Payment> builder)
{
builder.UseTpcMappingStrategy();
}// 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
└── WalletTransactionIdCreditCardPayments (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
└── WalletTransactionIdPros: 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}"
};
}// 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();// 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);
}// 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");
});// 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// 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, WalletPaymentsAdding 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)// 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;
}
}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.