If you're using Entity Framework Core and appreciate its robust features, consider taking advantage of interceptors.
EF Core interceptors are a powerful feature that allows us intercept various points of interaction between the EF Core and the underlying database.
They enable the modification or suppression of intercepted operations, allowing us to fine-tune database operations and much more.
This includes low-level database operations such as executing a command, as well as higher-level operations, such as calls to SaveChanges.
How does it works?
Every interceptor instance must implement one or more interface derived from IInterceptor.
Out of the box, there are several abstract base classes for various interceptions, enabling you to build your first interceptor with minimal boilerplate code and a focus on custom logic:
- DbConnectionInterceptor
- TransactionInterceptor
- DbCommandInterceptor
- SaveChangesInterceptor
We can use interceptors to track, log, modify or cancel operations before or after EF Core executes them.
However, for logging and monitoring, using simple logging or Microsoft.Extensions.Logging is more likely a better option.
I typically use interceptors for:
- Audit
- Soft delete
To implement these interceptors, we use the SaveChangesInterceptor.
NOTE: There's no single solution, different implementations can still effectively help you achieve your goals.
Implement Interceptor for Auditable Entities
In this implementation, we will see how to automatically update audit properties for entities, ensuring consistent audit fields without cluttering the business logic or the SaveChanges method.
The first step is defining an interface that marks entities requiring auditing. This interface includes properties for the creation and modification timestamps.
public interface IAuditableEntity
{
public DateTime CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
}
Any entity implementing the IAuditableEntity interface will automatically include properties or methods to track audit-related information.
public sealed class Product : IAuditableEntity
{
public Guid Id { get; set; }
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
public Product(Guid id, string name, string description, decimal price)
{
Id = id;
Name = name;
Description = description;
Price = price;
}
public void Update(string name, string description, decimal price)
{
Name = name;
Description = description;
Price = price;
}
}
Next, we need to define a custom SaveChangesInterceptor to ensure that audit-related fields are automatically updated whenever changes are saved to the database.
public sealed class UpdateAuditableEntitiesInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is null)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
UpdateAuditableEntities(eventData.Context!);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void UpdateAuditableEntities(DbContext dbContext)
{
foreach (var entityEntry in dbContext.ChangeTracker.Entries<IAuditableEntity>())
{
if (entityEntry.State == EntityState.Added)
{
entityEntry.Property(nameof(IAuditableEntity.CreatedAt)).CurrentValue = DateTime.UtcNow;
}
if (entityEntry.State == EntityState.Modified)
{
entityEntry.Property(nameof(IAuditableEntity.ModifiedAt)).CurrentValue = DateTime.UtcNow;
}
}
}
}
The interceptor works by overriding the SavingChangesAsync method, which is triggered before changes are committed to the database.
The UpdateAuditableEntities method is where the core functionality resides. It iterates over all entities tracked by EF Core’s ChangeTracker that implements the IAuditableEntity interface.
For each entity, the method checks its state:
- If the state is Added, indicating the entity is new, the method sets its CreatedAt property to the current UTC time.
- If the state is Modified, meaning the entity has been updated, the ModifiedAt property is updated to the current UTC time.
The last step can be found in the Register Interceptors section.
Implement Interceptor for Soft Delete
Just as we handle auditing, we can also implement soft deletion.
Unlike hard delete, which permanently removes a record from the database, soft delete simply marks the record as deleted while keeping it in the database.
I'll be diving deeper into soft delete in my upcoming blog post.
As we did previously, we'll create an interface to define the necessary properties for soft deletable entities.
By utilizing ISoftDeletableEntity interface, we can apply soft delete exclusively to entities where preserving historical data is essential, while allowing hard deletes for others.
In this example, our interface will include an IsDeleted flag to indicate whether the record is marked as deleted, and a DeletedAt property to store the timestamp of the deletion.
public interface ISoftDeletableEntity
{
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
}
After updating the product entity to implement the ISoftDeletableEntity interface, we can dive into the implementation of the SoftDeleteInterceptor:
public class SoftDeleteInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData,
InterceptionResult<int> result,
CancellationToken cancellationToken = default)
{
if (eventData.Context is null)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
UpdateAuditableEntities(eventData.Context!);
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
private static void UpdateAuditableEntities(DbContext dbContext)
{
var entries = dbContext.ChangeTracker.Entries<ISoftDeletableEntity>()
.Where(e => e.State == EntityState.Deleted);
foreach (var entry in entries)
{
entry.State = EntityState.Modified;
entry.Entity.IsDeleted = true;
entry.Entity.DeletedAt = DateTime.UtcNow;
}
}
}
This interceptor also leverages EF Core's SaveChangesInterceptor but in this case we are converting hard delete operations into soft delete actions.
When an entity implementing the ISoftDeletableEntity interface is marked for deletion, this interceptor modifies the operation. It does this by updating the entity's state to Modified, setting the IsDeleted flag to true, and populating the DeletedAt property with the current timestamp.
Since we touched on the topic of soft deletes, if you want to generally exclude entities that are soft-deleted, you can use a global filter. This will automatically exclude them instead of requiring manual exclusion repeatedly.
You can achieve this within the OnModelCreating method by utilizing the HasQueryFilter method:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder
.Entity<Product>()
.HasQueryFilter(p => !p.IsDeleted);
// ...
}
Alternatively, you can define your filter inside an IEntityTypeConfiguration implementation.
Register Interceptors
When working with interceptors, the last step is to register them. There are two common approaches I know:
- By registering your interceptors in the DI container ensure that they are available for injection and managed by the DI. Once the interceptors are registered, you can add them to the DbContext configuration when setting up your DbContext.
- Alternatively, you can directly add interceptors inside the OnConfiguring method of the DbContext, which does not require DI but hardcodes the interceptor registration.
services.AddSingleton<UpdateAuditableEntitiesInterceptor>();
services.AddSingleton<SoftDeleteInterceptor>();
services.AddDbContext<ApplicationDbContext>((serviceProvider, options) =>
options
.UseSqlServer(configuration.GetConnectionString("Database"))
.AddInterceptors(
serviceProvider.GetRequiredService<UpdateAuditableEntitiesInterceptor>(),
serviceProvider.GetRequiredService<SoftDeleteInterceptor>()));
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.AddInterceptors(
new UpdateAuditableEntitiesInterceptor(),
new SoftDeleteInterceptor());
Each instance should be registered only once, even if it implements multiple interception interfaces.
EF Core will invoke the registered interceptors at the appropriate stages based on their type.
By properly registering interceptors, you ensure that custom logic is applied across your application's data operations.
Conclusion
With interceptors, you can tailor EF Core’s behavior to fit your specific needs, making your application easier to manage.
They are definitely powerful tool, that allows us to modify and enhance data operations.
By implementing interceptors such as SaveChangesInterceptor, you can automate tasks like auditing and soft deletes without cluttering your business logic.
If you want to check out examples I created, you can find the source code here:
Source CodeI hope you enjoyed it, subscribe and get a notification when a new blog is up!