Audit trails are a very, very common requirement in software development. And if you are using SQL Server (or MariaDB) it's really easy to use a Temporal Table and track everything, that happens to your data. Just let your expensive database handle it!
Now a common example in EntityFramework Core articles is to add auditing by overriding the DbContext.SaveChanges
method
and add the audit information by inspecting the ChangeTracker
. Some tutorials also use an SaveChangesInterceptor
to add
it as a cross-cutting concern.
The problem with this kind of approach to auditing is, that it doesn't work for the EF Core 7 ExecuteUpdateAsync
methods, that basically bypass the DbContext.SaveChangesAsync
method. So this example serves as a word of caution and
also shows how to work with Temporal tables instead.
Please feel free to make a PR to the repository and fix this issue, because I don't know of a EntityFramework Core-based solution:
Table of contents
The common EntityFramework Core Example
So let's first recreate the common example for writing audit trails, that's shown in many videos and tutorials.
We start by adding an abstract AuditableEntity
, which all audited entities derive from:
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
namespace EfCoreAudit.Model
{
/// <summary>
/// Audit Information.
/// </summary>
public abstract class AuditableEntity
{
/// <summary>
/// Gets or sets the created date.
/// </summary>
public DateTime? CreatedDateTime { get; set; }
/// <summary>
/// Gets or sets the modified date.
/// </summary>
public DateTime? ModifiedDateTime { get; set; }
}
}
Next we add a Person
entity, that extends an AuditableEntity
:
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
namespace EfCoreAudit.Model
{
/// <summary>
/// A Person in the application.
/// </summary>
internal class Person : IAuditableEntity
{
/// <summary>
/// Gets or sets the Id.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the Name.
/// </summary>
public required string FullName { get; set; }
}
}
We can then define the ApplicationDbContext
, which will be used in the application and configure the Person
using a ModelBuilder
:
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using EfCoreAudit.Model;
using Microsoft.EntityFrameworkCore;
namespace EfCoreAudit.Context
{
internal class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Person> People { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasSequence<int>("sq_Person", schema: "Application")
.StartsAt(1000)
.IncrementsBy(1);
modelBuilder.Entity<Person>(entity =>
{
entity.ToTable("Person", "Application");
entity.HasKey(e => e.Id);
entity.Property(x => x.Id)
.HasColumnType("INT")
.HasDefaultValueSql("NEXT VALUE FOR [Application].[sq_Person]")
.ValueGeneratedOnAdd();
entity.Property(e => e.FullName)
.HasColumnType("NVARCHAR(255)")
.HasColumnName("FullName")
.IsRequired(true)
.HasMaxLength(255);
entity.Property(e => e.CreatedDateTime)
.HasColumnType("DATETIME2(7)")
.HasColumnName("CreatedDateTime")
.IsRequired(false);
entity.Property(e => e.ModifiedDateTime)
.HasColumnType("DATETIME2(7)")
.HasColumnName("ModifiedDateTime")
.IsRequired(false);
});
base.OnModelCreating(modelBuilder);
}
}
}
Then a SaveChangesInterceptor
can be implemented, that overrides the SavingChangesAsync
method. It then
inspects, if entities of type AuditableEntity
have been added or modified and then sets their ModifiedDateTime
accordingly.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using EfCoreAudit.Model;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace EfCoreAudit.Database.Interceptors
{
/// <summary>
/// A <see cref="SaveChangesInterceptor"/> for adding auditing metadata.
/// </summary>
internal class AuditingInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, InterceptionResult<int> result, CancellationToken cancellationToken = default)
{
DbContext ctx = eventData.Context!;
if (ctx == null)
{
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
var auditableEntities = ctx.ChangeTracker.Entries<AuditableEntity>().ToList();
foreach (var auditableEntity in auditableEntities)
{
if (auditableEntity.State == EntityState.Added)
{
auditableEntity.Property(x => x.CreatedDateTime).CurrentValue = DateTime.UtcNow;
}
if (auditableEntity.State == EntityState.Modified)
{
auditableEntity.Property(x => x.ModifiedDateTime).CurrentValue = DateTime.UtcNow;
}
}
return base.SavingChangesAsync(eventData, result, cancellationToken);
}
}
}
In the TransactionalTestBase
class, we can see how the AuditingInterceptor
is configured when
creating an ApplicationDbContext
. The TransactionalTestBase
class can be used by all tests and
basically executes everything in a TransactionScope
:
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using EfCoreAudit.Context;
using EfCoreAudit.Database.Interceptors;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using NUnit.Framework;
namespace EfCoreAudit.Tests
{
/// <summary>
/// Will be used by all integration tests, that need an <see cref="ApplicationDbContext"/>.
/// </summary>
internal class TransactionalTestBase
{
// ...
/// <summary>
/// Builds an <see cref="ApplicationDbContext"/> based on a given Connection String
/// and enables sensitive data logging for eventual debugging.
/// </summary>
/// <param name="connectionString">Connection String to the Test database</param>
/// <returns>An initialized <see cref="ApplicationDbContext"/></returns>
private ApplicationDbContext GetApplicationDbContext(string connectionString)
{
var dbContextOptionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>()
.AddInterceptors(new AuditingInterceptor())
.UseSqlServer(connectionString);
return new ApplicationDbContext(dbContextOptionsBuilder.Options);
}
}
}
And finally we can write the integration test, that makes the problem obvious. While all
modifications will be audited for calls to DbContext#SaveChanges
, all Bulk Updates using
the more recent ExecuteUpdateAsync
will go unaudited.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using EfCoreAudit.Model;
using EfCoreAudit.Tests;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
namespace EfCoreAudit
{
[TestFixture]
internal class AuditingEntitiesTests : TransactionalTestBase
{
public override async Task OnSetupBeforeTransaction()
{
await _applicationDbContext.Database.EnsureCreatedAsync();
}
[Test]
public async Task AuditingEntites_SaveChanges_SetsCreatedDateTime()
{
// Prepare
var person = new Person
{
FullName = "Philipp Wagner"
};
// Act
await _applicationDbContext.AddAsync(person);
await _applicationDbContext.SaveChangesAsync();
// Assert
var people = await _applicationDbContext.People
.AsNoTracking()
.ToListAsync();
Assert.AreEqual(1, people.Count);
Assert.AreEqual("Philipp Wagner", people[0].FullName);
Assert.IsNotNull(people[0].CreatedDateTime);
Assert.IsNull(people[0].ModifiedDateTime);
}
[Test]
public async Task AuditingEntites_SaveChanges_SetsModifiedDateTime()
{
// Prepare
var person = new Person
{
FullName = "Philipp Wagner"
};
await _applicationDbContext.AddAsync(person);
await _applicationDbContext.SaveChangesAsync();
// Act
person.FullName = "Edited Name";
await _applicationDbContext.SaveChangesAsync();
// Assert
var people = await _applicationDbContext.People
.AsNoTracking()
.ToListAsync();
Assert.AreEqual(1, people.Count);
Assert.AreEqual("Edited Name", people[0].FullName);
Assert.IsNotNull(people[0].CreatedDateTime);
Assert.IsNotNull(people[0].ModifiedDateTime);
}
[Test]
public async Task AuditingEntites_ExecuteUpdate_SetsModifiedDateTime()
{
// Prepare
var person = new Person
{
FullName = "Philipp Wagner"
};
await _applicationDbContext.AddAsync(person);
await _applicationDbContext.SaveChangesAsync();
// Act
await _applicationDbContext.People.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.FullName, p => "Edited Name"));
// Assert
var people = await _applicationDbContext.People
.AsNoTracking()
.ToListAsync();
Assert.AreEqual(1, people.Count);
Assert.AreEqual("Edited Name", people[0].FullName);
Assert.IsNotNull(people[0].CreatedDateTime);
Assert.IsNotNull(people[0].ModifiedDateTime);
}
}
}
And boom, you will see that the last test fails. And there is no simple way to fix it, because the
interceptor doesn't know about ExecuteUpdate
. And it also bypasses the ChangeTracker
, so there
is no simple way to inspect it.
So you are basically left with an implementation, that only covers only a few use cases.
I guess the easiest fix would be to override the DbContext#ExecuteUpdateAsync
method and set the
ModifiedDateTime
, but you need somewhat advanced skills to rewrite the Expression
used by
EntityFramework Core (I don't have them).
Amazing!
Temporal Tables to Rescue
There is no one-size-fits-all solution, but I think you shouldn't do auditing using a DbContext
in
your application layer. What if you need to modify data using a Stored Procedure? What if you need to
run migrations or fix data inconsistencies?
A much safer (and sane) way is to use Temporal Tables, if you are working with a Microsoft SQL Server.
Database Schema
We start by creating the Database Schema:
CREATE SCHEMA [Application];
Then we create a Sequence for the PersonID
Primary Key:
CREATE SEQUENCE [Application].[sq_Person]
AS INT
START WITH 38187
INCREMENT BY 1;
And then we can create the [Application].[Person]
table with SYSTEM VERSIONING enabled:
CREATE TABLE [Application].[Person](
[PersonID] INT CONSTRAINT [DF_Application_Person_PersonID] DEFAULT (NEXT VALUE FOR [Application].[sq_Person]) NOT NULL,
[FullName] NVARCHAR(255) NOT NULL,
[ValidFrom] DATETIME2 (7) GENERATED ALWAYS AS ROW START NOT NULL,
[ValidTo] DATETIME2 (7) GENERATED ALWAYS AS ROW END NOT NULL,
CONSTRAINT [PK_Person] PRIMARY KEY ([PersonID]),
PERIOD FOR SYSTEM_TIME (ValidFrom, ValidTo)
) WITH (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [Application].[PersonHistory]));
For our tests, we need to clean the History Table between the tests. But once you have System Versioning enabled on a table, there is no way for EntityFramework Core to delete the historic data. That makes sense, and rightly so!
So we define a Stored Procedure [Application].[usp_Database_ResetForTests]
, that resets all data for us by
first deactivating System Versioning, then deleting all data, and then reactivating System versioning:
CREATE PROCEDURE [Application].[usp_Database_ResetForTests]
AS BEGIN
EXECUTE [Application].[usp_TemporalTables_DeactivateTemporalTables];
EXEC(N'DELETE FROM [Application].[Person]');
EXEC(N'DELETE FROM [Application].[PersonHistory]');
EXECUTE [Application].[usp_TemporalTables_ReactivateTemporalTables];
END
In [Application].[usp_TemporalTables_DeactivateTemporalTables]
we disable System Versioning:
CREATE PROCEDURE [Application].[usp_TemporalTables_DeactivateTemporalTables]
AS BEGIN
IF OBJECTPROPERTY(OBJECT_ID('[Application].[Person]'), 'TableTemporalType') = 2
BEGIN
PRINT 'Deactivate Temporal Table for [Application].[Person]'
ALTER TABLE [Application].[Person] SET (SYSTEM_VERSIONING = OFF);
ALTER TABLE [Application].[Person] DROP PERIOD FOR SYSTEM_TIME;
END
END
In [Application].[usp_TemporalTables_ReactivateTemporalTables]
we reactivate System Versioning:
CREATE PROCEDURE [Application].[usp_TemporalTables_ReactivateTemporalTables]
AS BEGIN
IF OBJECTPROPERTY(OBJECT_ID('[Application].[Person]'), 'TableTemporalType') = 0
BEGIN
PRINT 'Reactivate Temporal Table for [Application].[Person]'
ALTER TABLE [Application].[Person] ADD PERIOD FOR SYSTEM_TIME([ValidFrom], [ValidTo]);
ALTER TABLE [Application].[Person] SET (SYSTEM_VERSIONING = ON (HISTORY_TABLE = [Application].[PersonHistory], DATA_CONSISTENCY_CHECK = ON));
END
END
That's it!
Integration Tests
The AuditableEntity
base class is gone. All our entities are System Versioned, so we just add a
ValidFrom
and ValidTo
timestamp. If you want to know, when the entity was created, you would
find the first entity in the history table.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
namespace EfCoreAudit.Temporal.Model
{
/// <summary>
/// A Person in the application.
/// </summary>
public class Person
{
/// <summary>
/// Gets or sets the Id.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Gets or sets the Name.
/// </summary>
public required string FullName { get; set; }
/// <summary>
/// Gets or sets the ValidFrom.
/// </summary>
public DateTime? ValidFrom { get; set; }
/// <summary>
/// Gets or sets the ValidTo
/// </summary>
public DateTime? ValidTo { get; set; }
}
}
The ApplicationDbContext
now has the properties ValidFrom
and ValidTo
to be mapped:
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using EfCoreAudit.Temporal.Model;
using Microsoft.EntityFrameworkCore;
namespace EfCoreAudit.Temporal.Database
{
internal class ApplicationDbContext : DbContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
: base(options)
{
}
public DbSet<Person> People { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasSequence<int>("sq_Person", schema: "Application")
.StartsAt(1000)
.IncrementsBy(1);
modelBuilder.Entity<Person>(entity =>
{
entity.ToTable("Person", "Application");
entity.HasKey(e => e.Id);
entity.Property(x => x.Id)
.HasColumnType("INT")
.HasColumnName("PersonID")
.HasDefaultValueSql("NEXT VALUE FOR [Application].[sq_Person]")
.ValueGeneratedOnAdd();
entity.Property(e => e.FullName)
.HasColumnType("NVARCHAR(255)")
.HasColumnName("FullName")
.IsRequired(true)
.HasMaxLength(255);
entity.Property(e => e.ValidFrom)
.HasColumnType("DATETIME2(7)")
.HasColumnName("ValidFrom")
.IsRequired(false)
.ValueGeneratedOnAddOrUpdate();
entity.Property(e => e.ValidTo)
.HasColumnType("DATETIME2(7)")
.HasColumnName("ValidTo")
.IsRequired(false)
.ValueGeneratedOnAddOrUpdate();
});
base.OnModelCreating(modelBuilder);
}
}
}
The TransactionalTestBase
is also out the door and now TestBase
. The reason? A PersonHistory
entry will only be written, when a Transaction is commited (again, makes a lot of sense). So we
cannot keep the Transaction open for the entire time.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using EfCoreAudit.Temporal.Database;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
namespace EfCoreAudit.Temporal.Tests
{
/// <summary>
/// Will be used by all integration tests, that need an <see cref="ApplicationDbContext"/>.
/// </summary>
internal class TestBase
{
/// <summary>
/// We can assume the Configuration has been initialized, when the Tests
/// are run. So we inform the compiler, that this field is intentionally
/// left uninitialized.
/// </summary>
protected IConfiguration _configuration = null!;
public TestBase()
{
_configuration = ReadConfiguration();
}
/// <summary>
/// Read the appsettings.json for the Test.
/// </summary>
/// <returns></returns>
private IConfiguration ReadConfiguration()
{
return new ConfigurationBuilder()
.AddJsonFile("appsettings.json")
.Build();
}
/// <summary>
/// Builds an <see cref="ApplicationDbContext"/> based on a given Configuration. We
/// expect the Configuration to have a Connection String "ApplicationDatabase" to
/// be defined.
/// </summary>
/// <param name="configuration">A configuration provided by the appsettings.json</param>
/// <returns>An initialized <see cref="ApplicationDbContext"/></returns>
/// <exception cref="InvalidOperationException">Thrown when no Connection String "ApplicationDatabase" was found</exception>
protected ApplicationDbContext CreateDbContext()
{
var connectionString = _configuration.GetConnectionString("ApplicationDatabase");
if (connectionString == null)
{
throw new InvalidOperationException($"No Connection String named 'ApplicationDatabase' found in appsettings.json");
}
var dbContextOptionsBuilder = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlServer(connectionString);
return new ApplicationDbContext(dbContextOptionsBuilder.Options);
}
}
}
And in the AuditingEntitiesTests
we can now see, that all changes commited to the database are tracked
in the PersonHistory
table. We didn't need to add a single line of code to do so, but let the database
handle it.
The great thing is, that this also applies to data modifications bypassing our application layer, such as Stored Procedures or ETL jobs, that need to run on the database.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using EfCoreAudit.Temporal.Model;
using EfCoreAudit.Temporal.Tests;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
namespace EfCoreAudit.Temporal
{
[TestFixture]
internal class AuditingEntitiesTests : TestBase
{
[SetUp]
public void SetUp()
{
using (var context = CreateDbContext())
{
context.Database.ExecuteSqlRaw("EXEC [Application].[usp_Database_ResetForTests]");
}
}
[Test]
public async Task AuditingEntites_SaveChanges_SetsCreatedDateTime()
{
// Prepare
using var context = CreateDbContext();
var person = new Person
{
FullName = "Philipp Wagner"
};
// Act
await context.AddAsync(person);
await context.SaveChangesAsync();
// Assert
var people = await context.People
.AsNoTracking()
.ToListAsync();
var peopleHistory = await context.People.TemporalAll()
.AsNoTracking()
.ToListAsync();
Assert.AreEqual(1, people.Count);
Assert.AreEqual("Philipp Wagner", people[0].FullName);
Assert.That(people[0].ValidFrom, Is.EqualTo(DateTime.UtcNow).Within(1).Seconds);
Assert.That(people[0].ValidTo, Is.EqualTo(new DateTime(9999, 12, 31, 23, 59, 59)).Within(1).Seconds);
Assert.AreEqual(1, peopleHistory.Count);
Assert.That(peopleHistory[0].ValidFrom, Is.EqualTo(DateTime.UtcNow).Within(1).Seconds);
Assert.That(peopleHistory[0].ValidTo, Is.EqualTo(new DateTime(9999, 12, 31, 23, 59, 59)).Within(1).Seconds);
}
[Test]
public async Task AuditingEntites_SaveChanges_SetsModifiedDateTime()
{
// Prepare
using var context = CreateDbContext();
var person = new Person
{
FullName = "Philipp Wagner"
};
await context.AddAsync(person);
await context.SaveChangesAsync();
// Act
person.FullName = "Edited Name";
await context.SaveChangesAsync();
// Assert
var people = await context.People
.AsNoTracking()
.ToListAsync();
var peopleHistory = await context.People.TemporalAll()
.OrderByDescending(x => x.ValidFrom)
.AsNoTracking()
.ToListAsync();
// Check Current Entity
Assert.AreEqual(1, people.Count);
Assert.AreEqual("Edited Name", people[0].FullName);
Assert.IsNotNull(people[0].ValidFrom);
Assert.IsNotNull(people[0].ValidTo);
// Check Person History
Assert.AreEqual(2, peopleHistory.Count);
Assert.AreEqual("Edited Name", peopleHistory[0].FullName);
Assert.That(peopleHistory[0].ValidTo, Is.EqualTo(new DateTime(9999, 12, 31, 23, 59, 59)).Within(1).Seconds);
Assert.AreEqual("Philipp Wagner", peopleHistory[1].FullName);
Assert.That(peopleHistory[1].ValidTo, Is.LessThan(new DateTime(9999, 12, 31, 23, 59, 59)));
}
[Test]
public async Task AuditingEntites_ExecuteUpdate_SetsModifiedDateTime()
{
// Prepare
using var context = CreateDbContext();
var person = new Person
{
FullName = "Philipp Wagner"
};
await context.AddAsync(person);
await context.SaveChangesAsync();
// Act
await context.People.ExecuteUpdateAsync(s =>
s.SetProperty(p => p.FullName, p => "Edited Name"));
// Assert
var people = await context.People
.AsNoTracking()
.ToListAsync();
var peopleHistory = await context.People.TemporalAll()
.OrderByDescending(x => x.ValidFrom)
.AsNoTracking()
.ToListAsync();
// Check Current Entity
Assert.AreEqual(1, people.Count);
Assert.AreEqual("Edited Name", people[0].FullName);
Assert.IsNotNull(people[0].ValidFrom);
Assert.IsNotNull(people[0].ValidTo);
// Check Person History
Assert.AreEqual(2, peopleHistory.Count);
Assert.AreEqual("Edited Name", peopleHistory[0].FullName);
Assert.That(peopleHistory[0].ValidTo, Is.EqualTo(new DateTime(9999, 12, 31, 23, 59, 59)).Within(1).Seconds);
Assert.AreEqual("Philipp Wagner", peopleHistory[1].FullName);
Assert.That(peopleHistory[1].ValidTo, Is.LessThan(new DateTime(9999, 12, 31, 23, 59, 59)));
}
}
}
Conclusion
I think using a Temporal Table is a good way to audit changes to your database. There is no need to fiddle around with Change Tracking and relying on EntityFramework Core to write the Audit trails.
The situation is different, if you are using PostgreSQL, which doesn't have Temporal Tables. But even with PostgreSQL, I think using the temporal_tables extensions might be a much more foolproof option.