EF Core can persist discriminated unions (replacing C# union types) by mapping them to the same table as their containing entity using table-per-hierarchy strategy, eliminating the need for JOINs on read. This is achieved by configuring the discriminated union as a non-nullable entity with a discriminator column, where the containing entity's ID serves as the identity for the union value, allowing EF Core to handle all conversions automatically without manual data model mapping.
Deep Dive
Prerequisite Knowledge
- No data available.
Where to go next
- No data available.
Deep Dive
Discriminated Unions Over Union Types: The EF Core Viewpoint
Added:Union types are coming to C# 15, but EF Core is not announcing support for them.
So, the question remains open about how we will persist union types.
In the previous video, I have shown you a workaround that lets you store a union type within the same table as its containing entity. In this video, however, I will show you bare EF Core, and how it can accomplish the same goal with a tweak.
I will turn this structure into a discriminated union, and then EF Core will do the rest for me.
Here is the query we will have in the end. Despite what you may find on the Internet, EF Core supports storing polymorphic records into the same table with the entity, in table-per-hierarchy style. But there is a long way to walk before we can accomplish this goal, so let's return to code. This is where our journey begins.
This union type models a four-eyes approval process in financial transactions.
A transaction may not require an approval, or may be in the pending state, awaiting two employees to approve it. It might be approved by one employee, waiting for another, or fully approved by two. Additionally, the transaction might be rejected, and it is sufficient to have one employee doing so.
This is the Transfer entity. It holds the two account identifiers, and the approval state. But there is a twist.
This property is the domain model, but it is currently ignored in EF Core settings.
That is so, because union types are value types in C#, and EF Core cannot track them, nor treat them as entities. The private property is what gets persisted using EF Core. You can watch the previous video to get the full picture of how you can persist proper union types using EF Core today.
In this video, I will show you how you can set up EF Core to accomplish all this without manual conversion of types. The first step towards this goal is to ditch union types. The syntax is beautifully compact, and it comes with powerful syntactic support. You can watch my other video solely discussing union types. But EF Core is just not ready for them.
I will rely on the good-old hierarchy of records. So, there will be the abstract base record, carrying no properties of its own. The five concrete cases remain exactly as they were, but now they will need to inherit from this base type.
Note that each subtype carries specific properties, so it is the combination of the runtime type and the data that gives power and flexibility to this structure.
We call this a discriminated union, because the runtime type discriminates individual values.
That is where this video begins, as we now need to figure out how to configure EF Core to persist and reconstruct values belonging to that discriminated union.
Most notably, I don't want to see any manual mappings into an explicit data model in my code.
It must entirely fall onto EF Core's back. That is what it's advertising, isn't it?
The goal of this exercise is to make sure that both my code and EF Core use the domain model property. That brings us to the DbContext, where we will invest some time into mapping the types and properties.
The Approval property is currently being ignored. There are no mappings for it.
That will change, of course, but how exactly? How do we map a hierarchy of records into the same table as the containing entity? Well, that is two questions.
First, mapping a hierarchy of records, second, mapping it into the same table as the containing entity. So, by ignoring the requirement that everything must fit into a single table, we can start treating the FourEyesApproval hierarchy as an entity. That will give us the opportunity to save it using the table-per-hierarchy strategy, which is exceptionally well adapted to small hierarchies such as discriminated unions. A discriminated union must have a discriminator.
I'm adding a column called ApprovalType for the purpose.
Each concrete type in a hierarchy gets its own discriminator value, named after the type itself.
Be aware that using the nameof operator to calculate the discriminator will be a mistake here. Class names may change in code, but the older data will remain in the database, unless you are prepared to update all the data in lockstep with renaming classes. You better put hardcoded strings here.
But this is where all the beauty ends. The FourEyesApproval records will go into their own table, and hence they must have an ID. I'm adding a shadow property to at least protect the records from having this property defined explicitly.
But this entire idea makes no sense. A discriminated union acts like a value in code, but now I'm mapping it in EF Core, like a full-fledged entity, that simply doesn't look right. Spoiler alert, this identity will disappear later, when we finally move the discriminated union into the Transfers table, precisely where it belongs.
Then comes the worst part, the wiring up. For every transfer, there will be one approval row. But now comes the surprise.
You will need half of a PhD to figure this out. For every approval row, there can be many transfer rows referencing it. You will need to wait for one minute longer until the whole culprit unfolds behind this line. The rest is straightforward.
There must be a foreign key, now that we are treating the discriminated union as an entity, but it will also be a shadow property, and the deletion will cascade.
The only thing that's good here is how I've configured the discriminated union.
Everything else will go before this video ends. This is the migration EF Core generated.
It adds the foreign key column to the transfers table, referencing the approvals table, with cascade delete, exactly as configured. But notice how the FourEyesApproval table has been configured. There's a column in it for each of the properties declared in the subtypes. That is how EF Core configures table-per-hierarchy. The discriminator column defines how these other columns will be used to reconstruct an actual instance on read.
That is the beauty of EF Core doing its magic with polymorphic types, including discriminated unions.
Are you ready to see the demonstration? Here it is.
The first part of trying out what we have developed is the insert.
I'm creating two transfers, one pending the approval, and another being approved by a single employee. After saving changes, EF Core will see them translated to database rows and fields. That will work perfectly.
But a more interesting part of the demo is below. We will now query the database, and then update the approval of an existing transfer. Querying first, we start from the transfers DbSet, but then right away we need to include the approval.
Without eager loading, the property would remain uninitialized in the transfer object.
But then comes the most beautiful part of all this endeavor.
I'm able to query the transfer object by the runtime type of its contained approval object.
EF Core will translate the is operator into a filter on the discriminator column in the database, but we don't have to worry about that. This is precisely why we are doing all this, to be able to work on domain types all the way, and completely ignore the database.
And then comes the effective update. I'm replacing the approval, constructing a fully approved record before saving changes to the database.
My hopes are with EF Core to carry this out, but unfortunately, that will not go easy.
The trouble is that the old approval from this transfer has now remained orphaned in the approvals table. No transfer will reference it anymore after the update. We must delete that record, but strictly after inserting the new one and updating the transfer to reference that row.
This maneuver explains why I had to set up the relationship between transfers and approvals as one-to-many. And doing this cleanup here is a part of what is dirty in this entire idea. But don't walk away.
I plan to delete these added lines sooner than you may think.
Nevertheless, I will run the application as it is right now.
You can witness that the insertion phase has indeed created two transfers, and that the update demo has successfully fetched the partly approved transfer and added the second approver to it.
So far, everything works as advertised. But it is far from perfect.
This is the query that will fetch the transfer data.
The most prominent feature of this query is that it contains an inner JOIN.
The data are there and they look right. Look at the discriminator column.
EF Core did its magic to make two-way data conversions just right, but if I ended the demo in its present state, it would tell that you must use JOINs to fetch every discriminated union stored in the database. That is just unacceptable.
The problem is in how I have configured the mapping.
That is the second question from the beginning of this video.
Can we store the table-per-hierarchy mapping into the same database table where the parent entity is? And the answer is, yes, we can.
I will show you how you can do that in EF Core, and with that, this demo will be complete.
But before that, let me steal a moment of your time to remind you that this video, and all other videos on this channel, are free, but only thanks to the patrons, the people who are sponsoring this channel. If you like what you have learned from this video so far, then please consider joining the growing community of patrons on my Patreon page.
By becoming a patron, you will get access to the source code from this video and all other videos on my channel, as well as some discounts I occasionally post on Patreon.
Thank you very much for your support. Now let's get back to the final task of fixing EF Core configuration. The change will be very simple in the end.
First off, the approval will not have its own ID. It acts like a value.
There's no argumentation in favor of it having an ID.
So, I remove the shadow property. Then comes the critical part, the table-per-hierarchy structure will go into the same table where transfer data are.
That one line is the entire fix, and it also answers the question of identity.
The existing Id column from the table will also identify the approval record.
That is the ID of the transfer, you see? Yet, every approval instance is uniquely identified by the transfer it belongs to. Now, this entire mapping begins to make sense.
The rest is just formality. The relationship between transfers and approvals will be one-to-one, just as one would expect. The foreign key on the approval is the actual ID of the row it belongs to, and I will also instruct EF Core to make sure the approval is not nullable.
A missing approval wouldn't make sense. That is it.
This is the entire mapping of a discriminated union into a containing entity's table, mapped in table-per-hierarchy style. I have removed a prior migration and added a new one, so you can see it here. It is straightforward, with no surprises.
It is just a bunch of new columns added to the Transfers table, beginning with the discriminator column. The rest is just data columns, storing the approvers and the rejector. This is proper table-per-hierarchy imposed upon the table where the containing entity is stored. Can you believe it?
By the way, if you asked AI to do this, it would readily tell you that's not possible.
Claude doesn't know this. The last remaining detail is to fix the consuming code. That will be easy, because I will mostly be deleting stuff. Including the approvals will remain, because approvals are still being treated as entities under the hood.
But deleting the old approval is now out of the question.
I'm removing these two lines. They were nothing but noise.
The trick to understanding this is in seeing that writing the new approval object will overwrite the data of the previous one. EF Core handles the update as a plain write to a single database row. Needless to say, the externally visible behavior of the application will remain the same. All the output you can see here is the same as it was in the previous run. But the difference is there, and you will observe it easily if you try to query the database. There will be no JOIN needed to fetch a discriminated union. All the values now come from the Transfers table alone. That finally makes the full circle.
I have successfully mapped the discriminated union in the EF Core, so that it saves it with the table-per-hierarchy strategy, but I have also accomplished another goal, which is important when trying to preserve performance. I have configured EF Core to map this discriminated union right into the table that holds the parent entity.
If you follow the steps shown in this video, you will be able to get the best of both worlds, to use records as values, and also to retain the maximum performance of your relational store.
Related Videos

TOP 15 Data compression Interview Questions and Answers 2019 Part-2 | Data compression | Wisdom jobs
wisdomjobs
281 views•2019-06-28

CTS 158: 802.11w Management Frame Protection
ClearToSend
4K views•2019-02-04

NDSS 2019 Send Hardest Problems My Way: Probabilistic Path Prioritization for Hybrid Fuzzing
NDSSSymposium
496 views•2019-04-02

How realistic is Cities: Skylines?
CityBeautiful
159K views•2019-02-14

GUIs & TUIs: Choosing a User Interface for Your Python Project | Real Python Podcast
realpython
2K views•2025-04-04

The OSI Model - Explained by Example
hnasr
225K views•2019-05-12

Cloud Computing - Introduction
elithecomputerguy
98K views•2019-10-07

From Traveler's Dilemma to Dynamic Routing | Demystifying Networking
IITBombayJuly
5K views•2019-08-04
Trending

YouTube Disabled Our Comments Again (Are Any Humans Left at YouTube?)
SpecialBooksbySpecialKids
39K views•2026-07-21

One Must Imagine Sisyphus Happy
vlogbrothers
61K views•2026-07-21

The Downfall of OnePlus!
techwiser
65K views•2026-07-21

The REAL History Behind The Odyssey Will BLOW Your Mind! It's NOT a Myth!
metatronyt
20K views•2026-07-21