Mastering Data Access Patterns with Entity Framework Core 8

Efficient data access is foundational to every high-performance application. In .NET 8, Entity Framework Core 8 (EF Core) introduces several improvements, making it easier than ever to optimize your queries, reduce overhead, and structure your persistence logic cleanly.

In this guide, we’ll explore the most important data access patterns and best practices using EF Core 8.


🧱 Choosing the Right Pattern: Repository vs. Unit of Work vs. Direct DbContext

Repository Pattern: Provides a cleaner abstraction around data access and query logic.

public interface IProductRepository
{
    Task<Product?> GetByIdAsync(int id);
    Task<IEnumerable<Product>> GetAllAsync();
    Task AddAsync(Product product);
}

Unit of Work Pattern: Encapsulates multiple repositories under a single transaction boundary.

public interface IUnitOfWork
{
    IProductRepository Products { get; }
    Task<int> CommitAsync();
}

Direct DbContext Access: Use this when simplicity matters and you don’t need abstraction layers (e.g., small services).

✅ Recommendation: Use Repository + UoW in complex domains or multi-aggregate architectures. Go direct for microservices or CRUD-heavy apps.


⚡ EF Core 8 Features & Enhancements

1. Interceptors

Allows injecting logic before/after DB commands. Useful for:

  • Auditing
  • Soft deletes
  • Query rewriting
public class AuditInterceptor : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData, InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        // Custom audit logic
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
}

Register it:

options.AddInterceptors(new AuditInterceptor());

2. Raw SQL Improvements

Better mapping, async support, and safety with FromSqlRaw:

var products = await db.Products
    .FromSqlRaw("SELECT * FROM Products WHERE Price > {0}", 100)
    .ToListAsync();

3. Complex Type Mapping

EF Core 8 supports value objects and nested properties more gracefully.

public class Product
{
    public int Id { get; set; }
    public PriceDetails Price { get; set; }
}

[Owned]
public class PriceDetails
{
    public decimal Value { get; set; }
    public string Currency { get; set; }
}


🔁 Tracking vs NoTracking

By default, EF Core tracks changes, which adds overhead. Use NoTracking for read-only queries.

var list = await db.Products.AsNoTracking().ToListAsync();


🎯 Compiled Queries

Precompile expensive LINQ queries for faster performance.

public static Func<AppDbContext, int, Task<Product?>> GetById =
    EF.CompileAsyncQuery((AppDbContext db, int id) =>
        db.Products.FirstOrDefault(p => p.Id == id));

Great for high-traffic endpoints or shared query logic.


📦 Batching & Transactions

Group multiple operations into a transaction:

await using var tx = await db.Database.BeginTransactionAsync();
db.Products.Add(new Product { Name = "Test" });
await db.SaveChangesAsync();
await tx.CommitAsync();

EF Core will automatically batch compatible commands where possible to minimize roundtrips.


🔍 Query Diagnostics

Use the ToQueryString() method to inspect SQL translation.

string sql = db.Products.Where(p => p.Price > 100).ToQueryString();
Console.WriteLine(sql);

Use dotnet ef dbcontext optimize for precompilation benefits and trimming analysis.


🚀 Best Practices

  • Prefer async APIs for scalability
  • Use AsNoTracking() for queries that don’t modify data
  • Profile SQL with SQL Profiler or EF logging
  • Avoid N+1 by eager loading with Include()
  • Use raw SQL only for performance-critical paths
  • Break repositories by aggregate (not per-entity)

🔮 When to Consider Dapper

For extremely performance-critical sections (e.g., report generation), consider using Dapper in conjunction with EF Core.

Use EF Core for write-heavy and relational consistency, and Dapper for tight, fast SELECTs.


🧠 Final Thoughts

EF Core 8 gives you all the tools needed to build clean, performant, and maintainable data layers. Whether you use raw SQL, compiled queries, or full repository patterns, the key is understanding when and where to apply each.

By adopting these patterns and practices, your application will scale better, perform faster, and remain flexible as requirements evolve.

Want a full repo example? Stay tuned on DotNetWisdom.co.uk for downloadables, templates, and additional walkthroughs.

Leave a comment