Entity Framework Core (EF Core) has many powerful features, with Interceptors being one of the most versatile. Interceptors allow us to plug in custom behavior at different stages of the EF Core operation pipeline, giving us enhanced control over data interaction processes. Moreover, by using interceptors, we can fine-tune our database operations, enforce business rules, ensure data integrity, and more.

To download the source code for the video, visit our Patreon page (YouTube Patron tier).

This article uses Docker to run a database container for our tests.

Let’s dive in!

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

VIDEO: Entity Framework Core Interceptors.


What Are EF Core Interceptors?

EF Core Interceptors are classes that implement the Microsoft.EntityFrameworkCore.Diagnostics.IInterceptor interface. Essentially, the framework calls instances of these classes at different stages of our data interactions, depending on their type. We use interceptors to monitor, log, modify, or cancel operations before or after EF Core executes them.

You are new to Entity Framework Core? We have prepared a series for that: Entity Framework Core Series.

EF Core has several main categories of interceptors, each serving a specific purpose. It even provides abstract base classes out of the box for them:

InterceptorDescription
SaveChangesInterceptorHooks into the SaveChanges process
DbCommandInterceptorIntercepts and modifies database commands
TransactionInterceptorProvides control over transaction operations
DbConnectionInterceptorMonitors and modifies database connection events

To create interceptors, we can use these abstract base classes. Consequently, this helps us avoid boilerplate code and focus on implementing custom logic.

For instance, imagine an application where users register for an account. The registration process involves creating a new user record in the database and sending a welcome email. Both steps must succeed, or the process should roll back to prevent an inconsistent state.

Prerequisites for Implementation of EF Core Interceptors

Firstly, to run our example, we need Docker running on our machine and the Testcontainers NuGet package installed:

dotnet add package Testcontainers --version 3.9.0

Consequently, we can test our EF Core interceptors against a database running in Docker as a container.

If you are interested in how Testcontainers work in detail, check out our article Testing Using Testcontainers for .NET and Docker.

Our Project Setup

First, let’s define an interface for auditing:

public interface IAuditableEntity
{
    public DateTime Created { get; }

    public DateTime Modified { get; }

    void AuditCreation(TimeProvider timeProvider);

    void AuditModification(TimeProvider timeProvider);
}

Next, we will create our User entity, which consists of the properties Id, Name, Email, and Created and Modified. It also implements the IAuditableEntity interface to support audit functionality:

public class User : IAuditableEntity
{
    public long Id { get; protected set; }

    public string Name { get; set; }

    public string Email { get; set; }

    public DateTime Created { get; private set; }

    public DateTime Modified { get; private set; }

    public AuditCreation(TimeProvider timeProvider)
    {
        var now = timeProvider.GetUtcNow().UtcDateTime;

        Created = now;
        Modified = now;
    }

    public AuditModification(TimeProvider timeProvider)
    {
        Modified = timeProvider.GetUtcNow().UtcDateTime;
    }

    public void ValidateState()
    {
        if (string.IsNullOrWhiteSpace(Email))
            throw new ApplicationException("Invalid user state! Email should be provided!");
    }
}

The AuditCreation() and AuditModification() methods from IAuditableEntity are designed for interceptors to call for auditing purposes, while ValidateState() is used for validation before saving. We can place business logic here to maintain a consistent state for our entity.

Subsequently, we can configure the User entity in EF Core:

internal class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.ToTable("Users", "master");

        builder.Property(x => x.Id)
            .HasColumnName("Id")
            .UseIdentityColumn();

        builder.Property(x => x.Name).HasColumnName("Name");
        builder.Property(x => x.Email).HasColumnName("Email");
        builder.Property(x => x.Created).HasColumnName("Created");
        builder.Property(x => x.Modified).HasColumnName("Modified");
    }
}

Here, we map the entity’s properties to their respective database columns and specify that the Id is an identity column.

Using the Testcontainers package added to our project earlier, we can build a database container configuration:

var user = "sa";
var password = "$trongPassword";
var portNumber = 1433;

var container = new ContainerBuilder()
                .WithImage("mcr.microsoft.com/mssql/server:2022-latest")
                .WithPortBinding(portNumber, true)
                .WithEnvironment("ACCEPT_EULA", "Y")
                .WithEnvironment("SQLCMDUSER", user)
                .WithEnvironment("SQLCMDPASSWORD", password)
                .WithEnvironment("MSSQL_SA_PASSWORD", password)
                .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(portNumber))
                .Build();

await container.StartAsync();

