In previous non-core versions of Entity Framework (EF), it was possible to intercept calls to and from the database, but it’s wasn’t a feature many would classify under “first-class support”. Thankfully the community stepped in at the time to fill the needs of the .NET ecosystem. Everyday use cases for intercepting EF calls include debugging, data enrichment, query enrichment, and auditing.

Since the release of EF Core, the Entity Framework team has recognized the need for interceptors and has provided folks with multiple ways to apply custom interceptors right out of the box, without resorting to nasty hacks or intense code gymnastics.

This post will explore the different interceptors that EF Core developers can add to their DbContext instances.

What’s an Interceptor?

When we mention interceptors in EF Core, we’re specifically talking about any type that implements the IInterceptor marker interface. This interface has no methods, as can be seen in the code below.

namespace Microsoft.EntityFrameworkCore.Diagnostics
{
	public interface IInterceptor
	{	
	}
}

There are derived interfaces and types that fall into the hierarchy of IInterceptor: IDbCommandInterceptor, IDbConnectionInterceptor, IDbTransactionInterceptor, and ISaveChangesInterceptor. While folks could directly implement these interfaces, it’s recommended that developers inherit from provided concrete types and override the necessary methods. Some of the concrete types include DbCommandInterceptor, DbConnectionInterceptor, DbTransactionInterceptor, and SaveChangesInterceptor.

Implementing A SaveChangesInterceptor

As readers may have noticed, the EF Core team has named each interceptor based on its area of effect. Let’s take a look at implementing a SaveChangesInterceptor type. Our first step will be to inherit from the SaveChangesInterceptor.

using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore.Diagnostics;

namespace Entertainment.Interceptors
{
    public class LoggingSavingChangesInterceptor :
        SaveChangesInterceptor
    {
        public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
            DbContextEventData eventData, 
            InterceptionResult<int> result,
            CancellationToken cancellationToken = new CancellationToken())
        {
            Console.WriteLine(eventData.Context.ChangeTracker.DebugView.LongView);
            return new ValueTask<InterceptionResult<int>>(result);
        }
    }
}

Let’s look at the significant parts of our implementation:

  1. We are overriding the SavingChangesAsync method, which occurs before the call to our data storage engine.
  2. The method receives three parameters of DbContextEventData, InterceptionResult<int>, and CancellationToken.
  3. eventData holds our DbContext instance, which we can introspect to understand our call’s current state. The parameter includes tracked entities and other critical information.

In our example, we are using the eventData parameter to retrieve the DebugView of our EF Core call. Before we can use our interceptor, we first need to wire it to our DbContext, which we can do in our OnConfiguring method within our implementation.

protected override void OnConfiguring(DbContextOptionsBuilder options)
    => options
    .AddInterceptors(new LoggingSavingChangesInterceptor())
    .UseSqlite("Data Source=entertainment.db");

Note that we need to instantiate interceptors when adding them to the DbContext. There are other mechanisms to create and manage interceptors, as detailed by Mickaël Derriey in his blog post about dependency injection and interceptors.

Now, we can use our DbContext instance as we would normally.

await database.Movies.AddAsync(new Movie
{
    Name = $"test-{DateTime.Now:d}", 
    DurationInMinutes = 120, 
    Release = DateTime.Now
});
await database.SaveChangesAsync();

After executing our database call, we can see the resulting debugging value in our console output.

Movie {Id: -2147482647} Added
  Id: -2147482647 PK Temporary
  Discriminator: 'Movie'
  Name: 'test-11/10/2020'
  Release: '11/10/2020 11:54:48 AM'
  DurationInMinutes: 120
  WorldwideBoxOfficeGross: 0
  Characters: []
  Ratings: []

A Different Interceptor Approach

With EF Core 5, developers have other options to intercept calls to the database, at least when calling SaveChanges. EF Core 5 introduces the ability to subscribe to the SaveChanges event directly on each instance of a DbContext.

database.SavingChanges += (sender, args) =>
{
    Console.WriteLine($"Saving changes for {((DbContext)sender)?.Database.GetConnectionString()}");
};

database.SavedChanges += (sender, args) =>
{
    Console.WriteLine($"Saved {args.EntitiesSavedCount} changes for {((DbContext)sender)?.Database.GetConnectionString()}");
};

The sender argument is the DbContext instance, while the args value is either a SavingChangesEventArgs or SavedChangesEventArgs depending on the event we subscribe to.

There are caveats to this approach, the major one being that we need to subscribe to events on each new instance of a DbContext. Additionally, event handlers should be appropriately disposed of or be at risk of memory leaks.

Interceptor Dependencies

While interceptors allow for tieing directly into significant events during the lifetime of a DbContext, they do have severe limitations. We saw that interceptors need to be registered using the new operator within the OnConfiguring method.

There are a few options developers can take when needing access to external dependencies.

Constructor Injection Into DbContext

Depending on the lifetime scope of a DbContext instance, we may consider injecting a dependency directly into our DbContext and letting an external container resolve our dependency. We would still need to pass those dependencies to the appropriate interceptor.

public EntertainmentDbContext(string important = null)
{
    this.important = important;
}

protected override void OnConfiguring(DbContextOptionsBuilder options)
    => options
    .AddInterceptors(new LoggingSavingChangesInterceptor(important))
    .UseSqlite("Data Source=entertainment.db");

The drawbacks to this approach are immediately evident, as each interceptor’s parameters become the parameters of the DbContext.

We could also take an IEnumerable<IInterceptors> as a parameter for our DbContext. We need to be careful not to capture any dependencies inadvertently if we’re resolving our interceptors from an inversion of control (IoC) container.

public EntertainmentDbContext(IEnumerable<IInterceptor> interceptors)
{
    this.interceptors = interceptors;
}

protected override void OnConfiguring(DbContextOptionsBuilder options)
    => options
    .AddInterceptors(interceptors)
    .UseSqlite("Data Source=entertainment.db");

Finally, folks could pass in an IoC container and use the service locator pattern. Note, this is my least favored approach as it means a DbContext might have access to all and every dependency within the current execution context.

private readonly IContainer container;

public EntertainmentDbContext(IContainer container)
{
    this.container = container;
}

protected override void OnConfiguring(DbContextOptionsBuilder options)
    => options
    .AddInterceptors(container.GetServices<IEnumerable<IInterceptor>>())
    .UseSqlite("Data Source=entertainment.db");

Drawbacks of Interceptors

While interceptors give us the ability to do anything within the context of an executing EF Core call, it also means WE CAN DO ANYTHING! Let’s look at some of the tasks we could do while a database query is happening:

  • Store event information into audit tables
  • Call a third party service for additional information
  • Call a web service for authorization information

Each of these possibilities provides additional opportunities to break transactional guarantees and add performance overhead to each EF Core query. We need to consider each interceptor carefully and its impact on the overall user experience. More so, we need to consider failure states and whether any action that we place within an interceptor must be part of an overall transaction.

Tl;DR for this section is don’t perform expensive operations in interceptors.

Conclusion

With EF Core, interceptors allow us to see and handle critical parts of a DbContext instance’s lifecycle. From when EF Core creates the connection, initializes the command, and ultimately executes the query, we have access to it all. Like all things, “with great power comes great responsibility.” Each interceptor and DbContext share a lifetime, so thoughtful consideration needs to happen when putting functionality into an interceptor implementation.

I hope you found this post helpful, and please leave a comment below if you’re using interceptors today with your application. As always, thank you for reading.

References