Save Observers and Method Listeners

Summary

Save observers and method listeners let application code react to what happened to records, without a [Logic] subscriber on each entity. Use them for cross-cutting work that looks at many entity types, such as starting background jobs or writing an audit trail. For rules about one entity, use the regular events instead.

Hook Namespace Called when
ISaveObserver Benevia.Core.API.Events Before and after each save through the events pipeline
IMethodInvokedListener Benevia.Core.Events.Events After an instance [Method] finished
IWorkflowCommitObserver Benevia.Core.API.Workflows.Services After each workflow commit persisted, even one with nothing to write

Register any number of implementations in DI. A scoped implementation shares the request's services. Save observers are resolved when a save commits, so a transient one is created for each save.

services.AddScoped<ISaveObserver, AuditSaveObserver>();
services.AddScoped<IMethodInvokedListener, AuditMethodListener>();

Save observers

sealed class AuditSaveObserver(DbContext db, AuditQueue queue) : ISaveObserver
{
    List<AuditEntry> entries = [];

    public void BeforeCommit(SaveChangeSet changes)
    {
        // Change tracking is final here, so EF's original and current values can be read.
        entries = changes.Changed.Select(e => AuditEntry.From(db.Entry(e))).ToList();
    }

    public void AfterCommit(SaveChangeSet changes) => queue.AddRange(entries);
}

SaveChangeSet lists the entities the save adds (Added), changes (Changed) and deletes (Deleted).

  • BeforeCommit runs after PreSave events and the dirty persisted properties were computed, and before the database write. The save can still fail after it, for example on a validation error or a pending question. Throwing from it stops the save.
  • Nothing to write: a save with no added, changed or deleted entities calls neither method. A workflow commit with nothing to write, because later steps undid every staged change, is reported to IWorkflowCommitObserver instead.
  • AfterCommit runs only when the write succeeded, with the same change set. An exception is logged and does not fail the save.
  • Workflows: a workflow commit writes inside a transaction that commits later, when the workflow is finalized. AfterCommit runs before that, and the transaction can still roll back. Work that leaves the database, such as calling another system, should wait until the request has ended. The automation package does this; see Automation › Inside a workflow.
  • Virtual and workflow entities go through the same save but are not in the EF model. Check db.Model.FindRuntimeEntityType(entity.GetType()) before you call db.Entry(entity), or EF throws.

Method listeners

sealed class AuditMethodListener(AuditQueue queue) : IMethodInvokedListener
{
    public void MethodInvoked(IEventEntity entity, MethodLogicBase method, EventContext context)
    {
        if (method.Type == MethodType.Modify)
            queue.Add(AuditEntry.ForMethod(entity, method.MethodName));
    }
}
  • The listener runs after the method's guards passed and every handler ran.
  • For a SaveOnCompletion method it runs after the save committed. A method that aborts, or whose save fails, does not notify.
  • For a method without SaveOnCompletion it runs before the method's changes are saved.
  • Inside a workflow nothing is committed yet, even with SaveOnCompletion: the changes are staged until the workflow commits, and the workflow can still be discarded. Work that must follow a real commit should wait for it, as a save observer does.
  • Static methods have no entity and do not notify.
  • An exception is logged and does not fail the method.

See also

  • PreSave — change a record's values at save time.
  • Methods — declaring and implementing methods.