
In this blog post, we will be implementing an automatic audit trail of changes made to our entities. Our audit trail will automatically track and record when an entity is added, when an entity is modified, and when an entity is deleted.
As well as recording when an entity is modified, our change audit will also record the old and new values of any properties that have been modified.
We will achieve our audit logs being created automatically by overriding the SaveChangesAsync method of DbContext to write our logs before the entities are saved.
We will be implementing an IAuditable interface so that we can choose which entities we want to create audit trails for.
Our audit trail will also incorporate versioning and will auto-increment the version number of the entity.
In this tutorial, we will be building a .NET 6.0 Blazor application. However, this tutorial applies to any project that uses Entity Framework Core.
Our .NET 6.0 Blazor application will be authenticated using individual accounts, meaning that we will also obtain and store the user that made changes to any entities in our audit trail if they are logged in.
Before building our Entity Framework Core audit trail, we first need to build our audit table and our CRUD.
IAuditable interface & audit table
Let’s first create the IAuditable interface:
public interface IAuditable { }
We will use this interface solely to differentiate between entities that we do and do not want to audit, hence it contains no properties.
Next, create the following ChangeAudit entity where our audit log will be saved:
public class ChangeAudit { public int Id { get; set; } public string EntityName { get; set; } public string Action { get; set; } public string EntityIdentifier { get; set; } public string? PropertyName { get; set; } public string? OldValue { get; set; } public string? NewValue { get; set; } public string? UserName { get; set; } public DateTime TimeStamp { get; set; } }
Building the CRUD operations
Let’s create our entity to be audited:
public class Contact : IAuditable { public int Id { get; set; } public int Version { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public DateTime DateOfBirth { get; set; } public string Email { get; set; } public string Phone { get; set; } }
Next, we’ll create appropriate DTOs for the Contact table and form:
public class ContactRowDTO { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } }
public class CreateEditContactDTO { public int Id { get; set; } [Required] [StringLength(30)] public string FirstName { get; set; } [Required] [StringLength(30)] public string LastName { get; set; } [Required] public DateTime DateOfBirth { get; set; } [Required] [EmailAddress] [StringLength(30)] public string Email { get; set; } [Required] [DataType(DataType.PhoneNumber)] public string Phone { get; set; } }
Now we will implement our repository pattern:
public interface IContactRepository { Task CreateContact(CreateEditContactDTO createEditContactDTO); Task DeleteContact(int id); Task<ContactRowDTO[]> GetContacts(); Task<CreateEditContactDTO> GetCreateEditContactDTO(int id); Task UpdateContact(CreateEditContactDTO createEditContactDTO); } public class ContactRepository : IContactRepository { private readonly ApplicationDbContext context; public ContactRepository(ApplicationDbContext context) { this.context = context; } public async Task<ContactRowDTO[]> GetContacts() { return await context .Contacts .OrderBy(c => c.LastName) .Select(c => new ContactRowDTO { Id = c.Id, FirstName = c.FirstName, LastName = c.LastName }).ToArrayAsync(); } public async Task<CreateEditContactDTO> GetCreateEditContactDTO(int id) { if (id == 0) return new CreateEditContactDTO(); var contact = await context .Contacts .FindAsync(id); return new CreateEditContactDTO { Id = contact.Id, FirstName = contact.FirstName, LastName = contact.LastName, DateOfBirth = contact.DateOfBirth, Email = contact.Email, Phone = contact.Phone }; } public async Task CreateContact(CreateEditContactDTO createEditContactDTO) { var contact = new Contact { FirstName = createEditContactDTO.FirstName, LastName = createEditContactDTO.LastName, DateOfBirth = createEditContactDTO.DateOfBirth, Email = createEditContactDTO.Email, Phone = createEditContactDTO.Phone }; await context.Contacts.AddAsync(contact); await context.SaveChangesAsync(); } public async Task UpdateContact(CreateEditContactDTO createEditContactDTO) { var contact = await context .Contacts .FindAsync(createEditContactDTO.Id); contact.FirstName = createEditContactDTO.FirstName; contact.LastName = createEditContactDTO.LastName; contact.DateOfBirth = createEditContactDTO.DateOfBirth; contact.Email = createEditContactDTO.Email; contact.Phone = createEditContactDTO.Phone; context.Contacts.Update(contact); await context.SaveChangesAsync(); } public async Task DeleteContact(int id) { var contact = await context.Contacts.FindAsync(id); context.Contacts.Remove(contact); await context.SaveChangesAsync(); } }
Now we can create our views. Let’s start with the Contacts table:
@page "/contacts" <h3>Contacts</h3> @{ var createContactUrl = "contact/create"; <button @onclick="() => navigationManager.NavigateTo(createContactUrl)" class="btn btn-link">Create</button> } @if (contacts == null) { <p>Loading...</p> } else if (!contacts.Any()) { <p>No records.</p> } else { <table class="table"> <thead> <tr> <th scope="col">First</th> <th scope="col">Last</th> <th scope="col"></th> </tr> </thead> <tbody> @foreach (var contact in contacts) { var editContactUrl = $"contact/edit/{contact.Id}"; <tr> <td>@contact.FirstName</td> <td>@contact.LastName</td> <td> <button @onclick="() => navigationManager.NavigateTo(editContactUrl)" class="btn btn-link">Edit</button> <button @onclick="() => DeleteContact(contact.Id)" class="btn btn-link">Delete</button> </td> </tr> } </tbody> </table> } @code { [Inject] NavigationManager navigationManager { get; set; } = default!; [Inject] IContactRepository contactRepository { get; set; } = default!; ContactRowDTO[]? contacts; protected override async Task OnInitializedAsync() => await GetContacts(); private async Task GetContacts() => contacts = await contactRepository.GetContacts(); private async Task DeleteContact(int id) { await contactRepository.DeleteContact(id); await GetContacts(); } }
Finally, we can create our Contact form:
@page "/contact/create" @page "/contact/edit/{id:int}" <h3>CreateEditContact</h3> @if (contact == null) { <p>Loading...</p> } else { <EditForm Model="@contact" OnValidSubmit="@HandleValidSubmit"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="row mb-3"> <div class="col"> <label for="FirstName" class="form-label">First Name</label> <InputText id="FirstName" @bind-Value="contact.FirstName" class="form-control" /> </div> </div> <div class="row mb-3"> <div class="col"> <label for="LastName" class="form-label">Last Name</label> <InputText id="LastName" @bind-Value="contact.LastName" class="form-control" /> </div> </div> <div class="row mb-3"> <div class="col"> <label for="DateOfBirth" class="form-label">Date of Birth</label> <InputDate id="DateOfBirth" @bind-Value="contact.DateOfBirth" class="form-control" /> </div> </div> <div class="row mb-3"> <div class="col"> <label for="Email" class="form-label">Email</label> <InputText id="Email" @bind-Value="contact.Email" class="form-control" /> </div> </div> <div class="row mb-3"> <div class="col"> <label for="Phone" class="form-label">Phone</label> <InputText id="Phone" @bind-Value="contact.Phone" class="form-control" /> </div> </div> <button type="submit" class="btn btn-primary">Submit</button> </EditForm> } @code { [Parameter] public int Id { get; set; } [Inject] IContactRepository contactRepository { get; set; } = default!; [Inject] NavigationManager navigationManager { get; set; } = default!; CreateEditContactDTO? contact = null; protected override async Task OnParametersSetAsync() => contact = await contactRepository.GetCreateEditContactDTO(Id); private async Task HandleValidSubmit() { if (Id == 0) await contactRepository.CreateContact(contact); else await contactRepository.UpdateContact(contact); navigationManager.NavigateTo("contacts"); } }
Implementing the Audit Trail
We will implement our automatic audit trail by overriding the SaveChangesAsync method of our DbContext and including the method AuditChanges:
public class ApplicationDbContext : IdentityDbContext { AuthenticationStateProvider authenticationStateProvider; public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options, AuthenticationStateProvider authenticationStateProvider) : base(options) { this.authenticationStateProvider = authenticationStateProvider; } public DbSet<Contact> Contacts { get; set; } public DbSet<Address> Addresses { get; set; } public DbSet<ChangeAudit> ChangeAudits { get; set; } public override async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default) { await AuditChanges(); return await base.SaveChangesAsync(cancellationToken); } private async Task AuditChanges() { DateTime now = DateTime.Now; var entityEntries = ChangeTracker.Entries() .Where(x => x.State == EntityState.Added || x.State == EntityState.Modified || x.State == EntityState.Deleted).ToList(); foreach (EntityEntry entityEntry in entityEntries) { IncrementVersionNumber(entityEntry); if (entityEntry.Entity is IAuditable) await CreateAuditAsync(entityEntry, now); } } private void IncrementVersionNumber(EntityEntry entityEntry) { if (entityEntry.Metadata.FindProperty("Version") != null) { var currentVersionNumber = Convert.ToInt32(entityEntry.CurrentValues["Version"]); entityEntry.CurrentValues["Version"] = currentVersionNumber + 1; } } private async Task CreateAuditAsync(EntityEntry entityEntry, DateTime timeStamp) { if (entityEntry.State == EntityState.Added || entityEntry.State == EntityState.Deleted) { var changeAudit = await GetChangeAuditAsync(entityEntry, timeStamp); await ChangeAudits.AddAsync(changeAudit); } else { foreach (var prop in entityEntry.OriginalValues.Properties) { var originalValue = !string.IsNullOrWhiteSpace(entityEntry.OriginalValues[prop]?.ToString()) ? entityEntry.OriginalValues[prop]?.ToString() : null; var currentValue = !string.IsNullOrWhiteSpace(entityEntry.CurrentValues[prop]?.ToString()) ? entityEntry.CurrentValues[prop]?.ToString() : null; if (originalValue != currentValue) { var changeAudit = await GetChangeAuditAsync(entityEntry, timeStamp); changeAudit.PropertyName = prop.Name; changeAudit.OldValue = originalValue; changeAudit.NewValue = currentValue; await ChangeAudits.AddAsync(changeAudit); } } } } private async Task<ChangeAudit> GetChangeAuditAsync(EntityEntry entityEntry, DateTime timeStamp) { return new ChangeAudit { EntityName = entityEntry.Entity.GetType().Name, Action = entityEntry.State.ToString(), EntityIdentifier = GetEntityIdentifier(entityEntry), UserName = await GetUserNameAsync(), TimeStamp = timeStamp }; } private static string GetEntityIdentifier(EntityEntry entityEntry) { if (entityEntry.Entity is IdentityUser) return $"{entityEntry.CurrentValues["UserName"]}"; else if (entityEntry.Entity is Contact) return $"{entityEntry.CurrentValues["FirstName"]} {entityEntry.CurrentValues["LastName"]}"; else if (entityEntry.Entity is Address) return $"{entityEntry.CurrentValues["AddressLine1"]}, {entityEntry.CurrentValues["Postcode"]}"; return "None"; } private async Task<string?> GetUserNameAsync() { var authenticationState = await authenticationStateProvider.GetAuthenticationStateAsync(); if (authenticationState.User.Identity.IsAuthenticated) return authenticationState.User.Identity.Name; return null; } }
In our AuditChanges method, we first get a list of entities that have been added, modified, or deleted. For each of these entities, we attempt to increment the version number of each entity.
We do this by first seeing if the entity contains a property named Version. If it does, we get its current value and increment by 1.
Next, we create the audit entry for each entity. For all entity states, we save the name of the entity, whether it was added, modified, or deleted, and a timestamp.
Each ChangeAudit also includes an EntityIdentifier field, which acts as another way of identifying which entity the audit relates to. When viewing our audits, the name of a particular contact will be far more meaningful than an ID.
As our application is authenticated using individual accounts, we also attempt to obtain the name of the currently logged-in user.
Finally, if our entity has been modified, we include the name of the property that has been updated, as well as the old and new values.
You can see the results of our audit trail in the image below:

That concludes this tutorial on how to implement an audit trail in Entity Framework Core. The entire source code for this tutorial can be found in this GitHub repository.
hi, i just start learning to create blazor app. c# and .net.
can you share the IAuditTable interface and AuditTable class?
i can figure it out .. and another thing is normally what kind of scope to register in program.cs
I will look to post the full code on GitHub soon. Thanks for reading the blog!
I’ve updated the tutorial to include the IAuditable interface and audit table. The full project can also be viewed on GitHub, hope this helps!
https://github.com/trystanwilcock/EntityFrameworkCoreAuditTrail