Part #3: Using Interceptors With Entity Framework Core
Create an audit table & build the interceptor
In this series, we have created a new Minimal API from scratch. We have also set up Entity Framework Core with SQLite. Then in Part #2 we built out all our CRUD endpoints and installed Swagger to help us make requests to our database.
The final stage is to build out our interceptor.
Create an Audit table in the database
Our ultimate aim is to save a new EmployeeAudit
in a database table. The new database table stores details on modifications to our Employee
entity.
Start by creating a new EmployeeAudit
entity inside our Entities
folder. We want to record:
- The
Id
of theEmployee
that has been modified - The modification
Type
(Added
,Updated
orChanged
) - The full name of the modified
Employee
- If it is a new entry, then record the
CreatedAt
time. Append the data type with a?
to allow the field to be nullable. - If the entry is being modified, then record the
ModifiedAt
time. Append the data type with a?
to allow the field to be nullable.
public class EmployeeAudit
{
public int Id { get; set; }
public int EmployeeId { get; set; }
public string Type { get; set; }
public string FullName { get; set; }
public DateTime? CreatedAt { get; set; }
public DateTime? ModifiedAt { get; set; }
}
Now add a new DbSet
inside DbContext
to inform Entity Framework Core. The next Migration will now acknowledge our new EmployeeAudit
.
public class DataContext : DbContext
{
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
public DbSet<Employee> Employees { get; set; }
public DbSet<EmployeeAudit> EmployeeAudits { get; set; }
}
In the terminal run a new migration:
dotnet ef migrations add CreateEmployeeAuditsTable
We now have a new Migration file inside our Migrations
folder that details how our database will change. Permitting you are happy with the Migration file you can now update the database:
dotnet ef database update
In a previous article, we set up DB Browser for SQL Lite to view our SQLite database. If you open the database file using this tool you will see your new EmployeeAudit
table.
Adding your Interceptor
As mentioned before, we will work with the SaveChangesInterceptor
. Inheriting from this class allows us to add behaviour before or after saving changes.
We now need to create an interceptor. Inside the Data
folder create a new file called EmployeeAuditInterceptor
. Have our new class inherit from SaveChangesInterceptor
:
using Microsoft.EntityFrameworkCore.Diagnostics;
public class EmployeeAuditInterceptor : SaveChangesInterceptor
{
//
}
ISaveChangesInterceptor
has multiple methods that we can override:
SavingChangesAsync
SaveChangesFailedAsync
SavedChangesAsync
(full list available here)
When we save an Entity, we invoke each of the methods above at different moments. We are going to intercept the step before we save a specific Entity. We will override the SavingChangesAsync
method.
The most important argument for our use case is DbContextEventData
. DbContextEventData
is a payload class for events that reference a DbContext
. The class holds the context for any changes that have affected our Entities.
In the below code, we have a check to see if our DbContextEventData
has any Context
. To phrase it differently: has anything changed in our database?
public class EmployeeAuditInterceptor : SaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken)
{
if (eventData.Context is not null)
{
}
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
We will add a new method called UpdateAuditableEntities()
inside this conditional check. Our new method will gather together all the affected Entities within our Context
. We will then check the State of these affected Entities: Added, Modified, Deleted, Detached
or Unchanged
. We are only interested in the first three options.
UpdateAuditableEntities()
takes the Context
as an argument:
public class EmployeeAuditInterceptor : ISaveChangesInterceptor
{
public override async ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken)
{
if (eventData.Context is not null)
{
UpdateAuditableEntities(eventData.Context);
}
return await base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
The first step is to get all the changed Entities and build a collection. To get all of the changed entities in our database we need to use the ChangeTracker
. The ChangeTracker
can review the DbContext
instance and see what has changed. ChangeTracker
has a property called Entries
that holds all the changed Entities.
When we call to get all of the Entries
inside the ChangeTracker
we will limit what it returns to any Entity that inherits from IAuditable
.
private async void UpdateAuditableEntities(DbContext eventDataContext)
{
var entities = eventDataContext.ChangeTracker.Entries<IAuditable>()
.Where(e => e.State is not (EntityState.Detached or EntityState.Unchanged))
.ToList();
//
}
Now we have got our list of Entries
we can loop through them. With each iteration, we call a new AddAuditEntryAsync
method which takes two arguments:
- The
EntityEntry
we are currently on - The
DbContext
private async void UpdateAuditableEntities(DbContext eventDataContext)
{
// our collection of modified Entities
var entities = eventDataContext.ChangeTracker.Entries<IAuditable>()
.Where(e => e.State is not (EntityState.Detached and EntityState.Unchanged))
.ToList();
foreach (var entity in entities)
{
// loop through each modified Entity and create a new EmployeeAudit
await AddAuditEntryAsync(entity, eventDataContext);
}
}
The new AddAuditEntryAsync
method has two main responsibilities. First, the method will create an EmployeeAudit
entity using the passed Entity argument. Then it will save the new EmployeeAudit
entity using the provided DbContext
:
private async Task AddAuditEntryAsync(EntityEntry<IAuditable> entity, DbContext context)
{
DateTime utcNow = DateTime.UtcNow;
var auditEntry = new EmployeeAudit
{
EmployeeId = (int)entity.Property(nameof(Employee.Id)).CurrentValue,
CreatedAt = entity.State == EntityState.Added ? utcNow : null,
ModifiedAt = entity.State == EntityState.Added ? null : utcNow,
Type = entity.State.ToString(),
FullName = $"{(string)entity.Property(nameof(Employee.FirstName)).CurrentValue} {(string)entity.Property(nameof(Employee.LastName)).CurrentValue}"
};
context.Set<EmployeeAudit>().Add(auditEntry);
}
Rather than a concrete Employee
object we are dealing with an EntityEntry
. As a result, it is not straightforward to access its fields. Instead, we need to traverse the Property
field and get the CurrentValue
. The Property
method takes a propertyName
value to target the data you need. We then cast it back to the data type our EmployeeAudit
field expects.
For CreatedAt
and ModifiedAt
we are running a check on its entity.State
. As detailed earlier, we only want to update the CreatedAt
field if the Entity
in question is new. We only want to update the ModifiedAt
field if the Entity
was either Updated
or Deleted
.
Register the interceptor
We register the interceptor in our DbContext
for our app to pick it up.
Return to the DataContext
class and override the OnConfiguring
method. OnConfiguring
takes a DbContextOptonsBuilder
argument. The DbContextOptonsBuilder
object exposes an AddInterceptors()
method. We will use AddInterceptors()
to register our interceptor.
On optionsBuilder
invoke the AddInterceptors()
method and create a new instance of your EntityModifiedAuditInterceptor
class:
public class DataContext : DbContext
{
public DataContext(DbContextOptions<DataContext> options) : base(options)
{
}
public DbSet<Employee> Employees { get; set; }
public DbSet<EmployeeAudit> EmployeeAudits { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.AddInterceptors(
new EntityModifiedAuditInterceptor()
);
}
}
Test the interceptor
With everything now wired up your interceptor is ready to work. Be sure you are at the Project’s root and enter dotnet run
in the terminal.
Visit your Swagger page and use the endpoints. Create, Update and Delete some Employee
entries and then open DB Browser for SQLite.
Go to ‘Browse Data’ and select your EmployeeAudit
database table. You should see new entries in there to reflect your completed API requests.
Conclusion
In this series of articles, we have created a powerful Minimal API. Our app allows us to run key CRUD operations against a database and use interceptors to create a log of these actions in a separate table. All of the interceptor logic lives in a single location which will be easy to maintain and extend in the future. To complement the lightweight nature of Minimal APIs we have also learned about SQLite databases and how we can use a tool like DB Browser for SQLite to view our data.
🙌 🙌