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>();
  • LastChangedOn stamps the time when any property in the group changes. ChangeSummary lists which ones changed.
  • Both update on the next read or save.
  • DirtyByGroup watches current and later properties, excluding the computed property itself.
  • It supports LoadAll membership 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.

  • GetValueAsObject returns 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

  • GetPropertyStates returns live states by name, including inherited and derived properties. It does not load values or references; unavailable features are skipped.
  • IsModified includes 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.