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:
- We are overriding the
SavingChangesAsync
method, which occurs before the call to our data storage engine. - The method receives three parameters of
DbContextEventData
,InterceptionResult<int>
, andCancellationToken
. -
eventData
holds ourDbContext
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.