I'm trying to implement an Audit Trail (track what changed, when and by whom) on a selection of classes in Entity Framework Core.
My current implementation relies on overriding OnSaveChangesAsync:
public override Task<int> SaveChangesAsync(bool acceptAllChangesOnSuccess, CancellationToken cancellationToken = default)
{
var currentUserFullName = _userService.CurrentUserFullName!;
foreach (var entry in ChangeTracker.Entries<AuditableEntity>())
{
switch (entry.State)
{
case EntityState.Added:
entry.Entity.CreatedBy = currentUserFullName;
entry.Entity.Created = _dateTime.Now;
break;
case EntityState.Modified:
var originalValues = new Dictionary<string, object?>();
var currentValues = new Dictionary<string, object?>();
foreach (var prop in entry.Properties.Where(p => p.IsModified))
{
var name = prop.Metadata.Name;
originalValues.Add(name, prop.OriginalValue);
currentValues.Add(name, prop.CurrentValue);
}
entry.Entity.LastModifiedBy = currentUserFullName;
entry.Entity.LastModified = _dateTime.Now;
entry.Entity.LogEntries.Add(
new EntityEvent(
_dateTime.Now,
JsonConvert.SerializeObject(originalValues),
JsonConvert.SerializeObject(currentValues),
currentUserFullName));
break;
}
}
return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
}
This is simple, clean and very easy to use; any entity that needs an audit trail only needs to inherit AuditableEntity
.
However, there is a severe limitation to this approach: it cannot capture changes made to navigation properties.
Our entities make good use of Value Objects, such as an EmailAddress:
public class EmailAddress : ValueObjectBase
{
public string Value { get; private set; } = null!;
public static EmailAddress Create(string value)
{
if (!IsValidEmail(value))
{
throw new ArgumentException("Incorrect email address format", nameof(value));
}
return new EmailAddress {
Value = value
};
}
}
... Entity.cs
public EmailAddress Email { get; set; }
... Entity EF configuration
entity.OwnsOne(e => e.Email);
... Updating
entity.Email = EmailAddress.Create("[email protected]");
Now if the user changes an email address of this entity, the State of the Entity is never changed to modified. EF Core seems to handle ValueObjects as Navigation Properties, which are handled separately.
So I think there are a few options:
Stop using ValueObjects as entity properties. We could still utilize them as entity constructor parameters, but this would cause cascading complexity to the rest of the code. It would also diminish the trust in the validity of the data.
Stop using SaveChangesAsync and build our own handling for auditing. Again, this would cause further complexity in the architecture and probably be less performant.
Some weird hackery to the ChangeTracker - this sounds risky but might work in theory
Something else, what?
In the case where you value objects are mapped to a single column in the database (e.g. an email address is stored in a text column) you might be able to use converters instead:
var emailAddressConverter = new ValueConverter<EmailAddress, string>(
emailAddress => emailAddress.Value,
@string => EmailAddress.Create(@string));
modelBuilder.Entity<User>()
.Property(user => user.Email)
.HasConversion(emailAddressConverter);
This should work well with your change tracking code.
The problem when audit logging value objects was how to find the previous values for the property. The added values can be found for instance with IsOwned metadata as Jeremy suggested but previous deleted properties can not be found this way.
Deleted property can be found by first querying for all deleted objects of the same type as the added property. Then we can find matching object by comparing the foreign keys.
// Find corresponding Deleted value objects of the same type
var deleted = changeTracker.Entries().Where(a => a.State == EntityState.Deleted
&& a.Metadata.ClrType.Equals(added.Metadata.ClrType));
:
// Foreign keys must match
deletedProp.GetContainingForeignKeys().Contains(added.Metadata.ForeignKey)
Please have look at the example project on github: https://github.com/ovirta/ValueObjectAuditing
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With