Here we configure a SQL Server database and spin it up in a container.

Let’s get started with our interceptors.

DbConnectionInterceptor

The first one is the ConnectionInterceptor:

public class ConnectionInterceptor(ILogger<ConnectionInterceptor> logger) : DbConnectionInterceptor
{
    public override async Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData,
        CancellationToken cancellationToken = default)
    {
        logger.LogInformation("Connection opened.");

        await base.ConnectionOpenedAsync(connection, eventData, cancellationToken);
    }

    public override async Task ConnectionClosedAsync(DbConnection connection, ConnectionEndEventData eventData)
    {
        logger.LogInformation("Connection closed.");

        await base.ConnectionClosedAsync(connection, eventData);
    }
}

Since it derives from DbConnectionInterceptor, it is called every time a database connection event occurs. In our example, we focus on when a connection is opened and when it is closed. For demonstration purposes, we log these events to the console.

SaveChangesInterceptor

The second interceptor is the ValidateEntitiesStateInterceptor:

internal class ValidateEntitiesStateInterceptor(ILogger<ValidateEntitiesStateInterceptor> logger)
    : SaveChangesInterceptor
{
    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        if (eventData.Context is not null)
            ValidateEntitiesStates(eventData.Context);

        return new ValueTask<InterceptionResult<int>>(result);
    }

    private void ValidateEntitiesStates(DbContext context)
    {
        logger.LogInformation("Validating entities.");

        foreach (var entry in context.ChangeTracker.Entries<User>())
        {
            entry.Entity.ValidateState();
        }
    }
}

Here, we derive from the SaveChangesInterceptor class and we use it to call ValidateState() on our User entities during saving changes to the database.

The third interceptor is AuditableEntitiesInterceptor, also a child of SaveChangesInterceptor:

internal class AuditableEntitiesInterceptor(ILogger<AuditableEntitiesInterceptor> logger, TimeProvider timeProvider)
    : 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 new ValueTask<InterceptionResult<int>>(result);
    }

    private void UpdateAuditableEntities(DbContext context)
    {
        logger.LogInformation("Auditing entities.");

        foreach (var entry in context.ChangeTracker.Entries<IAuditableEntity>())
        {
            switch (entry.State)
            {
                case EntityState.Added:
                    entry.Entity.AuditCreation(timeProvider);
                    break;
                case EntityState.Modified:
                    entry.Entity.AuditModification(timeProvider);
                    break;
                default:
                    continue;
            }
        }
    }
}

Here, our interceptor gets all newly created or updated entities from the DbContext that implement IAuditableEntity and calls their methods responsible for auditing, happening before the changes are saved.

DbTransactionInterceptor

The fourth interceptor is TransactionInterceptor, inheriting from DbTransactionInterceptor:

public class TransactionInterceptor(ILogger<TransactionInterceptor> logger) : DbTransactionInterceptor
{
    public override async Task TransactionCommittedAsync(DbTransaction transaction, TransactionEndEventData eventData,
        CancellationToken cancellationToken = default)
    {
        logger.LogInformation("Transaction {TransactionId} successful.", eventData.TransactionId);

        await base.TransactionCommittedAsync(transaction, eventData, cancellationToken);
    }
}

Interceptors of this type handle events related to transactions. Specifically, in our case, we want to log the transaction ID to the console when the system successfully commits a transaction.

Registering the Interceptors and the DbContext

With all our interceptors created, we now register them in the DI container and add them to the DbContext. Note that interceptors are registered per DbContext instance when the context is configured.

builder.Services
    .AddScoped<ConnectionInterceptor>()
    .AddScoped<ValidateEntitiesStateInterceptor>()
    .AddScoped<AuditableEntitiesInterceptor>()
    .AddScoped<TransactionInterceptor>();

builder.Services.AddDbContext<ApplicationDbContext>((sp, contextBuilder) =>
{
    contextBuilder
        .UseSqlServer(connectionString)
        .AddInterceptors(
            sp.GetRequiredService<ConnectionInterceptor>(),
            sp.GetRequiredService<ValidateEntitiesStateInterceptor>(),
            sp.GetRequiredService<AuditableEntitiesInterceptor>(),
            sp.GetRequiredService<TransactionInterceptor>());
}, contextLifetime: ServiceLifetime.Scoped);

Additionally, we should also pay attention to the order in which we add interceptors of the same category with AddInterceptors(), as this order determines their execution.

Using EF Core Interceptors

Now, let’s put our interceptors into action. We need an email notification service and an endpoint to process user registration.

