Entity Framework Core: Advanced Topics
Dive deeper into Entity Framework Core (EF Core) with these advanced topics. Explore techniques to optimize performance, handle complex scenarios, and leverage the full power of EF Core in your .NET applications.
Key Advanced Concepts
Change Tracking Explained
EF Core's change tracking mechanism is fundamental to how it detects and persists changes made to entities. Understanding how EF Core tracks entity states (Added, Modified, Deleted, Unchanged, Detached) and property values is crucial for efficient data operations.
The DbContext
instance maintains a cache of entities it's tracking. When you query entities, they are automatically added to the change tracker. Modifications to entity properties are recorded. When SaveChanges()
is called, EF Core inspects the change tracker to generate the appropriate SQL commands.
// Example: Detecting changes
var product = await _context.Products.FindAsync(1);
product.Price = 15.99M;
// EF Core automatically marks the entity as Modified.
var entry = _context.Entry(product);
Console.WriteLine($"Entity State: {entry.State}"); // Outputs: Modified
Console.WriteLine($"Original Price: {entry.OriginalValues["Price"]}"); // Outputs: Original price
You can also manually manage the state of entities using methods like _context.Entry(entity).State = EntityState.Deleted;
.
Concurrency Control
Concurrency occurs when multiple users or processes attempt to modify the same data simultaneously. EF Core provides strategies to handle these conflicts gracefully.
- Optimistic Concurrency: This is the most common approach. It assumes conflicts are rare. EF Core detects conflicts by checking a versioning column (e.g., a rowversion or timestamp) or by comparing all original values of the entity. If a conflict is detected during
SaveChanges()
, anDbUpdateConcurrencyException
is thrown. - Pessimistic Concurrency: This involves locking the data to prevent other users from modifying it while one user is working on it. EF Core supports this via database-specific mechanisms like
SELECT ... FOR UPDATE
.
To implement optimistic concurrency, mark a property in your entity with the [Timestamp]
attribute (for SQL Server) or configure a row version property in OnModelCreating
.
// Entity with Timestamp for optimistic concurrency
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public byte[] RowVersion { get; set; } // For optimistic concurrency
}
// Handling DbUpdateConcurrencyException
try
{
await _context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
// Handle the concurrency conflict, e.g., reload the entity,
// prompt the user, or merge changes.
var entityEntries = ex.Entries;
foreach (var entry in entityEntries)
{
var databaseValues = await entry.GetDatabaseValuesAsync();
if (databaseValues == null)
{
Console.WriteLine("The entity being updated has been deleted by another user.");
return;
}
var proposedValues = entry.CurrentValues;
var originalValues = entry.OriginalValues;
// Refresh original values and client values from the database
entry.Reload();
// You might want to show the user the conflicting values
// and let them decide how to resolve it.
}
// Retry saving after resolving conflicts
// await _context.SaveChangesAsync();
}
Performance Optimizations
Efficiently querying and saving data is vital for application performance. EF Core offers several strategies:
- Asynchronous Operations: Always use asynchronous methods like
ToListAsync()
,FindAsync()
, andSaveChangesAsync()
to prevent blocking the application's thread pool. - Query Optimization:
- Projection: Select only the properties you need using
Select()
to reduce the amount of data transferred from the database. - Filtering: Use
Where()
clauses to fetch only relevant data. - Lazy Loading vs. Eager Loading: Understand when to use
Include()
(eager loading) versus relying on lazy loading (requires proxy creation enabled). Eager loading is generally preferred for performance when you know you'll need related data. AsNoTracking()
: For read-only queries, useAsNoTracking()
to disable change tracking, which significantly improves performance.
- Projection: Select only the properties you need using
- Batching: Perform multiple inserts, updates, or deletes in a single database roundtrip. EF Core itself doesn't provide built-in batching for multiple entities, but libraries like
EFCore.BulkExtensions
can be used. - Connection Pooling: EF Core utilizes ADO.NET's connection pooling, so reusing
DbContext
instances within a unit of work is important. ExecuteSqlRawAsync()
/ExecuteSqlInterpolatedAsync()
: For complex or highly optimized operations, consider executing raw SQL.
// Optimized query using AsNoTracking() and Select()
var productNames = await _context.Products
.AsNoTracking() // Disable change tracking for read-only query
.Where(p => p.Price > 10)
.Select(p => p.Name) // Project only the Name property
.ToListAsync();
// Eager loading
var productsWithCategories = await _context.Products
.Include(p => p.Category)
.ToListAsync();
Custom Conventions
Conventions allow you to automatically configure your model based on common patterns in your entity classes. You can define custom conventions to enforce specific naming conventions, automatically set property types, or apply configurations.
Custom conventions are implemented by inheriting from IEntityTypeConvention
, IPropertyConvention
, etc., or by using the fluent API builder in OnModelCreating
.
// Example: Convention to pluralize table names
public class TablePluralizationConvention : IEntityTypeConvention
{
public void Apply(IConventionEntityTypeBuilder entityTypeBuilder)
{
var tableName = entityTypeBuilder.Metadata.Relational().TableName;
if (tableName.EndsWith("y"))
{
entityTypeBuilder.ToTable(tableName + "ies");
}
else
{
entityTypeBuilder.ToTable(tableName + "s");
}
}
}
// In DbContext.OnModelCreating:
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
configurationBuilder.Conventions.Add(new TablePluralizationConvention());
}
Working with Expression Trees
EF Core translates LINQ queries into SQL using Expression Trees. While you typically don't need to manually construct them for standard queries, understanding them is beneficial for advanced scenarios like creating custom query operators or dynamic querying.
Expression trees represent code as a data structure. EF Core analyzes these trees to generate SQL.
// Manual creation of a simple expression tree
var parameter = Expression.Parameter(typeof(Product), "p");
var property = Expression.Property(parameter, "Price");
var constant = Expression.Constant(10M);
var comparison = Expression.GreaterThan(property, constant);
var lambda = Expression.Lambda>(comparison, parameter);
// You could then use this lambda with LINQ methods,
// but EF Core does this automatically.
// _context.Products.Where(lambda).ToList();
Compiled Models
Compiled Models are a performance optimization that pre-compiles your EF Core model and queries into C# code. This avoids the overhead of model discovery and query translation at runtime, leading to faster startup times and improved query performance.
To use compiled models:
- Install the
Microsoft.EntityFrameworkCore.Design
NuGet package. - Add a class that implements
IModelCustomizer
. - In the customizer, use the
ModelBuilder
to configure your model. - Use the
dotnet ef compile-models
command.
// Example IModelCustomizer
public class MyModelCustomizer : IModelCustomizer
{
public void Customize(ModelBuilder modelBuilder)
{
modelBuilder.Entity().Property(p => p.Name).IsRequired();
// ... other configurations
}
}
// Command: dotnet ef compile-models --context YourDbContext --output-dir CompiledModels
Interceptors
Interceptors allow you to hook into EF Core's pipeline and intercept various operations, such as saving changes, executing commands, or opening connections. This is useful for cross-cutting concerns like logging, auditing, or modifying commands.
You can create custom interceptors by implementing interfaces like DbCommandInterceptor
or SaveChangesInterceptor
.
// Example DbCommandInterceptor for logging SQL
public class SqlLoggingInterceptor : DbCommandInterceptor
{
public override InterceptionResult CommandExecuting(DbCommand command, CommandEventData eventData, InterceptionResult result)
{
Console.WriteLine($"Executing SQL: {command.CommandText}");
return result;
}
}
// In DbContext:
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(new SqlLoggingInterceptor());
}
Global Query Filters
Global query filters allow you to apply a Where()
clause to all queries for a specific entity type. This is commonly used for soft deletes or multi-tenancy.
Configure global filters in OnModelCreating
using HasQueryFilter()
.
// Entity with a IsDeleted flag for soft delete
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public bool IsDeleted { get; set; } = false;
}
// In DbContext.OnModelCreating:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity().HasQueryFilter(p => !p.IsDeleted);
// To include deleted items:
// _context.Products.IgnoreQueryFilters().Where(...).ToList();
}
Executing Raw SQL Queries
While LINQ is the preferred way to query data, EF Core allows you to execute raw SQL queries when necessary, for example, to use database-specific features or optimize complex queries.
Use FromSqlRaw()
or FromSqlInterpolated()
to execute raw SQL and map the results to your entity types.
// Using FromSqlRaw
var products = await _context.Products
.FromSqlRaw("SELECT * FROM Products WHERE Price > {0}", 50)
.ToListAsync();
// Using FromSqlInterpolated (safer against SQL injection)
var productName = "Laptop";
var priceThreshold = 1000M;
var filteredProducts = await _context.Products
.FromSqlInterpolated($"SELECT * FROM Products WHERE Name = {productName} AND Price > {priceThreshold}")
.ToListAsync();
// Executing non-query SQL (e.g., stored procedures)
await _context.Database.ExecuteSqlRawAsync("EXEC UpdateProductPrice @Id, @NewPrice", new SqlParameter("@Id", 1), new SqlParameter("@NewPrice", 20.00M));
Batch Delete and Update
EF Core's SaveChanges()
operation typically translates each entity's state change into a separate SQL statement. For large numbers of entities, this can lead to many roundtrips to the database.
While EF Core doesn't have built-in batching for SaveChanges()
, you can use third-party libraries like EFCore.BulkExtensions
or execute SQL commands directly for more efficient batch operations.
Using EFCore.BulkExtensions
(example):
// Requires installing EFCore.BulkExtensions package
// using EFCore.BulkExtensions;
var productsToUpdate = await _context.Products.Where(p => p.Price < 5).ToListAsync();
foreach(var p in productsToUpdate) { p.Price *= 1.1M; } // Increase price by 10%
await _context.BulkUpdateAsync(productsToUpdate); // Efficiently updates multiple entities
var productsToDelete = await _context.Products.Where(p => p.IsDeleted).ToListAsync();
await _context.BulkDeleteAsync(productsToDelete); // Efficiently deletes multiple entities