EF Core - how to audit trail with value objects

c# entity-framework-core

Question

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("e@mail.com");

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:

  1. 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.

  2. Stop using SaveChangesAsync and build our own handling for auditing. Again, this would cause further complexity in the architecture and probably be less performant.

  3. Some weird hackery to the ChangeTracker - this sounds risky but might work in theory

  4. Something else, what?

1
5
3/31/2020 12:10:20 PM

Accepted Answer

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.

2
3/31/2020 9:12:18 AM

Popular Answer

There's probably a more efficient method, but you can enumerate the set of owned records;

entry.References.Where(r =>
    r.TargetEntry != null
    && r.TargetEntry.State == EntryState.Modified
    && r.TargetEntry.Metadata.IsOwned())

Where the parent object is either Unchanged or Modified, then track their changes too.



Related Questions





Related

Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow
Licensed under: CC-BY-SA with attribution
Not affiliated with Stack Overflow