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.

Figure 1: Data Model with Person mapped as an Owned Entity vs. a Complex Property
Figure 1: Data Model with Person mapped as an Owned Entity vs. a Complex Property

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

DeclarationTypeMutability
classReference typeMutable unless designed otherwise
recordReference typeImmutable
record structValue typeMutable unless designed otherwise
readonly record structValue typeImmutable

Table 2: EF Core 8 support for ComplexProperty and OwnedEntity mappings

ComplexPropertyOwned Entity
ClassYesYes
RecordYesWith side effects
Record StructYesNo (must be a ref type!)
Record with Primary CTORYesYes
Class with Primary CTORNoYes
CollectionsNo (EF Core 9)Yes (OwnsMany)
NestedNo (EF Core 9)Yes
Data Annotation availableYesYes
Store in its own tableNoYes
Map to JSON columnNo (EF Core 9?)Yes
Seeding via DbContext/MigrationsNoYes
Supported in Cosmos providerNoYes