Description
When implementing a custom IAuditLogContributor to support audit logging of ValueObjects, we discovered that the ABP Framework does not provide the correct InternalEntity on OriginalValues during the audit logging process while the NewValue is resolved correctly, the InternalEntity for original values is already overwritten by the time the contributor executes. All EF Core sources that should expose the previous state (e.g., EntityEntry.OriginalValues) already reflect the new value.
Technical Context
[AttributeUsage(AttributeTargets.Property)]
public class EntityChangeTrackedValueObjectAttribute : Attribute
{
public string MemberName { get; }
public bool IsMethod { get; }
public EntityChangeTrackedValueObjectAttribute(string memberName, bool isMethod = true)
{
MemberName = memberName;
IsMethod = isMethod;
}
}
*Custom AuditLogContributor* - Implemented so newValue is fetched
public override void PostContribute(AuditLogContributionContext context)
{
if (context?.AuditInfo?.EntityChanges == null)
{
base.PostContribute(context);
return;
}
foreach (var entityChange in context.AuditInfo.EntityChanges)
{
if (entityChange?.PropertyChanges == null) continue;
var entityTypeFullName = entityChange.EntityTypeFullName;
var entityTypeName = entityChange.EntityTypeFullName?.Split('.').LastOrDefault()?.Trim();
foreach (var propertyChange in entityChange.PropertyChanges)
{
if (propertyChange == null) continue;
var propertyPath = propertyChange.PropertyName ?? string.Empty;
if (string.IsNullOrWhiteSpace(propertyPath)) continue;
var propertyLast = propertyPath.Split('.').Last();
if (!string.IsNullOrEmpty(propertyChange.NewValue))
continue;
if (!string.IsNullOrEmpty(entityTypeName) &&
VoTrackedPropertyCache.TryGet(entityTypeName, propertyLast, out var voType, out var memberName, out var isMethod, out var declaringType))
{
var entryWrapper = entityChange.EntityEntry;
var wrapperType = entryWrapper.GetType();
object newResult = null;
var entityProp = wrapperType.GetProperty("Entity");
var currentEntity = entityProp?.GetValue(entryWrapper);
if (currentEntity != null && declaringType.IsInstanceOfType(currentEntity))
{
var voProp = declaringType.GetProperty(propertyLast);
var currentVoInstance = voProp?.GetValue(currentEntity);
if (currentVoInstance != null)
{
newResult = GetResultFromVo(currentVoInstance, voType, memberName, isMethod);
}
}
if (newResult != null)
{
propertyChange.NewValue = newResult.ToString();
propertyChange.PropertyTypeFullName = "System.String";
}
}
}
}
}
Observed Behavior
During the PostContribute phase, we attempted to retrieve the old values from: entityChange.EntityEntry, without success beecause all InternalEntities already ahd the new values.
Expected Behavior ABP should expose the actual original value of the Value Object within the audit context, when accessing the InternalEntity of a change.
So, we want to know if there is already in the framework a way to handle value objects obtaining similar results without using our implementation, and, if not, if you can provide a solution so we can use this aproach also for OriginalValues.
Thanks in advance
3 Answer(s)
-
0
- Solution
Why you see “new” values in PostContribute
- ABP builds the AuditLog (including EntityChanges) after EF Core SaveChanges pipeline applies the changes. At PostContribute time, the tracked EntityEntry and any InternalEntity references already reflect current (new) values. This is by design: EF’s OriginalValues are updated after SaveChanges and navigation/value-object instances on the entity are the new ones.
- Therefore, reading “old” value-object members from entityChange.EntityEntry (or its “Entity”) in PostContribute cannot yield the previous state.
What ABP exposes for original values today
- ABP populates EntityChange.PropertyChanges with OriginalValue and NewValue for scalar properties it can detect. If your target is a ValueObject’s derived member (e.g., a calculated or nested member), ABP doesn’t automatically compute it. You need to compute and store it yourself before EF overwrites the tracked state.
Recommended approach: compute OriginalValue in PreContribute using the snapshot already captured by ABP
- Use PreContribute to read the ABP-captured original scalar values for the VO’s underlying properties from entityChange.PropertyChanges (these are still available).
- Reconstruct a lightweight “previous VO” from those original scalar property values.
- Compute the derived value (method/property) from this reconstructed VO and assign it to propertyChange.OriginalValue.
- Compute NewValue from the current entity instance (as you already do) and assign to propertyChange.NewValue.
Code sketch:
public class ValueObjectAuditContributor : AuditLogContributor { public override void PreContribute(AuditLogContributionContext context) { if (context?.AuditInfo?.EntityChanges == null) return; foreach (var entityChange in context.AuditInfo.EntityChanges) { if (entityChange.PropertyChanges == null) continue; // Example: Suppose Address is a VO with Street, City and you want Original/New of a derived member Full. // First, gather originals from ABP’s recorded scalar changes. var originalVoParts = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase); foreach (var pc in entityChange.PropertyChanges) { // Match VO sub-properties like "Address.Street", "Address.City" if (pc.PropertyName != null && pc.PropertyName.StartsWith("Address.", StringComparison.Ordinal)) { var sub = pc.PropertyName.Substring("Address.".Length); originalVoParts[sub] = pc.OriginalValue; // ABP’s captured original scalar value } } // If there is enough info to reconstruct the VO, build it and compute the derived value if (originalVoParts.Count > 0) { var originalVo = new Address( street: originalVoParts.TryGetValue("Street", out var s) ? s : null, city: originalVoParts.TryGetValue("City", out var c) ? c : null ); // Find or create a synthetic propertyChange entry for the derived member var fullPc = entityChange.PropertyChanges .FirstOrDefault(x => x.PropertyName == "Address.Full"); if (fullPc == null) { fullPc = new EntityPropertyChangeInfo { PropertyName = "Address.Full", PropertyTypeFullName = "System.String" }; entityChange.PropertyChanges.Add(fullPc); } // Set Original derived value from reconstructed VO fullPc.OriginalValue = originalVo.Full; // computed at PreContribute time } } } public override void PostContribute(AuditLogContributionContext context) { if (context?.AuditInfo?.EntityChanges == null) return; foreach (var entityChange in context.AuditInfo.EntityChanges) { if (entityChange.PropertyChanges == null) continue; // Compute New derived value from the current entity (which now holds the new VO) var entryWrapper = entityChange.EntityEntry; var entity = entryWrapper?.GetType().GetProperty("Entity")?.GetValue(entryWrapper); if (entity == null) continue; var addressProp = entity.GetType().GetProperty("Address"); var newVo = addressProp?.GetValue(entity); if (newVo == null) continue; var fullPc = entityChange.PropertyChanges .FirstOrDefault(x => x.PropertyName == "Address.Full"); if (fullPc == null) { fullPc = new EntityPropertyChangeInfo { PropertyName = "Address.Full", PropertyTypeFullName = "System.String" }; entityChange.PropertyChanges.Add(fullPc); } var fullMember = newVo.GetType().GetProperty("Full"); // or method invoke if needed var newResult = fullMember?.GetValue(newVo)?.ToString(); if (!string.IsNullOrEmpty(newResult)) { fullPc.NewValue = newResult; fullPc.PropertyTypeFullName = "System.String"; } } } }Notes:
The key is PreContribute: it runs before ABP finalizes the audit info and while ABP’s captured OriginalValue for scalar properties is still in the PropertyChanges list. You cannot rely on EF’s EntityEntry to have old in-memory VO instances after SaveChanges.
If the VO is stored as an owned entity with its scalar columns, ABP should record property changes for those columns when they actually change. If not, add the owned scalar properties to the EntityHistory selectors and ensure EF detects the changes.
Alternative: capture a pre-save snapshot in a SaveChanges interceptor
- If your VO changes aren’t resulting in ABP property changes at the scalar level, introduce a DbContext interceptor or override AbpDbContext.ChangeTracker-based capture just before ApplyAbpConcepts and SaveChanges, stash the original VO snapshot into the current UnitOfWork’s Items, then read it in PreContribute to fill propertyChange.OriginalValue. This keeps timing correct and avoids relying on EntityEntry after SaveChanges.
Is there an existing built-in way for VO-derived members?
- ABP does not calculate derived/orchestrated ValueObject members into EntityChange.PropertyChanges out of the box. You need a contributor (as shown) or a custom interception to reconstruct the originals and set OriginalValue yourself.
- This approach aligns with common ABP conventions, but specific official documentation for “value-object derived member original values in contributors” is not available.
Practical checklist
- Use PreContribute to fill OriginalValue for VO-derived members from ABP’s scalar PropertyChanges.
- Use PostContribute (or PreContribute) to fill NewValue from the current entity/VO instance.
- Ensure EF marks the owned/VO scalar columns as modified so ABP records Original/New for them.
- Avoid trying to read original VO instances from EntityEntry in PostContribute—those are already replaced.
- Sources
- https://abp.io/docs/latest/framework/infrastructure/audit-logging
- https://abp.io/docs/latest/framework/infrastructure/audit-logging#entity-changes
- https://github.com/abpframework/abp/blob/dev/framework/src/Volo.Abp.Auditing/Volo/Abp/Auditing/AuditLogContributor.cs
- https://github.com/abpframework/abp/blob/dev/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs
- https://github.com/abpframework/abp/search?q=EntityChange.EntityEntry
This response was generated by AI. If it didn’t solve your problem, feel free to post a new answer — our support team will jump in to help you soon.
-
0
Use PreContribute to fill OriginalValue for VO-derived members from ABP’s scalar PropertyChanges: PreContribute is not bringing any EntityChanges on the context. More assistance required.
-
0
hi
Can you share your test project?
I will download and debug it.
liming.ma@volosoft.com
Thanks.