With the release of .NET 8, we also see the release of Entity Framework Core 8 and a bounty of new features. One of my favorite new features is Complex Types. When data modeling with Entity Framework Core, we may unnecessarily add tables to our database schema because “that’s just how database modeling works”. This can lead to table sprawl, decreased insert performance, and increased query times.

In this post, we’ll explore how to use Complex Types in Entity Framework Core 8 to reduce the number of tables in our schema, simplify inserts, and increase query performance.

What are Complex Types?

Entity Framework Core has long had the concept of “owned types”, which are properties dependent on their parent object. Depending on your data model, these pieces of information only make sense within the context of additional data. For example, in a hypothetical domain, a physical address may only make sense when related to a customer. Otherwise, it is an arbitrary piece of information. Let’s look at how you may model this.

public class Customer  
{  
    public int Id { get; set; }  
    public required string Name { get; set; }  
    public required Address Address { get; set; }  
}

As you may notice, the Address property is used within the Customer definition. Folks familiar with EF Core might assume that there would be an Addresses table, and in previous versions of EF Core, that would have been the case.

In EF Core 8, the modeling process lets us map an Address directly to columns within a Customers table.

create table main.Customers
(
    Id               INTEGER not null
        constraint PK_Customers
            primary key autoincrement,
    Name             TEXT    not null,
    Address_City     TEXT    not null,
    Address_Country  TEXT    not null,
    Address_Line1    TEXT    not null,
    Address_Line2    TEXT,
    Address_PostCode TEXT    not null
);

According to the initial release notes, Complex types have certain characteristics:

  1. Types are not identified or tracked by a key value.
  2. Must only exist as part of the entity and not directly have a DbSet
  3. Can be value or reference types (records or classes).
  4. Can share the same instance across multiple properties* (be careful).

The final bullet states that the same instance is only shared during the manipulation process of in-memory objects. Once the data is read back out, the newly tracked objects will be different instances. This is important because you may encounter unexpected issues if you’re operating under false assumptions.

So, how do you implement a complex type?

[ComplexType]
public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

You add a ComplexType attribute, of course. Again, note that this class does not have any key.

Saving Complex Types

Using a complex type is as you’d expect. Let’s take a look at a quick usage sample.

Database db = new();

// Complex Type storage of Address
var customer = new Customer
{
    Name = "Khalid Abuhakmeh",
    Address = new()
    {
        Line1 = "1 Fantasy Lane",
        City = "Los Angeles",
        Country = "USA",
        PostCode = "90210",
    }
};
db.Customers.Add(customer);
await db.SaveChangesAsync();

When we look at the Insert SQL statement, we can see a straightforward command.

 INSERT INTO "Customers" ("Name", "Address_City", "Address_Country", "Address_Line1", "Address_Line2", "Address_PostCode")
      VALUES (@p0, @p1, @p2, @p3, @p4, @p5)

That’s one less table needed for our insert statements. That’s great news! What about querying the same model?

Let’s perform the query db.Customers.FirstOrDefault() and see what we get.

SELECT "c"."Id", "c"."Name", "c"."Address_City", "c"."Address_Country", "c"."Address_Line1", "c"."Address_Line2", "c"."Address_PostCode"
FROM "Customers" AS "c"
LIMIT 1

Cool! Querying a single table is great news again.

We can even write LINQ queries like you’d expect from any previous DbContext.

var result = await db
    .Customers
    .Where(x => x.Id == customer.Id)
    .Select(x => x.Address)
    .FirstOrDefaultAsync();

The previous LINQ statement produces the following SQL.

SELECT "c"."Address_City", "c"."Address_Country", "c"."Address_Line1", "c"."Address_Line2", "c"."Address_PostCode"
  FROM "Customers" AS "c"
  WHERE "c"."Id" = @__customer_Id_0
  LIMIT 1

This is awesome.

A quick note about performance claims, it’s important to test any optimization and performance improvements in your own codebase. While generally less tables involved in a transaction are better, there are scenarios that can be less performant. For example, if you use Complex Types to create a monsterous 500+ column table, then you might want to reconsider your approach and it might be less performant than inserting into multiple tables.

Here’s the DbContext for completeness.

using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;

namespace EntityFrameworkCoreEight;

public class Database : DbContext
{
    public DbSet<Customer> Customers => Set<Customer>();
    
    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
        => optionsBuilder
            .UseSqlite("Data Source= database.db")
            .LogTo(Console.Write);
}

public class Customer
{
    public int Id { get; set; }
    public required string Name { get; set; }
    public required Address Address { get; set; }
}


[ComplexType]
public class Address
{
    public required string Line1 { get; set; }
    public string? Line2 { get; set; }
    public required string City { get; set; }
    public required string Country { get; set; }
    public required string PostCode { get; set; }
}

Conclusion

Complex types allow you to reduce table sprawl, increase insert performance, and speed up query times. There are many opportunities to review existing schemas and optimize your database. It’s still important to realize you might be dealing with reference objects, so be careful about how you assign and modify objects that may be shared. There are still some outstanding issues with Complex Types, but they are not critical show-stoppers. One of the issues is support for inheritance. It is planned for future versions, but I can live without it now. Despite the minor issues, this is an excellent addition to Entity Framework Core 8.

I hope you enjoyed this blog post, and as always, cheers.