- Published on
EF Core 8 - Exploring Must-Know Key Features
12 min read- Authors
- Name
- Daniel Mackay
- @daniel_mackay
- Introduction
- New Features
- Complex Types
- Unmapped Queries
- Primitive Collections
- DateOnly & TimeOnly Support
- Enhanced Bulk Updates & Deletes
- JSON Column Enhancements
- Hierarchy IDs
- Sentinel Values
- Source Code
- Summary
- Resources
Introduction
EF Core 8 is the latest version of the Entity Framework Core ORM. It's a major release that brings with it a number of new features and performance enhancements. Now that EF Core has switched to an annual release cadence, we are now getting new ORM functionality at a much faster rate than ever.
In this post, we'll take a look at some of the key features that you should know about.
New Features
EF Core 8 includes 18 new features and enhancements. The full list can be found here. Let's take a look and what I think are the most important of these.
Complex Types
When modeling our entities in EF, we don't have to use always use flat data structures. We can use nested data structures to group similar properties together and help us reason about our data.
In EF Core 7, we used Owned Entities for this. These mostly worked, but had some limitations due to the use of entities under the hood.
In EF Core 8, we now have Complex Types, which serve a similar purpose, but do not use entities under the hood. They more closely match a typical 'Value Object', that you may see in the DDD world. This allows us to create more expressive models, that we can also attach behavior to.
Considering we have the following Contact
and Address
entities:
public class Contact
{
public int Id { get; init; }
public required string Name { get; init; }
public required Address Address { get; init; }
}
public class Address
{
public required string Street { get; init; }
public required string City { get; init; }
public required string PostCode { get; init; }
}
We can configure the Address
as a complex type in our DbContext
:
public class ApplicationDbContext : DbContext
{
public DbSet<Contact> Contacts { get; set; } = null!;
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<Contact>()
.ComplexProperty(c => c.Address);
}
}
The result in the DB is a single table with all the properties of the Address
type:
Use Cases:
- Using value objects in DDD
- Breaking up large entities into smaller, more logical chunks
- Avoids some of the limitations of Owned Entities:
- Can be either a value type or reference type
- Can be shared by properties across multiple entities
Unmapped Queries
Sometimes you might want to run queries against a DB you don't control, and you don't want the extra hassle of setting up a full ORM. Alternatively, you may be using CQRS and have a separate read and write model. You may also want to run some SQL that is not supported by EF Core (such as common table expressions, and window functions).
In these cases you can use EF Core to execute unmapped queries.
Consider the following UnmappedProduct
class:
public class UnmappedProduct
{
public string Name { get; set; } = null!;
}
We can then use this class to run a query against the DB:
using var db = new ApplicationDbContext();
var allProducts = db.Database
.SqlQuery<UnmappedProduct>($"SELECT Name FROM Products")
.ToList();
Use Cases:
- Simple query and command scenarios that don't require entities to be registered with EF Core
- Provide 'Dapper-like' functionality for simple queries and commands
- Increased performance for queries and commands that don't require entities
- Allows us to easily have separate read and write models
Primitive Collections
Primitive Collections allow us to store a list of primitive values in a column WITHOUT having to create a separate table. EF Core will automatically do this for any primitive collections on our entities. This doesn't require any special configuration.
Consider the following Product
and Color
classes:
public class Product
{
public int Id { get; private set; }
public required string Name { get; init; }
public List<Color> Colors { get; init; } = [];
}
public enum Color
{
Red = 1,
Green,
Blue
}
After inserting some data to the DB and inspecting the table, will see that EF Core has created a Colors
column as follows:
We can still use EF to query this data as we usually would.
Use Cases:
- Keep DB Schema clean without having to create unneeded tables for primitive collections
DateOnly & TimeOnly Support
In previous versions of EF Core if we wanted to store either a date or time the only option we had was to use DateTime
in our models. This left us with messy code that had to constantly ignore the date or time component of the DateTime
object.
EF Core 8.0 introduces support for the DateOnly
and TimeOnly
types. These are value types that represent a date or time without a time zone. They are useful for storing dates and times without the overhead of a full DateTime
object.
We don't need any extra configuration. EF Core will handle this out of the box:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = null!;
public bool IsDeleted { get; set; }
public DateOnly DateCreated { get; set; }
public TimeOnly TimeCreated { get; set; }
}
Use cases:
- Storing only dates
- Storing only time
Enhanced Bulk Updates & Deletes
Bulk updates & deletes were introduced In EF Core 7, and were a really nice addition to the library. However, there were some limitations. Now in EF Core 8, we can do updates across multiple structures (however, they still need to live in the same table). This allows us to use Owned Entities and Complex Types to have a nicely structured domain model, but still be able to do bulk updates.
Consider the following ApplicationDbContext
and entities:
public class Product
{
public int Id { get; private set; }
public required string Name { get; init; }
public required Color Color { get; init; }
}
public class Color
{
public ColorCode Code { get; init; }
public required string Name { get; init; }
public int NumInStock { get; init; }
}
public enum ColorCode
{
Red = 1,
Green,
Blue
}
public class ApplicationDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(DbConnectionFactory.Create("EnhancedBulkUpdateAndDelete"));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>().OwnsOne(p => p.Color);
}
}
Use cases:
- Bulk Update and Delete on Owned Entities and Complex Types
JSON Column Enhancements
Support for JSON columns was introduced in EF Core 7. You could query and update JSON columns. But there were some limitations. EF Core 8 adds some more advanced JSON capabilities. We can now query JSON collections of complex objects.
Consider the following Product
and Color
entities:
public class Product
{
public int Id { get; private set; }
public required string Name { get; init; }
public List<Color> Colors { get; init; } = [];
}
public class Color
{
public ColorCode Code { get; init; }
public required string Name { get; init; }
public int NumInStock { get; init; }
}
public enum ColorCode
{
Red = 1,
Green,
Blue
}
We can configure Product.Colors
as a JSON column in our ApplicationDbContext
:
public class ApplicationDbContext : DbContext
{
public DbSet<Product> Products => Set<Product>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(DbConnectionFactory.Create("EnhancedJsonColumns"));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Product>().OwnsMany(e => e.Colors, builder => builder.ToJson());
}
}
As Colors
is a list of complex objects, the data in our DB table will look as follows:
And we can query Colors
as we usually would with EF Core:
using var db = new ApplicationDbContext();
var blackProducts = db.Products
.AsNoTracking()
.SelectMany(p => p.Colors.Where(c => c.Code == ColorCode.Black))
.ToList();
Use cases:
- Use JSON columns to store complex objects/arrays
- Removes previous limitations of JSON queries
Hierarchy IDs
Hierarchy IDs are a special data type in SQL Server that allow you to store and query hierarchical data. This is a great way to store data that has a parent-child relationship, such as a file system, organizational chart, or product categories. The DB is then able to run queries such as finding all descendants of a node, or finding the common ancestor of two nodes.
This feature has been available in SQL server for a while, but EF Core 8 now allows us to use it in our models and help with the heavy lifting.
NOTE: You will need the CLR enabled on your SQL Server instance for this to work. The Azure SQL Edge Docker image does not support this.
Consider the following Employee
entity:
public class Employee
{
public int Id { get; private set; }
public required HierarchyId Path { get; init; }
public required string Name { get; init; }
}
We can seed the hierarchies as follows:
using var db = new ApplicationDbContext();
var employees = new List<Employee>
{
new() { Name = "CEO", Path = HierarchyId.Parse("/")},
new() { Name = "Product Manager", Path = HierarchyId.Parse("/1/")},
new() { Name = "Tech Lead", Path = HierarchyId.Parse("/1/1/")},
new() { Name = "Senior Dev", Path = HierarchyId.Parse("/1/1/1/")},
new() { Name = "Junior Dev", Path = HierarchyId.Parse("/1/1/2/")},
new() { Name = "Intern", Path = HierarchyId.Parse("/1/1/3/")},
};
db.Employees.AddRange(employees);
db.SaveChanges();
We can get team members under the Tech Lead:
var techLead = db.Employees.First(e => e.Path == HierarchyId.Parse("/1/1/"));
var techLeadSubordinates = db.Employees
.AsNoTracking()
.Where(e => e.Path.IsDescendantOf(techLead.Path))
.ToList();
We can get the managers above the Tech Lead:
IQueryable<Employee> FindAllAncestors(string name)
=> db.Employees.Where(
ancestor => db.Employees
.Single(
descendent =>
descendent.Name == name
&& ancestor.Id != descendent.Id)
.Path.IsDescendantOf(ancestor.Path))
.OrderByDescending(ancestor => ancestor.Path.GetLevel());
var techLeadManagers = FindAllAncestors("Tech Lead").ToList();
And we can ask questions about the hierarchy such as:
techLead.Path.IsDescendantOf(ceo.Path); // true
ceo.Path.IsDescendantOf(techLead.Path); // false
Use Cases:
- Store and query hierarchical (i.e. tree-like) data:
- An organizational structure
- A file system
- A set of tasks in a project
- A taxonomy of language terms
- A graph of links between Web pages
Sentinel Values
EF Core can configure SQL Server to use Database defaults. For this to work, EF needs to know when NOT to send a value to the DB so that the DB can use the default value. It does this by using the default
value of the .NET CLR type This works well for reference types, but not so well for value types.
In some cases the CLR default value is a value valid to insert. For example, when creating an account we may want to default credit property to 10
. This means that we're not able to create an account with the default CLR value of 0
, as EF Core will ignore this value and use the DB default of 10
.
This is where sentinel values come in. EF Core 8 allows us to configure a specific sentinel value for a CLR type. This gives us full control over when to use (and not use) the DB default value
There are two ways we can configure sentinel values.
The first is via EF Configuration:
public class Account
{
public int Id { get; private set; }
// use sentinel values is to use a sentinel value that's been configured in ApplicationDbContext
public int Credits { get; set; } = -1;
}
public class ApplicationDbContext : DbContext
{
public DbSet<Account> Accounts => Set<Account>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder
.UseSqlServer(DbConnectionFactory.Create("SentinelValues"));
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Account>().Property(a => a.Credits).HasDefaultValue(10).HasSentinel(-1);
}
}
The second is to use a nullable backing field. This works without any additional EF configuration:
public class Account
{
public int Id { get; private set; }
// use a nullable backing field
private int? _balance;
public int Balance
{
get => _balance ?? 100;
set => _balance = value;
}
}
Use Cases:
- Inserting rows with default CLR values when the DB has a default value
- Correct EF Core behavior when using boolean
default
andenum
default values - Overriding defaults for other value types such as
int
,DateTime
, etc.
Source Code
For the past few versions of EF Core, I've been building samples of EF Core that shows each feature in isolation. My goal is to show the simplest possible way to use a new feature. I hope you find the samples in the repo useful.
github.com/danielmackay/dotnet-ef-core-samples
Summary
This blog post delves into the latest updates in Entity Framework Core ORM (EF Core 8), emphasizing its new features and performance improvements. Key highlights include Complex Types for more expressive models, Unmapped Queries for running SQL without an ORM setup, Primitive Collections to store lists of primitives without extra tables, and support for DateOnly
and TimeOnly
types. EF Core 8 also enhances Bulk Updates & Deletes, offers advanced JSON Column capabilities, introduces Hierarchy IDs for efficient hierarchical data management, and implements Sentinel Values for better control over database defaults.
By learning about more of the lesser known features of EF Core, you can make better use of the ORM and use the right 'tool for the job'.