How To Use EF Core Interceptors

How To Use EF Core Interceptors

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 interceptors allow 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:

  1. The entire workflow is now eventually consistent. Domain event handlers will save changes to the database after the original transaction.

  2. 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 using AddDbContext

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:

  1. 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.

  2. 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.