Skip to content

Trigger states

Koen edited this page Nov 30, 2020 · 1 revision

Currently a shortcoming in the trigger design is that Scoped triggers share all state within a trigger session, whereas Transient triggers do not share state for different lifecycles of the same entity and change type combination.

Consider a previously given example:

public class RemoveEmailAddressesFromCommentContent : IBeforeSaveTrigger<Comment>, IAfterSaveTrigger<Comment>
{
    readonly IEmailService _emailService;
    List<string> _foundEmailAddresses;
    
    public RemoveEmailAddressesFromCommentContent(IEmailService emailService) {
        this._emailService = emailService;
    }

    public Task BeforeSave(ITriggerContext<Comment> context, CancellationToken cancellationToken)
    {
        if (context.ChangeType is ChangeType.Added or ChangeType.Modified)
        {
            _foundEmailAddresses = Helpers.FindAndReplaceEmailAddresses(context.Entity.Content);
        }
    }

    public Task AfterSave(ITriggerContext<Comment> context, CancellationToken cancellationToken)
    {
        if (_foundEmailAddresses != null) 
        {
            _emailSevice.SendEmail(context.AuthorEmailAddress, "For your protectection, we've removed the following email addresses: " + string.Join(", ", _foundEmailAddresses));
        }
    }
}

If this trigger was registered with a transient lifetime as such:

services.AddTransient<IBeforeSaveTrigger<Student>, RemoveEmailAddressesFromCommentContent>();
services.AddTransient<IAfterSaveTrigger<Student>, RemoveEmailAddressesFromCommentContent>();

then it would not be able carry its local state from the BeforeSave phase to the AfterSave phase as under the hood 2 trigger instances are created, one for each lifecycle.

Instead, if the triggers was registered with a scoped lifetime, as such:

services.AddScoped<RemoveEmailAddressFromCommentContent>();
services.AddScoped<IBeforeSaveTrigger<Comment>>(sp => sp.GetService<RemoveEmailAddressFromCommentContent>());
services.AddScoped<IAfterSaveTrigger<Comment>>(sp => sp.GetService<RemoveEmailAddressFromCommentContent>());

Then we would be cheating a bit since we made the assumption that there is Only one affected Comment in the changeset (as if there were 2, _foundEmailAddresses would first be set to the ones found in the first Post and afterwards set to the ones found in the second post). Even worse, In the AfterSave phase, the ones last stored (from the second Post) would be mailed to both the author of the first post and the author of the second post. Not great!

In order to work around this, we should keep track of the state of different entities, which is the current recommended approach, e.g.

public class RemoveEmailAddressesFromCommentContent : IBeforeSaveTrigger<Comment>, IAfterSaveTrigger<Comment>
{
    private Dictionary<BlogPoststring, List<string>> _foundEmailAddressesByComment = new();

    public Task BeforeSave(ITriggerContext<Comment> context, CancellationToken cancellationToken)
    {
        if (context.ChangeType is ChangeType.Added or ChangeType.Modified)
        {
            _foundEmailAddressesByComment[context.Entity] = Helpers.FindAndReplaceEmailAddresses(context.Entity.Content);
        }
    }

    public Task AfterSave(ITriggerContext<Comment> context, CancellationToken cancellationToken)
    {
        var foundEmailAddresses  = _foundEmailAddressesByComment[context.Entity];
        if (foundEmailAddresses != null) 
        {
            _emailSevice.SendEmail(context.AuthorEmailAddress, "For your protectection, we've removed the following email addresses: " + string.Join(", ", foundEmailAddresses));
        }
    }
}

This works and would correctly preserve the state for individual entities, but it leaves something to be desired.

Another case where we are not able to cleanly express our intend is when we want to aggregate changes and perform a bulk-update in response, consider the following example:

public class LogEventsAdded : IBeforeSaveTrigger<Event>, IAfterSaveTrigger<Event> { 
    int _addedCounter = 0;

    public Task BeforeSave(ITriggerContext<Event> context, CancellationToken cancellationToken) {
        if (context.ChangeType == ChangeType.Added) {
            _addedCounter++;
        }
    }

    public Task AfterSave(ITriggerContext<Event> context, CancellationToken cancellationToken) {
        if (_addedCounter > 0) {
            Console.WriteLine($"Added {_addedCounter} events");

            this._addedCounter = 0;
        }
    }
}

If we were to register this as a Scoped trigger as shown before, we would successfully be able to build up the correct state during the BeforeSave lifecycle however the AfterSave lifecycle would then be invoked for each individually changed event. whereas we only needed it to be invoked once. Not great.

These examples also show currently proposed workarounds.

A future version of this project may include some or more of the following:

  • Expose lifecyle events on the ITriggerContext interface, allowing you to expresss within your BeforeSave lifecylce: context.AfterSave += () => { _emailService.SendEmail(context.AuthorEmailAddress, ....) };
  • Implement lifecylce triggers, such as IBeforeSaveStaring, IAfterSaveStarting and IAfterSaveStarted. These would run once for the current lifecycle of the trigger session which lets you consume aggregated state once.
  • Since an ITriggerContext is shared for the same Entity/ChangeType between the different lifecycles of that trigger, we could add PropertyBag to the context, meaning that we could express: context.Properties["FoundEmaillAddresses"] = .... which we could then again pull out in a different lifecycle.
  • A transient trigger could potentially be shared between the different lifecycles, meaning a trigger implementing both IBeforeSaveTrigger and IAfterSaveTrigger would be created and shared for each individual Entity/ChangeType and thereby hold local state.
Clone this wiki locally