Sometimes, there are instances where you want to automatically set an entity's properties during save, or you want to audit changes to an entity. Entity Framework Core has a couple of features that can help with this.
Determining who made a change to an entity and when
There are times where you want to know who made a change to an entity. This is often needed for auditing purposes, or to ensure that the correct user is credited for creating/updating an entity.
For this very simple example, I like to reach for a common base class that contains these properties:
public abstract class AuditableEntity
{
public string? CreatedBy { get; set; }
public DateTime? CreatedOn { get; set; }
public string? LastModifiedBy { get; set; }
public DateTime? LastModifiedOn { get; set; }
}
(We're declaring the props as nullable to make migrating a little easier for this course, but in practice you would likely have to set them to something as you would not want them to be nullable.)
This class is declared as abstract because it's intended to be inherited by our models... which we will do now. Let's say for now we only want to track changes to the Employee
entity.
public class Employee : AuditableEntity
{
}
You would then add a migration and update the database:
dotnet ef migrations add EmployeeAuditableFields
We review the migration to make sure that it makes sense. Afterwards, you can update the database by running the application.
Auditing changes to an entity
We don't want to have to go through and manually set the LastModifiedBy
and LastModifiedOn
properties whenever we update an entity in every endpoint - I have a limit to the boilerplate I want to write. Luckily, we can override a few methods in our DbContext
to have EF Core set these properties for us.
We do this by overriding the SaveChanges
and SaveChangesAsync
methods.
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options)
{
}
public DbSet<Employee> Employees { get; set; }
public DbSet<Benefit> Benefits { get; set; }
public DbSet<EmployeeBenefit> EmployeeBenefits { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EmployeeBenefit>()
.HasIndex(eb => new { eb.EmployeeId, eb.BenefitId })
.IsUnique();
}
public override int SaveChanges()
{
UpdateAuditFields();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
UpdateAuditFields();
return base.SaveChangesAsync(cancellationToken);
}
private void UpdateAuditFields()
{
var entries = ChangeTracker.Entries<AuditableEntity>();
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedBy = "TheCreateUser";
entry.Entity.CreatedOn = DateTime.UtcNow;
}
if (entry.State == EntityState.Modified)
{
entry.Entity.LastModifiedBy = "TheUpdateUser";
entry.Entity.LastModifiedOn = DateTime.UtcNow;
}
}
}
}
Let's test our endpoint to see if this worked as we expect. Let's run the application and make a PUT
request to our Employees
endpoint.
Browse the data with SQLTools, and you should see that the LastModifiedBy
and LastModifiedOn
fields are being set for updated employees.
NOTE: we really want to capture the real user in the real application, and not just "TheUpdateUser". We will do that in the section on security.
Altering our tests to ensure these values are being set
We should also update our tests to ensure that these values are being set.
One challenge we have: the date fields are all being set for DateTime.UtcNow
in the UpdateAuditFields
method. This makes it a bit harder to test, because we can't set them to a specific value in the test. Or CAN WE?
I use the ISystemClock
service to help me with this.
Register it with your IServiceCollection
in Program.cs
:
using Microsoft.Extensions.Internal;
builder.Services.AddSingleton<ISystemClock, SystemClock>();
Then use ISystemClock
in your AppDbContext
like so:
public class AppDbContext : DbContext
{
private readonly ISystemClock _systemClock;
public AppDbContext(DbContextOptions<AppDbContext> options, ISystemClock systemClock) : base(options)
{
this._systemClock = systemClock;
}
public DbSet<Employee> Employees { get; set; }
public DbSet<Benefit> Benefits { get; set; }
public DbSet<EmployeeBenefit> EmployeeBenefits { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<EmployeeBenefit>()
.HasIndex(eb => new { eb.EmployeeId, eb.BenefitId })
.IsUnique();
}
public override int SaveChanges()
{
UpdateAuditFields();
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
UpdateAuditFields();
return base.SaveChangesAsync(cancellationToken);
}
private void UpdateAuditFields()
{
var entries = ChangeTracker.Entries<AuditableEntity>();
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedBy = "TheCreateUser";
entry.Entity.CreatedOn = _systemClock.UtcNow.UtcDateTime;
}
if (entry.State == EntityState.Modified)
{
entry.Entity.LastModifiedBy = "TheUpdateUser";
entry.Entity.LastModifiedOn = _systemClock.UtcNow.UtcDateTime;
}
}
}
}
Then we can update our tests to use a hardcoded ISystemClock
in the test, then check to make sure that value is getting set on update.
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
public static TestSystemClock SystemClock { get; } = new TestSystemClock();
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
builder.ConfigureServices(services =>
{
var dbContextDescriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbContextOptions<AppDbContext>));
services.Remove(dbContextDescriptor);
var dbConnectionDescriptor = services.SingleOrDefault(
d => d.ServiceType ==
typeof(DbConnection));
services.Remove(dbConnectionDescriptor);
// Create open SqliteConnection so EF won't automatically close it.
services.AddSingleton<DbConnection>(container =>
{
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
return connection;
});
services.AddDbContext<AppDbContext>((container, options) =>
{
var connection = container.GetRequiredService<DbConnection>();
options.UseSqlite(connection);
});
var systemClockDescriptor = services.Single(d => d.ServiceType == typeof(ISystemClock));
services.Remove(systemClockDescriptor);
services.AddSingleton<ISystemClock>(SystemClock);
});
}
}
public class TestSystemClock : ISystemClock
{
public DateTimeOffset UtcNow { get; } = DateTimeOffset.Parse("2022-01-01T00:00:00Z");
}
Our test code looks like this now:
[Fact]
public async Task UpdateEmployee_ReturnsOkResult()
{
var client = _factory.CreateClient();
var response = await client.PutAsJsonAsync("/employees/1", new Employee {
FirstName = "John",
LastName = "Doe",
Address1 = "123 Main Smoot"
});
if (!response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
Assert.Fail($"Failed to update employee: {content}");
}
using var scope = _factory.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var employee = await db.Employees.FindAsync(1);
Assert.Equal("123 Main Smoot", employee.Address1);
Assert.Equal(CustomWebApplicationFactory.SystemClock.UtcNow.UtcDateTime, employee.LastModifiedOn);
}
Run your tests and you should see that the LastModifiedOn
field is being set to the current time.