Advanced Entity Framework Core Tips In Practice: Context pooling, Lazy vs Eager loading, Single vs. Split Queries, Tracking vs. No-Tracking Queries

Advanced Entity Framework Core Tips In Practice,

Context pooling

To improve performance for web applications you may consider using the context pooling feature. It is worth using only when your application has a decent load since it just caches DbContext instances for not to dispose after each request and recreate again. You may set the max size of the pool, so it will dispose of those which are above the limit.

services.AddDbContextPool<MyContext>(...);

To make even more optimization in EF Core 6 consider using PooledDbContextFactory instead of direct context injects by DI to eliminate DI overhead.

You can read more here and here regarding this topic.

Lazy vs Eager loading

Lazy loading is a nice feature but in real-world applications, it brings a big risk. With lazy loading enabled and proxies package installed EF makes overrides for your virtual navigation properties with functionality which retrieves from the database accessed properties.

Why it is dangerous, what can happen? 

Once you retrieved an entity that has not loaded lazy properties and use it somewhere in the logic you may,

  • Access lazy properties in the loop which would produce dozen of unwanted synchronous calls
  • While mapping or automapping lazy properties may also be called
  • While serialization the whole dependent tree may be retrieved from the database

So for applications under decent load lazy loading is kind of a boom that may explode after certain refactoring, with dozen of unwanted synchronous calls for each request which will bombard the DB and lead to web application connection pool exposition since more requests will be handled at the same time in the app. Here is the related note in the MS docs "Beware of lazy loading".

Eager loading is an obvious way to specify which dependent entities to load in any sub-level. Include and ThenInclude methods results in joins on the SQL side, so all the requested data loads in a single async query (unless SplitQuery feature enabled). Eager loading with disabled lazy loading helps eliminate all disadvantages of the pure lazy loading approach described above.

var data = await context.TodoLists
   .Include(l => l.TodoItems)
   .ThenInclude(i => i.TagList)
   .ToListAsync();

Single vs. Split Queries

All the related entities loaded with Include, ThenInclude, and other join variations produce joins on the database side. This means that when we load TodoList and TodoItems the list values will be duplicated across all TodoItems, even worse when we go deeper and load TagList for all TodoItems, so TodoItem and list data will be duplicated for each own tag. Such a deep loading duplication problem has the name "cartesian explosion". Due to the cartesian explosion, there was a need to split huge queries into multiple calls, which is why was introduced .AsSplitQuery() for not to make multiple calls manually.

var data = await context.TodoLists
    .Include(l => l.TodoItems)
    .ThenInclude(i => i.TagList)
    .AsSplitQuery()
    .ToListAsync();

Another way is to enable split query behavior globally and override the behavior by calling .AsSingleQuery() when needed.

// Startup file
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(
   configuration.GetConnectionString("DefaultConnection"),
b => b.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery)));
var data = await context.TodoLists
   .Include(l => l.TodoItems)
   .ThenInclude(i => i.TagList)
   .AsSingleQuery()
   .ToListAsync();

Not all web apps would need such optimization but knowing this feature would save your time when facing such issues. More regarding this topic can be found here.

Tracking vs. No-Tracking Queries

By default EF tracks all entities (except keyless) which were retrieved via EF. It has a change tracking mechanism that detects each property change for the loaded entity. This is handy when you do some updates to entities to save. 

var item = context.TodoItem.First(i => i.Id == 111);
item.Done = true;
context.SaveChanges();

This is an awesome mechanism but it has 2 disadvantages,

  • Such a tracked entity may be accidentally modified in another part of the system and unwanted changes may be saved on context.SeveChanges() call
  • Any additional logic usually cost resources, so modification of tracked entity in memory triggers change tracking, as well as loading data with change tracking may need up to 2 times more resources

To omit unwanted changes and increase performance on a particular call consider calling .AsNoTracking(), also you can turn off tracking behaviour on the context itself or even from the context contractor to make it globally.

var data = await context.TodoLists
   .Include(l => l.TodoItems)
   .ThenInclude(i => i.TagList)
   .AsNoTracking()
   .ToListAsync();
context.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;

More regarding this topic can be found here and regarding performance here.