Map a polymorphic owned value object — an interface or abstract base with a few concrete shapes and no identity of its own — to inline columns on the owner's table: a discriminator column plus the union of the subtypes' scalar members as nullable columns. This is the owned-type inheritance that EF Core does not support natively.
Targets EF Core 8, 9, and 10. Provider-agnostic core, validated against PostgreSQL (Npgsql) and SQLite.
The package is published to the RapidataAI GitHub Packages feed on merge to main and on
v* tags. Add the feed (a repo-local NuGet.config), then reference the package:
<!-- NuGet.config -->
<configuration>
<packageSources>
<add key="github-rapidata" value="https://nuget.pkg.github.com/RapidataAI/index.json" />
</packageSources>
<packageSourceCredentials>
<github-rapidata>
<add key="Username" value="x-access-token" />
<!-- A PAT (or Actions GITHUB_TOKEN) with read:packages -->
<add key="ClearTextPassword" value="%GITHUB_TOKEN%" />
</github-rapidata>
</packageSourceCredentials>
</configuration>dotnet add package PolymorphicOwned.EntityFrameworkCore
Inside RapidataAI CI the ambient GITHUB_TOKEN already carries read:packages, so no extra secret
is needed to restore.
EF Core supports inheritance for entities (TPH/TPT/TPC) but not for owned or complex types. So a value object like this:
public interface IDiscount;
public sealed class PercentageDiscount : IDiscount
{
public double Percentage { get; set; }
public double MaxAmount { get; set; }
public int MinItems { get; set; }
}
public sealed class FixedAmountDiscount : IDiscount
{
public double Amount { get; set; }
public double MinOrderTotal { get; set; }
public int MaxRedemptions { get; set; }
}
public sealed class Order
{
public int Id { get; set; }
public string Reference { get; set; } = "";
public IDiscount Discount { get; set; } = default!; // 1:1 owned, polymorphic
}…can today only be stored by:
- JSON — loses typed columns, indexing, and clean SQL filtering.
- Promote to an entity + TPH — adds an identity, a table, and a join for something that is conceptually a value.
- Hand-rolled — a
[NotMapped]accessor over manually-declared nullable columns and a discriminator, flattened and reconstructed by hand in every entity. This is what teams actually do, and it is rotting boilerplate.
This library automates option 3.
Configure the mapping in OnModelCreating:
modelBuilder.Entity<Order>()
.OwnsPolymorphic(o => o.Discount, poly =>
{
poly.HasDiscriminatorColumn("discount_type"); // default: "<prop>_type"
poly.HasDerivedType<PercentageDiscount>("percentage");
poly.HasDerivedType<FixedAmountDiscount>("fixed_amount");
// Column names default to convention (and cooperate with EFCore.NamingConventions).
// Override individually if you like:
poly.Property(d => ((PercentageDiscount)d).MaxAmount).HasColumnName("max_discount_amount");
});Register the interceptors on the context options:
optionsBuilder
.UseNpgsql(connectionString)
.UseSnakeCaseNamingConvention() // optional; snake_cases the shadow columns too
.UsePolymorphicOwned();That's it. Insert and read value objects as their concrete types:
db.Orders.Add(new Order
{
Reference = "ORD-1001",
Discount = new PercentageDiscount { Percentage = 15, MaxAmount = 50, MinItems = 3 },
});
await db.SaveChangesAsync();
var order = await db.Orders.SingleAsync(o => o.Reference == "ORD-1001");
if (order.Discount is PercentageDiscount percentage)
{
Console.WriteLine(percentage.Percentage); // 15
}The orders table ends up with exactly these columns — no separate table, no JSON
(max_amount is shown as max_discount_amount if you use the HasColumnName override above):
| id | reference | discount_type | percentage | max_amount | min_items | amount | min_order_total | max_redemptions |
|---|
dotnet ef migrations add emits every one of those columns with no hand-editing — they are
registered as shadow properties, so they appear in migrations and the model snapshot exactly like
hand-written columns.
| Call | Effect |
|---|---|
HasDerivedType<T>("value") |
Registers a concrete subtype and its discriminator value. Required (at least one). |
HasDiscriminatorColumn("name") |
Discriminator column name. Default: <property>_type. |
Property(r => ((T)r).Member).HasColumnName("col") |
Override the column for a subtype member. |
IsRequired(bool) / IsOptional() |
Whether the owner always has a value. Default: required. |
CollapseSharedMembers() |
Members that share a name and type across subtypes collapse to one column. |
Nullability. The owner is required by default (the discriminator column is NOT NULL); mark it
IsOptional() to allow a null value object. Subtype-specific columns are always nullable, since only
one subtype's columns are populated per row.
Shared members. By default a member that appears on more than one subtype maps to
subtype-qualified columns (PercentageDiscount_Foo, FixedAmountDiscount_Foo). Call
CollapseSharedMembers() to fold same-name-and-type members into a single column.
Activation. Subtypes are reconstructed via a constructor whose parameters match member names (records / immutable value objects), otherwise via a parameterless constructor and property setters. Get-only auto-properties are supported (written through the backing field).
Cast to a subtype and access a member, or use an is check, and it translates to SQL against the
underlying column — no magic strings:
// ((TSub)owner.Nav).Member -> the member's column
var bigPercentageOff = await db.Orders
.Where(o => ((PercentageDiscount)o.Discount).Percentage >= 10)
.OrderByDescending(o => ((PercentageDiscount)o.Discount).Percentage)
.ToListAsync();
// owner.Nav is TSub -> the discriminator column
var fixedDiscounts = await db.Orders
.Where(o => o.Discount is FixedAmountDiscount)
.ToListAsync();These become plain column predicates (rows of another subtype have a NULL in that column, so a
comparison is simply false — the cast never runs, so no InvalidCastException):
WHERE o.percentage >= 10 -- ((PercentageDiscount)o.Discount).Percentage >= 10
WHERE o.discount_type = 'fixed_amount' -- o.Discount is FixedAmountDiscountThe raw shadow properties are also addressable with EF.Property<T>(o, "Percentage") if you prefer
strings, but the cast form above is refactor-safe.
Just access the property in a projection — o.Discount — to get the reconstructed value object
without materializing the whole owner and without a hand-written EF.Property per member:
var items = await db.Orders
.Where(o => o.Discount is PercentageDiscount)
.Select(o => new OrderListItem
{
Id = o.Id,
Reference = o.Reference,
Discount = o.Discount, // reconstructed, typed
})
.ToListAsync();
if (items[0].Discount is PercentageDiscount p) { /* ... */ }The query interceptor (registered by UsePolymorphicOwned()) rewrites the o.Discount access to
select the discriminator + that mapping's flattened columns and reconstruct the concrete subtype in
the projection shaper — so the SQL is just those columns, the owner entity is neither selected in
full nor tracked, and the mapping knowledge stays in the library. The generated SQL for the Select
above is, e.g.:
SELECT o."Id", o."Reference", o.discount_type, o.percentage, o.max_amount, o.min_items,
o.amount, o.min_order_total, o.max_redemptions
FROM orders AS o
WHERE o.discount_type = 'percentage'Access the property only in the final Select projection (it reconstructs client-side, so it
can't appear inside a Where/OrderBy). For an optional owner with no value it yields null.
What translates to SQL is anything that maps onto the flattened columns: a subtype-member access
through a cast (((TSub)o.Nav).Member), an is TSub check, and EF.Property<T>. What does not is
running arbitrary logic on the reconstructed object inside the query — a method call on it, or a
member access without a cast (the base type has no members) — because the object only exists after
materialization. Access via the subtype cast (translated) or project the object and operate on it in
memory.
- Not a replacement for entity inheritance (TPH/TPT/TPC). Those are for entities with identity; this is for value objects without.
- Not a JSON mapper. If you want the value stored as a JSON document, use EF's built-in
ToJson()/ owned-JSON support instead. - Not (in v1) full LINQ translation of the polymorphic property — see the boundary above.
samples/PolymorphicOwned.Sample models exactly
Order { Discount: PercentageDiscount | FixedAmountDiscount } against PostgreSQL, including a
committed auto-generated migration.
# Start a Postgres (or point POSTGRES_CONNECTION at your own):
docker run -d --name poly-pg -e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=polymorphic_owned_sample -p 5432:5432 postgres:16-alpine
dotnet run --project samples/PolymorphicOwned.SampleOutput:
#1 ORD-1001 -> PercentageDiscount(15% up to 50, minItems=3)
#2 ORD-1002 -> FixedAmountDiscount(20 off over 100, maxRedemptions=1000)
Orders with a percentage discount >= 10%: ORD-1001
See docs/adr/0001-shadow-property-and-interceptor-approach.md for the architecture: why owned-type inheritance can't be done natively, and why shadow properties (model building) plus a materialization interceptor (read) and a save-changes interceptor (write) are the approach.
dotnet build
dotnet test # round-trip tests use Testcontainers (Docker required) for Postgres + SQLiteMIT — see LICENSE.