EF Core 8 was released late in 2023 and, if you haven't kept up, there are some important and interesting things to be aware of. There were over 100 tweaks and additions and another 125 bug fixes. I'll be highlighting those that are most important and a few that piqued my interest or just my curiosity. If you want to explore all of the enhancements and new features on GitHub, here's a link for those issues: https://bit.ly/EFCore8Features. The list of issues for the fixes can be perused at https://bit.ly/EFCore8Fixes.
If you've followed my tech wanderings over the years, it may be no surprise that my A#1 favorite new feature is the ComplexProperty mapping, an alternative to using Owned Entities to map complex types and value objects. The new ComplexProperty mapping provides a far superior way to map complex types (and therefore, value objects) than the Owned Entity mapping we've been using since the beginning of EF Core. To be clear, there are still some scenarios that are not yet supported, so you may end up using a mix of the two mappings until ComplexProperty is complete. It's the team's intention for this to eventually replace Owned Entities in their entirety. ComplexProperty is a big deal and it was a big deal for the team to execute. It's at the top of their “what's new” lists as well.
Although the OwnsOne and OwnsMany mappings have fulfilled the basic need to map classes that are used as properties of entities, the work that they were doing under the covers was complicated and led to numerous side effects. The team had tweaked the inner logic a number of times across versions, creating breaking changes along the way, but never really solved the problem properly. They have been contemplating a replacement for some time and have finally pulled it off.
There are some caveats, however, which are a few capabilities that didn't make it into EF Core 8 but will be ready for EF Core 9. In those cases, we just continue using the owned entity mappings. I'll explain the caveats after I allow you to feast your eyes on the new ComplexProperty mapping.
TLDR Complex Types and Value Objects
Let's be sure we're all on the same page. A complex type is a class that doesn't have any identity and is used as a property of another class. An easy example is if you have a first name property and a last name property in a Customer
class.
public class Customer
{
public int CustomerId {get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public DateOnly FirstPurchase { get; set; }
}
Instead of using two string types for every class that needs a person's name, you can create a new class that only has those two strings.
public class Customer
{
public int CustomerId { get; set; }
public PersonName Name { get; set; }
public DateOnly FirstPurchase { get; set; }
}
public class PersonName
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
PersonName
is a complex type and can be used as a property of any other class, such as this ShipLabel
class, which is obviously missing an address but that's only to keep this explanation simple.
public class ShipLabel
{
public int Id { get; set; }
pubic DateOnly Printed {get; set;}
public PersonName Name { get; set; }
}
The most important attribute of PersonName
is that it has no identity.
A value object is a critical Domain-Driven Design construct that enhances a complex type by ensuring that it's immutable and that its equality is always based on the values of every property in the type by overriding the Equals
and GetHashCode
methods. Listing 1 shows a version of PersonName
that's defined as a value object.
Listing 1: PersonName class implemented as a value object
public class PersonName
{
public PersonName(string firstName, string lastName)
{
FirstName = firstName;
LastName = lastName;
}
public string FirstName { get; init; }
public string LastName { get; init; }
public override bool Equals(object? obj)
{
return obj is PersonName name &&
FirstName == name.FirstName &&
LastName == name.LastName;
}
public override int GetHashCode ()
{
return HashCode.Combine(FirstName, LastName);
}
}
Whether PersonName
is a simple complex type or a value object, EF Core will see that type in its model when it's used as a property of another entity, such as Customer
. However, it will balk because it can only assume that it's another entity but can't figure out what to do with it because it has no key property. This results in a runtime exception when EF Core is trying to work out the data model.
The Problem(s) with Owned Entities
Owned entities originally came into EF Core to enable this mapping. They were a new paradigm for handling complex types, different from EF6 and earlier. By mapping the Name
property of Customer
as an owned entity (using the OwnsOne mapping), EF Core knows that it's okay that there's no key. However, in order to track and persist it, EF Core, under the covers, treats that PersonName
object as an entity in a relationship with Customer
. It does so by using a shadow property to infer a key in memory but ensures that the values are stored as individual fields in the Customers table in the database. That is the default behavior. You can configure the mapping to store the values in a separate database table.
It was a very clever solution that leveraged the existing behavior of EF Core. However, because of the complexity of faking the key, there were problematic side effects. For example, EF Core couldn't comprehend if you left the property null
or needed to edit it. Some of those problems were resolved but there are others still. For example, you can't copy an owned object from one entity instance to multiple other classes. Listing 2 shows logic that attempts to create two separate shipping label objects for one person. It will fail.
Listing 2: Retrieving a Customer and Using its Name for a new Label
var storedCustomer = ctx.Customers.First();
var label = new ShipLabel
{
Name = storedCustomer.Name,
};
var label2 = new ShipLabel
{
Name = storedCustomer.Name,
};
ctx.AddRange(label, label2);
ctx.SaveChanges();
Why? When this code assigns a person.Name
to the second label, EF Core moves it from the label it was already assigned to. The first label will no longer have a Name
- the property is now null. In the docs, the EF Core team explains other common use cases that will fail as well. Keep in mind that in unit tests that don't involve EF Core, the code poses no problem and is sensible. It's EF Core's tracking that fails. And as I said earlier, the team tried variations on how to handle these types of problems across versions of EF Core, all the while pondering how to implement a better mapping, rather than continuing to try to make owned entities work across the various needed scenarios.
Hello, or Welcome Back, Complex Properties
Entity Framework, and I mean pre-EF Core, had a concept of complex property mappings that were more natural than owned entities. EF Core 8 harkens back to that concept, although with a different implementation. EF Core still won't make an assumption that a complex type is anything but a malformed entity that needs a key property. But we have a new way to map it with the ComplexProperty mapping.
Whereas previously you'd have used the OwnsOne
method for the owned property, now you use the ComplexProperty
method.
override protected void OnModelCreating(ModelBuilder modelBuilder)
{
//modelBuilder.Entity<Customer>().OwnsOne(c => c.Name);
modelBuilder.Entity<Customer>().ComplexProperty(c => c.Name);
}
Like OwnsOne
, ComplexProperty
has its own methods where you can further define the property, for example, tying it to a backing field or specifying that it's required.
But what's most important is that EF Core just treats this as a property and when storing it, explodes the FirstName
and LastName
properties out to fields in the Customer's table (by default). It doesn't set up a fake relationship or fake key and then have to tangle with those every time you track, save, or retrieve data. Because it's not being treated as a separate entity, you can also share it among instances as needed. The logic in Listing 2 will succeed.
It's interesting to compare the visualizations of the model as well (using the wonderful EF Core Power Tool's diagram tool) shown in Figure 1. As an Owned Entity, the DbContext
sees the PersonName
as its own entity in a one-to-one relationship with Customer
. Notice that there's even a CustomerId
shadow property. As a ComplexProperty
, EF Core comprehends that it's just another property of Customer
.
In addition to noting the differences between the model in Figure 1, it's also interesting to see the DebugView
for the Customer
and ShipLabel
entities as they're seen by the change tracker prior to calling SaveChanges
in Listing 2.
First is the view for the OwnsOne
mapping, and there's so much going on here that I'm only showing the ShortView
.
Customer {CustomerId: 1} Unchanged
Customer.Name#PersonName {CustomerId: 1}
Unchanged FK {CustomerId: 1}
ShipLabel {Id: -2147482647} Added
ShipLabel {Id: -2147482646} Added
ShipLabel.Name#PersonName
{ShipLabelId: -2147482646} Added FK
{ShipLabelId: -2147482646}
With the owned entity mapping, the Customer's Name and the Name of only one of the ShipLabels
(remember, EF Core moved it, not copied it), are tracked separately and it's a bit convoluted.
Listing 3 shows the DebugView
when PersonName
is mapped as a ComplexProperty
: This time, I'm sharing the LongView
with more details because it's so easy to read. The details look just as you would expect. And EF Core is doing a lot less work to manage the PersonName
data.
Listing 3: DebugView (LongView) with Name mapped as a ComplexProperty in Customer and ShipLabel
Customer {CustomerId: 1} Unchanged
CustomerId: 1 PK
FirstPurchase: '1/1/2021'
Name (Complex: PersonName)
FirstName: 'John'
LastName: 'Doe'
ShipLabel {Id: -2147482647} Added
Id: -2147482647 PK Temporary
Name (Complex: PersonName)
FirstName: 'John'
LastName: 'Doe'
ShipLabel {Id: -2147482646} Added
Id: -2147482646 PK Temporary
Name (Complex: PersonName)
FirstName: 'John'
LastName: 'Doe'
It's much simpler and the side effects of the fake entities just disappear.
Classes, Records, and Value Objects, Oh My!
The PersonName
value object shown in Listing 1 includes a primary constructor to make it simpler to instantiate.
This leads us to the first caveat of ComplexProperty
. In EF Core 8, it won't work with a class that has a primary constructor - emphasis on class. If you want to map this class with ComplexProperty
, you need to remove that constructor and use an object initializer to instantiate a new PersonName
like this:
new PersonName{FirstName ="John",LastName="Doe"}
Otherwise, you'll need to go back to mapping with OwnsOne
.
What about records instead of a class? I recall first seeing the exploration that the C# team was doing on records at an MVP summit quite a few years ago. Because of how they simplified creating value objects, I was definitely eager to see them come into the language. Record types internalize equality comparison so you don't need to override the Equals
or GetHasCode
methods every single time.
However, records did not play very well with owned entities and again, there were side effects to worry about. Therefore, I never used records until EF Core 8 brought us the ComplexProperty
mapping and I had a bit of catching up to do to learn about records because there are many ways to express them.
A Quick Records Overview
Records have a number of formats. I spent a lot of time understanding the various ways to express a record to choose the correct flavor. The documentation was very helpful (https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record), but it still took a few read throughs for me. I have encapsulated some of the important details in Table 1 for a quick reference.
To begin with, a record, by default, is a reference type. But a record struct is a value type - the correct choice for a value object. There are more decisions to make.
You can define a record struct as a positional record, which has nothing more than a primary constructor and looks like this:
public record struct PersonName (string FirstName, string LastName);
That's the entire implementation! Internally, C# infers the FirstName
and LastName
string properties.
Currently my PersonName
record has no logic and is a good candidate for a positional record. If you have no need for logic or further constraints in the object, the streamlined positional syntax is awesome.
But - and this is a big but - on its own, a record struct is not, I repeat, not, immutable. Therefore, it fails the requirement of a value object. Luckily, C# 12 added the capability to make a record struct read only.
public readonly record struct PersonName (string FirstName, string LastName);
That's a very succinctly expressed and simple value object.
If you do need additional logic, you can express the record more like a class with properties and other logic explicitly defined, as I'm doing here, using init accessors to ensure that it's still immutable. This is an example of a read-only record struct without positional properties.
public readonly record struct PersonName
{
public FirstName X { get; init; }
public LastName Y { get; init; }
public string FullName => $"{FirstName} {LastName}";
}
There's an interesting capability of records that you should consider, which is that it's possible to create a new instance of a record with new values. This feature uses a with expression to replace property values.
For example, I might have instantiated a PersonName
using:
var jazzgreat=new PersonName("Ella", "Fitzgeralde");
Then I discover the typo of the “e” at the end of her name. Of course, with only two properties, I could easily create a new instance from scratch. But if you have a lot of properties, you could use the with expression syntax:
jazzgreat=jazzgreat with {LastName = "Fitzgerald"};
There are a lot of other nuances of records that you can learn about in the docs at https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/record.
Because I found it confusing to sort out all of the behaviors of the various flavors of record types, I've listed the critical aspects of each (as well as class for comparison) in Table 1.
Null Value Object Properties
The most important caveat about EF Core 8's implementation of ComplexProperty
, which is a deal breaker for some, is that it doesn't currently support null
objects. We saw this same problem with owned entities in an earlier rendition, but owned entities now support null
properties.
As an example, let's say I made PersonName
nullable in the customer type. Perhaps this isn't a logical change, but it serves my demonstration purpose.
public class Customer
{
public int CustomerId { get; set; }
public PersonName? Name { get; set; }
public DateOnly FirstPurchase { get; set; }
}
Mapped as an OwnedEntity
, EF Core can sort this out. The default mapping results in Name_FirstName
and Name_LastName
columns in the Customers table both being nullable.
The OwnedEntity
mapping comprehends the null
property when I create and store a customer without a name.
var customer=new Customer
{
FirstPurchase=new DateOnly(2021, 1, 1)
};
ctx.Customers.Add(customer);
ctx.SaveChanges();
When Name
is null, the Name_FirstName
and Name_LastName
database fields are both null as well. When I retrieve the customer, EF Core returns a Customer
with a null Name
property.
At some point, ComplexProperty
will have the same behavior. But currently (in EF Core 8), specifying Name
as a nullable type results in a runtime exception. The exception you get is dependent on how the value object is defined.
If the value object is a class, you get a message about the fact that it can't be optional when EF Core is attempting to build the data model based on the DbContext
mappings.
System.InvalidOperationException: 'Configuring the complex property
'Customer.Name' as optional is not supported, call 'IsRequired()'. See
https://github.com/dotnet/efcore/issues/31376 for more information.'
Making it required just so EF Core is happy is not a pleasing solution. It should only be required if your domain invariants specify that Name
should be required. If it's required, you can use ComplexProperty
. If not, you're stuck with OwnsOne
.
If PersonName
is a record struct (with or without positional properties), you'll trigger a different exception. EF Core configures the database fields from the value object properties (Customer_FirstName
, and Customer_LastName
) as non-nullable fields. At runtime, the database will throw an exception saying that it can't insert a null
value into a non-nullable column.
Note: If you look into the GitHub issue referenced in the exception message, please add your vote to make sure the EF Core team addresses this. I do expect it to be supported in EF Core 9 but every vote from the community helps them prioritize.
What Else Is and Isn't Supported?
Nullability of complex types, whether or not they are value objects, is an important topic, indeed. But there are a few other points to be aware of: some limitations as well as things that are supported. Let's start with the good news about primary constructors.
Records and Record Structs with Primary Constructors
I said above that you can't map a class with primary constructors using ComplexProperty. Well, happily, it works with the records! You can map a ComplexProperty with the positional records (which are declared in their entirety by a primary constructor) and non-positional records that have a primary constructor. I also successfully tested this with positional record structs, positional read-only record structs, and non-positional record structs. I definitely prefer primary constructors over expression builders to instantiate an object.
JSON Support, or Lack Thereof, for Now
Although JSON support has been improving in EF Core, some of it thanks to Owned Entities, it does not exist yet for ComplexProperty.
For example, if PersonName
is mapped as an owned entity, you can append the ToJson()
method to the OwnsOne
mapping resulting in the object being stored in some type of char field in your relational database table. A PersonName
is stored as
{"FirstName ":"John","LastName":"Doe"}
ComplexProperty does not yet support this capability. Additionally, its inability to transform complex types to JSON also means that you cannot use ComplexProperty with the CosmosDb provider that stores all its data as JSON. Not yet. This is another feature that is tagged in the GitHub repo as “consider for current release,” so hopefully that means EF Core 9.
Collections of Complex Types: Coming Soon
Owned Entity not only provides the OwnsOne mapping, but also OwnsMany. Therefore, it's possible to have a property in your entity that's a collection of the owned types. ComplexProperty doesn't yet support this, but the team has said it will be in EF Core 9. Keep in mind that value object collections are a disputed topic. Some call them an anti-pattern. But I've found some edge cases where they are quite useful. In fact, I have a collection of Author
value objects in my EF Core and Domain-Driven Design course on Pluralsight. And even though I'm using EF Core 8 in the course, I still had to map that particular value object as an owned entity. Happily (and intentionally), the sample application in that course has another value object that provided a great example of using a record and ComplexProperty mapping.
Nested Complex Types: Also Coming Soon
That Pluralsight course also demonstrates nesting value objects. The Author
value object has a PersonName
property similar to the one I've been using in this article. Because Author
is already mapped as an owned entity, I had to tack on its PersonName
property as an owned entity as well. You definitely can't combine Owned Entities and ComplexProperty when nesting.
More importantly, even if Author
was a ComplexProperty, nesting is not yet supported in EF Core 8. In the end, not only was I forced to use Owned Entity mappings for those two value objects, because they were owned entities, I had to declare them as classes, not records.
Is ComplexProperty Ready for Your Software?
I'm very happy to see and use ComplexProperty. Although it's not perfect yet, it already does solve many scenarios. The question becomes (for some): Should you mix and match the two mappings? I think the answer is yes. I don't think it needs to be seen as a maintenance problem. What ComplexProperty currently solves, it does very well - and does so better than Owned Entity. For the cases that you still need to use Owned Entity, continue to use them. But keep an eye on those cases because you'll be able to replace more (or all?) of those mappings when EF Core 9 comes out.
Table 2 provides you with a list of possible ways to define complex types and value objects and whether or not that expression is supported with ComplexProperty and Owned Entity mappings in EF Core 8. The EF Core team absolutely plans for a near future when we can completely eliminate OwnsOne and OwnsMany from our code. Until then, take advantage of the tool that works best for each scenario. And test, test, test.
The limitations are listed in the docs at this link (https://learn.microsoft.com/en-us/ef/core/what-is-new/ef-core-8.0/whatsnew#current-limitations). Each has a link to the relevant issue on GitHub and you can let the team know which are important to you by voting for these issues in GitHub.
Table 1: How classes and records are interpreted
Declaration | Type | Mutability |
---|---|---|
class | Reference type | Mutable unless designed otherwise |
record | Reference type | Immutable |
record struct | Value type | Mutable unless designed otherwise |
readonly record struct | Value type | Immutable |
Table 2: EF Core 8 support for ComplexProperty and OwnedEntity mappings
ComplexProperty | Owned Entity | |
---|---|---|
Class | Yes | Yes |
Record | Yes | With side effects |
Record Struct | Yes | No (must be a ref type!) |
Record with Primary CTOR | Yes | Yes |
Class with Primary CTOR | No | Yes |
Collections | No (EF Core 9) | Yes (OwnsMany) |
Nested | No (EF Core 9) | Yes |
Data Annotation available | Yes | Yes |
Store in its own table | No | Yes |
Map to JSON column | No (EF Core 9?) | Yes |
Seeding via DbContext/Migrations | No | Yes |
Supported in Cosmos provider | No | Yes |