EF Core is my favorite ORM for .NET applications. Yet, its many fantastic features sometimes go unnoticed. For example, query splitting, query filters, and interceptors.
EF interceptors are interesting because you can do powerful things with them. For example, you can hook into materialization, handle optimistic concurrency errors, or add query hints.
The most practical use case is adding behavior when saving changes to the database.
Today I want to show you three unique use cases for EF Core interceptors:
- Audit logging
- Publishing domain events
- Persisting Outbox messages
What are EF Interceptors?
EF Core interceptorsallow you to intercept, change, or suppress EF Core operations. Every interceptor implements the IInterceptor
interface. A few common derived interfaces include IDbCommandInterceptor
, IDbConnectionInterceptor
, and IDbTransactionInterceptor
.
The most popular one is the ISaveChangesInterceptor
. It allows you to add behavior before or after saving changes.
Interceptors are registered for each DbContext
instance when configuring the context.
public interface IInterceptor
{
}
You don't have to implement these interfaces directly. It's better to use concrete implementations and override the needed methods.
For example, I'll show you how to use the SaveChangesInterceptor
.
Audit Logging With EF Interceptors
An audit log of entity changes is a valuable feature in some applications. You write additional audit information every time an entity is created or modified. The audit log could also contain the complete before/after values, depending on your requirements.
However, let's use a simple example to make it easy to understand.
I have an IAuditable
interface with two properties representing when an entity was created or modified.
public interface IAuditable
{
DateTime CreatedOnUtc { get; }
DateTime? ModifiedOnUtc { get; }
}
Next, I'll implement an UpdateAuditableInterceptor
interceptor to write the audit values. It uses the ChangeTracker
to find all IAuditable
instances and sets the respective property value.
I want to highlight that I'm overriding the SavingChangesAsync
method here.SavingChangesAsync
runs before the changes are saved in the database and any updates applied inside the UpdateAuditableInterceptor
are also part of the current database transaction.
This implementation can be easily extended to include the information about the current user.
internal sealed class UpdateAuditableInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not null)
{
UpdateAuditableEntities(eventData.Context);
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void UpdateAuditableEntities(DbContext context)
{
DateTime utcNow = DateTime.UtcNow;
var entities = context.ChangeTracker.Entries<IAuditable>().ToList();
foreach (EntityEntry<IAuditable> entry in entities)
{
if (entry.State == EntityState.Added)
{
SetCurrentPropertyValue(
entry, nameof(IAuditable.CreatedOnUtc), utcNow);
}
if (entry.State == EntityState.Modified)
{
SetCurrentPropertyValue(
entry, nameof(IAuditable.ModifiedOnUtc), utcNow);
}
}
static void SetCurrentPropertyValue(
EntityEntry entry,
string propertyName,
DateTime utcNow) =>
entry.Property(propertyName).CurrentValue = utcNow;
}
}
Publish Domain Events With EF Interceptors
Another use case for EF interceptors is publishing domain events. Domain events are a DDD tactical pattern to create loosely coupled systems.
Domain events allow you to express side effects explicitly and provide a better separation of concerns in the domain.
You can create an IDomainEvent
interface, which derives from MediatR.INotification
. This allows you to use the IPublisher
to publish domain events and handle them asynchronously.
using MediatR;
public interface IDomainEvent : INotification
{
}
Then, I'll create a PublishDomainEventsInterceptor
that also inherits from SaveChangesInterceptor
. However, this time, we're using the SavedChangesAsync
to publish the domain events after saving changes in the database.
This has two important implications:
- The entire workflow is now eventually consistent. Domain event handlers will save changes to the database after the original transaction.
- If any domain event handlers fail, we risk failing the request even though the initial transaction was completed successfully.
You can make this process more reliable by using an Outbox.
internal sealed class PublishDomainEventsInterceptor : SaveChangesInterceptor
{
private readonly IPublisher _publisher;
public PublishDomainEventsInterceptor(IPublisher publisher)
{
_publisher = publisher;
}
public override async ValueTask<int> SavedChangesAsync(
SaveChangesCompletedEventData eventData,
int result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not null)
{
await PublishDomainEventsAsync(eventData.Context);
}
return result;
}
private async Task PublishDomainEventsAsync(DbContext context)
{
var domainEvents = context
.ChangeTracker
.Entries<Entity>()
.Select(entry => entry.Entity)
.SelectMany(entity =>
{
List<IDomainEvent> domainEvents = entity.DomainEvents;
entity.ClearDomainEvents();
return domainEvents;
})
.ToList();
foreach (IDomainEvent domainEvent in domainEvents)
{
await _publisher.Publish(domainEvent);
}
}
}
Store Outbox Messages With EF Interceptors
Instead of publishing domain events as part of the EF transaction, you can convert them to Outbox messages.
Here's an InsertOutboxMessagesInterceptor
that does precisely this.
It overrides the SavingChangesAsync
method. Which means it runs inside the current EF transaction before saving changes.
The InsertOutboxMessagesInterceptor
converts any domain events into an OutboxMessage
and adds it to the respective DbSet<OutboxMessage>
. This means they will be saved to the database with any existing changes inside the same transaction.
This is an atomic operation.
Either everything succeeds or everything fails.
There's no in-between state like in the PublishDomainEventsInterceptor
.
You can then create a background worker that will process the Outbox messages.
And this is how you implement the Outbox pattern with EF Core.
using Newtonsoft.Json;
public sealed class InsertOutboxMessagesInterceptor : SaveChangesInterceptor
{
private static readonly JsonSerializerSettings Serializer = new()
{
TypeNameHandling = TypeNameHandling.All
};
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is not null)
{
InsertOutboxMessages(eventData.Context);
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void InsertOutboxMessages(DbContext context)
{
context
.ChangeTracker
.Entries<Entity>()
.Select(entry => entry.Entity)
.SelectMany(entity =>
{
List<IDomainEvent> domainEvents = entity.DomainEvents;
entity.ClearDomainEvents();
return domainEvents;
})
.Select(domainEvent => new OutboxMessage
{
Id = domainEvent.Id,
OccurredOnUtc = domainEvent.OccurredOnUtc,
Type = domainEvent.GetType().Name,
Content = JsonConvert.SerializeObject(domainEvent, Serializer)
})
.ToList();
context.Set<OutboxMessage>().AddRange(outboxMessages);
}
}
Configuring EF Interceptors Using Dependency Injection
EF interceptors should be lightweight and stateless. You can add them to the DbContext
by calling AddInterceptors
and passing in the interceptor instances.
I like to configure the interceptors with Dependency Injection for two reasons:
- It allows me also to use DI in the interceptors (be mindful that they are singletons)
- To simplify adding the interceptors to the
DbContext
usingAddDbContext
Here's how you can configure the UpdateAuditableInterceptor
and InsertOutboxMessagesInterceptor
with the ApplicationDbContext
:
services.AddSingleton<UpdateAuditableInterceptor>();
services.AddSingleton<InsertOutboxMessagesInterceptor>();
services.AddDbContext<IApplicationDbContext, ApplicationDbContext>(
(sp, options) => options
.UseSqlServer(connectionString)
.AddInterceptors(
sp.GetRequiredService<UpdateAuditableInterceptor>(),
sp.GetRequiredService<InsertOutboxMessagesInterceptor>()));
Closing Thoughts
Interceptors allow you to do almost anything with an EF Core operation. But with great power comes great responsibility. You should be mindful that interceptors have an impact on performance. Calls to external services or handling events will slow down the operation.
Remember, you don't necessarily have to use EF interceptors. You can achieve the same behavior by overriding the SaveChangesAsync
method on the DbContext
and adding your custom logic there.
I showed you a few practical use cases for EF interceptors in this week's issue.
But, if you want to see more examples, I have a few videos about:
Thanks for reading, and stay awesome!
P.S. Whenever you're ready, there are 2 ways I can help you:
Pragmatic Clean Architecture: This comprehensive course will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture. Join 1,200+ students here.
Patreon Community: Think like a senior software engineer with access to the source code I use in my YouTube videos and exclusive discounts for my courses. Join 930+ engineers here.