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).
BeforeCommitruns 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
IWorkflowCommitObserverinstead. AfterCommitruns 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.
AfterCommitruns 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 calldb.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
SaveOnCompletionmethod it runs after the save committed. A method that aborts, or whose save fails, does not notify. - For a method without
SaveOnCompletionit 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.