Email Notification Service

First, let’s define a simple IEmailService interface and its implementation in the EmailService class:

public interface IEmailService
{
    Task<bool> SendWelcomeEmailAsync(long userId, string userName, string userEmail);
}

public class EmailService(ILogger<EmailService> logger) : IEmailService
{
    public async Task<bool> SendWelcomeEmailAsync(long userId, string userName, string userEmail)
    {
        logger.LogInformation("Sending welcome email to {email} with user id {userId} and user name {userName}",
            userEmail, userId, userName);

        await Task.Delay(TimeSpan.FromMilliseconds(300));

        return true;
    }
}

For demonstration purposes, our SendWelcomeEmailAsync() method logs to the console and returns true, incorporating an artificial delay to simulate sending an actual email.

Next, we register this service in the DI container along with its dependency on TimeProvider:

builder.Services
    .AddSingleton<TimeProvider>(TimeProvider.System)
    .AddSingleton<IEmailService, EmailService>();

User Registration Endpoint

With our notification service set up, let’s implement a /register-user endpoint and use it. First, we define an AddUserRequest record:

public record AddUserRequest(string Name, string Email);

Finally, using the email notification service and the AddUserRequest, we create the endpoint:

app.MapPost("/register-user", async (AddUserRequest addUserRequest,
                                     IEmailService emailService,
                                     ApplicationDbContext dbContext,
                                     CancellationToken cancellationToken) =>
{
    var user = new User { Name = addUserRequest.Name, Email = addUserRequest.Email };

    using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);

    try
    {
        dbContext.Users.Add(user);

        await dbContext.SaveChangesAsync(cancellationToken);

        var emailSent = await emailService.SendWelcomeEmailAsync(user.Id, user.Name, user.Email);
        if (!emailSent)
            throw new Exception("Failed to send welcome email");

        await transaction.CommitAsync(cancellationToken);

        return Results.Ok();
    }
    catch (Exception)
    {
        await transaction.RollbackAsync(cancellationToken);

        return Results.Problem(detail: "Registration failed!", statusCode: StatusCodes.Status500InternalServerError);
    }
});

If the registration succeeds, we get the newly generated ID and pass it to the email-sending method. If the welcome email also succeeds, we commit the transaction. Conversely, if it fails, we roll back the transaction and abort the operation.

Running the Application

Finally, with everything set up, let’s run our application and check the console output during a successful user registration:

info: EfCoreInterceptors.DbContextInterceptors.ConnectionInterceptor[0]
      Connection opened
info: EfCoreInterceptors.DbContextInterceptors.ValidateEntitiesStateInterceptor[0]
      Validating entities
info: EfCoreInterceptors.DbContextInterceptors.AuditableEntitiesInterceptor[0]
      Auditing entities
info: EfCoreInterceptors.Services.EmailService[0]
      Sending welcome email to [email protected] with user id 1 and user name jason.st56
info: EfCoreInterceptors.DbContextInterceptors.TransactionInterceptor[0]
      Transaction successful 0cd17b7e-2d6c-4480-b156-5557ff4dba89
info: EfCoreInterceptors.DbContextInterceptors.ConnectionInterceptor[0]
      Connection closed

As expected, the first interceptor handles the connection, followed by entity validation, auditing, transaction handling, and finally, closing the connection.

Benefits of EF Core Interceptors

Firstly, interceptors can log and monitor database operations, providing insights into query execution, performance, and potential issues. This helps us understand and optimize our database interactions.

Additionally, interceptors can track changes to entities and store this information for audit trails. This is crucial for maintaining a history of changes for regulatory compliance or internal tracking.

Furthermore, we can insert custom business logic before or after certain operations. For example, a soft delete mechanism can modify delete commands to update a flag instead of physically deleting the record.

Moreover, interceptors can enhance security by inspecting and modifying queries to prevent SQL injection or enforce security policies. This adds an extra layer of protection to our applications.

In addition, interceptors can handle database transactions to ensure consistency and integrity. This is useful for implementing custom logic, such as retrying transactions in case of transient failures.

Finally, we can perform operations on entities, such as updating their version to handle optimistic concurrency or validating them before saving them to the database.

Conclusion

In conclusion, by leveraging EF Core Interceptors, we enhance the flexibility and control of our data interactions. This ensures our applications are robust and maintainable. Our projects likely contain logic that fits perfectly with the interceptor concept. Let’s revisit our code and benefit from the power and cleanliness of Interceptors. Stay tuned for our next article!

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!