Part #3: Using Interceptors With Entity Framework Core

Create an audit table & build the interceptor

Nathan
The Tech Collective
6 min readMay 22, 2024

--

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 the Employee that has been modified
  • The modification Type (Added, Updated or Changed)
  • 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.

The EmployeeAudits table seen inside DB Browser for SQL Lite

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:

  1. The EntityEntry we are currently on
  2. 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.

🙌 🙌

--

--