Changed event and change tracking with a group
Part of property groups.
A group lets you watch a chosen set of properties together. Three options, from most to least preferred for tracking:
| Option | Runs | Use it to |
|---|---|---|
| Computed property | When read | Show what changed, such as change summary text, date, time, or user. |
| PreSave | Once per modified entity, during SaveChanges() |
Act on what changed at save time to create a log record. |
Changed |
On every change to every property in the group | React immediately, such as updating another property |
Performance: prefer Compute or PreSave
Changed fires on every change to every property in the group, so a busy group runs the handler many times.
See Performance consideration.
For change tracking, use a computed property or PreSave. Both read the group's GetPropertyStates, so only properties in the group are tracked.
Track changes with a computed property
A computed property recalculates only when read. Declare TrackedForAudit first, then use it in business logic:
// Business logic, inside a [RegisterLogic] method
var tracked = invoice.PropertyGroup<TrackedForAudit>()
.Add(i => new { i.Date, i.Customer, i.Details });
invoice.Compute(i => i.LastChangedOn)
.From(i => DateTime.UtcNow)
.DirtyByGroup<TrackedForAudit>();
invoice.Compute(i => i.ChangeSummary)
.From(i => string.Join(", ", tracked.GetPropertyStates(i)
.Where(p => p.IsModified).Select(p => p.PropertyName)))
.DirtyByGroup<TrackedForAudit>();
LastChangedOnstamps the time when any property in the group changes.ChangeSummarylists which ones changed.- Both update on the next read or save.
DirtyByGroupwatches current and later properties, excluding the computed property itself.- It supports
LoadAllmembership changes and rejects paged collections. - Child fields need
DirtyWithRelation(...).DirtyBy(...).
Track changes in PreSave
PreSave runs once for each modified entity, just before it is saved. Use the group in the handler so that only the properties in the group are checked:
// Business logic, inside a [RegisterLogic] method
var tracked = contact.PropertyGroup<TrackedForAudit>()
.Add(c => new { c.Email, c.Phone, c.Address });
contact.OnPreSave().Do((entity, args) =>
{
var changes = tracked.GetPropertyStates(entity)
.Where(p => p.IsModified)
.Select(p => new
{
Property = p.PropertyName,
Value = p.GetPropertyLogic().GetValueAsObject(entity, p)
})
.ToList();
if (changes.Count == 0)
return;
_ = new ContactChangeLog(args.Context)
{
Contact = entity,
ChangedOn = DateTime.UtcNow,
ChangesJson = JsonSerializer.Serialize(changes)
};
});
The handler writes one ContactChangeLog row per save. The log entity is an ordinary model entity with Contact, ChangedOn and ChangesJson properties.
ChangesJson is a string that holds one JSON object for each changed property, such as
[{"Property":"Email","Value":"a@new.com"}].
Changes to properties outside TrackedForAudit are not logged.
GetValueAsObjectreturns the current (new) value. The original value is not logged.- Keep the group to scalar properties. Reference and collection properties hold entities, which do not serialize to useful JSON. See PreSave.
NOTE: We have not done a full implementation of auditing and change logging. The example above should probably serialize the original value and the may be other things at that time. I expect to make future changes when we work on that - Glenn
Reading property states
GetPropertyStatesreturns live states by name, including inherited and derived properties. It does not load values or references; unavailable features are skipped.IsModifiedincludes accepted writes and collection changes, even equal and changed-back values. See state rules.
React to each change with Changed
Use Changed when something must happen immediately, not at read or save time:
// Business logic, inside a [RegisterLogic] method
contact.PropertyGroup<TrackedForContactInfo>()
.Add(c => new { c.Email, c.Phone })
.Changed().Do((entity, args) =>
{
if (args.ChangedBy == ChangedBy.Direct)
entity.ContactInfoChangedOn = DateTime.UtcNow;
});
Changed handles property writes, dirty notifications, and LoadAll association changes.
It also accepts Do(entity => ...) when you do not need the args.
Args provide the entity, property, change cause, and old/new values, after correction and validation.
For dirty properties, OldValue is cached. NewValue computes once when read and does not fire Changed again.
Each event keeps its own values and cause during nested changes.
To copy a change to another record, see Copying.
Collection events
Use CollectionChangedArgs for the action and changed items. Clear reports Reset.
OldValue, NewValue, and CopyTo throw for these events; no whole-collection snapshots are created.
Scalar and reference events have a null CollectionChangedArgs. List-valued scalar properties keep their value behavior.
- Shared-interface collection handlers run once per concrete backing association.
- Paged collections, virtual collections without mutation events, and proxies without a concrete backing association throw during startup.
- Loading and restoring collections do not fire events.
- Child-field changes need separate handlers.