Introduction
In this post, I would like to talk about extending your application and your DbContext
to run arbitrary code when a save occurs.
The Backstory
While working with quite a few applications that work with databases, especially using entity framework, I noticed the pattern of saving changes to the database and then do something else based on those changes. A few examples of that are as follows:
- When the user state changes, reflect that in the UI.
- When adding or updating a product, update the stock.
- When deleting an entity, do another action like check for validity.
- When an entity changes in any way (
add
, update
, delete
), send that out to an external service.
These are mostly akin to having database triggers when the data changes, some action needs to be performed, but those actions are not always database related, more as a response to the change in the database, which sometimes is just business logic.
As such, in one of these applications, I found a way to incorporate that behavior and clean up the repetitive code that would follow, while also keeping it maintainable by just registering the triggers into the IoC container of ASP.NET core.
In this post, we will be having a look at the following:
- How to extend the
DbContext
to allow for the triggers - How to register multiple instances into the container using the same interface or base class
- How to create entity instances from tracked changes so we can work with concrete items
- How to limit our triggers to only fire under certain data conditions
- Injecting dependencies into our triggers
- Avoiding infinite loops in our triggers
We have a long enough road ahead so let’s get started.
Creating the Triggers Framework
ITrigger Interface
We will start off with the root of our triggers and that is the ITrigger
interface.
using Microsoft.EntityFrameworkCore.ChangeTracking;
public interface ITrigger
{
void RegisterChangedEntities(ChangeTracker changeTracker);
Task TriggerAsync();
}
- The
RegisterChangedEntities
method accepts a ChangeTracker
so that if need be, we can store the changes that happened for later use. - The
TriggerAync
method actually runs our logic, the reason why these two are separate we will see when we will make changes to the DbContext
.
TriggerBase Base Class
Next off, we will be looking at a base class that is not mandatory though it does exist for two main reasons:
- To house the common logic of the triggers, including the state of the tracked entities
- To be able to filter out trigger based on the entity they are meant for
using System.Collections.Generic;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore.ChangeTracking;
public abstract class TriggerBase<T> : ITrigger
{
protected IEnumerable<TriggerEntityVersion<T>> TrackedEntities;
protected abstract IEnumerable<TriggerEntityVersion<T>>
RegisterChangedEntitiesInternal(ChangeTracker changeTracker);
protected abstract Task TriggerAsyncInternal(TriggerEntityVersion<T> trackedTriggerEntity);
public void RegisterChangedEntities(ChangeTracker changeTracker)
{
TrackedEntities = RegisterChangedEntitiesInternal(changeTracker).ToArray();
}
public async Task TriggerAsync()
{
foreach (TriggerEntityVersion<T> triggerEntityVersion in TrackedEntities)
{
await TriggerAsyncInternal(triggerEntityVersion);
}
}
}
Let’s break it down member by member and understand what’s with this base class:
- The class is a generic type of
T
, this ensures that the logic that will be running in any of its descendants will only apply to a specific entity that we want to run our trigger against. - The protected
TrackedEntities
field holds on to the changed entities, both before and after the change so we can run our trigger logic against them. - The
abstract
method RegisterChangedEntitiesInternal
will be overridden in concrete implementations of this class and ensures that given a ChangeTracker
, it will return a set of entities we want to work against. This is not to say that it cannot return an empty collection, it’s just that if we opt to implement a trigger via the TriggerBase
class, then it’s highly likely we would want to hold onto those instances for later use. - The
abstract
method TriggerAsyncInternal
runs our trigger logic against n entity we saved from the collection. - The
public
method RegisterChangedEntities
ensures that the abstract
method RegisterChangedEntitiesInternal
is called, then it calls .ToArray()
to ensure that if we have an IEnumerable
query, that it also actually executes so that we don’t end up with a collection that is updated later on in the process in an invalid state. This is mostly a judgment call on my end because it is easy to forget that IEnumerable
queries have a deferred execution mechanic. - The
public
method TriggerAsync
just enumerates over all of the entities calling TriggerAsyncInternal
on each one.
Now that we discussed the base class, it’s time we move on to the definition of a TriggerEntityVersion
.
The TriggerEntityVersion Class
The TriggerEntityVersion
class is a helper class that serves the purpose of housing the old and the new instance of a given entity.
using System.Linq;
using System.Reflection;
using Microsoft.EntityFrameworkCore.ChangeTracking;
public class TriggerEntityVersion<T>
{
public T Old { get; set; }
public T New { get; set; }
public static TriggerEntityVersion<TResult>
CreateFromEntityProperty<TResult>(EntityEntry<TResult> entry) where TResult : class, new()
{
TriggerEntityVersion<TResult> returnedResult = new TriggerEntityVersion<TResult>
{
New = new TResult(),
Old = new TResult()
};
foreach (PropertyInfo propertyInfo in typeof(TResult)
.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty)
.Where(pi => entry.OriginalValues.Properties.Any(property => property.Name == pi.Name)))
{
if (propertyInfo.CanRead && (propertyInfo.PropertyType == typeof(string) ||
propertyInfo.PropertyType.IsValueType))
{
propertyInfo.SetValue(returnedResult.Old, entry.OriginalValues[propertyInfo.Name]);
}
}
foreach (PropertyInfo propertyInfo in typeof(TResult)
.GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty)
.Where(pi => entry.OriginalValues.Properties.Any(property => property.Name == pi.Name)))
{
if (propertyInfo.CanRead && (propertyInfo.PropertyType == typeof(string) ||
propertyInfo.PropertyType.IsValueType))
{
propertyInfo.SetValue(returnedResult.New, entry.CurrentValues[propertyInfo.Name]);
}
}
return returnedResult;
}
}
The breakdown for this class is as follows:
- We have two properties of the same type, one representing the
Old
instance before any modifications were made and the other representing the New
state after the modifications have been made. - The factory method
CreateFromEntityProperty
uses reflection so that we can turn an EntityEntry
which into our own entity so it’s easier to work with, since an EntityEntry
is not something so easy to interrogate and work with, this will create instances of our entity and copy over the original and current values that are being tracked, but only if they can be written to and are string
s or value types (since classes would represent other entities most of the time, excluding owned properties). Additionally, we only look at the properties being tracked.
We will see an example of how this is used in the following section where we see how to implement concrete triggers.
Concrete Triggers
We will be creating two triggers to show off how they can differ and also how to register multiple triggers later on when we do the integration into the ServiceProvider
.
Attendance Trigger
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DbBroadcast.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Extensions.Logging;
public class AttendanceTrigger : TriggerBase
{
private readonly ILogger _logger;
public AttendanceTrigger(ILogger logger)
{
_logger = logger;
}
protected override IEnumerable RegisterChangedEntitiesInternal(ChangeTracker changeTracker)
{
return changeTracker
.Entries()
.Where(entry => entry.State == EntityState.Modified)
.Select(TriggerEntityVersion.CreateFromEntityProperty);
}
protected override Task TriggerAsyncInternal(TriggerEntityVersion trackedTriggerEntity)
{
_logger.LogInformation($"Update attendance for user {trackedTriggerEntity.New.Id}");
return Task.CompletedTask;
}
}
From the definition of this trigger, we can see the following:
- This trigger will apply for the entity
ApplicationUser
. - Since the instance of the trigger is created via
ServiceProvider
, we can inject dependencies via its constructor as we did with the ILogger
. - The
RegisterChangedEntitiesInternal
method implements a query on the tracked entities of type ApplicationUser
only if they have been modified. We could check for additional conditions but I would suggest doing that after the .Select
call so that you can work with actual instances of your entity. - The
TriggerAsyncInternal
implementation will just log out the new Id of the user (or any other field we might want).
Ui Trigger
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.Extensions.Logging;
using DbBroadcast.Models;
public class UiTrigger : TriggerBase<ApplicationUser>
{
private readonly ILogger<AttendanceTrigger> _logger;
public UiTrigger(ILogger<AttendanceTrigger> logger)
{
_logger = logger;
}
protected override IEnumerable<TriggerEntityVersion<ApplicationUser>>
RegisterChangedEntitiesInternal(ChangeTracker changeTracker)
{
return changeTracker.Entries<ApplicationUser>().Select
(TriggerEntityVersion<ApplicationUser>.CreateFromEntityProperty);
}
protected override Task TriggerAsyncInternal
(TriggerEntityVersion<ApplicationUser> trackedTriggerEntity)
{
_logger.LogInformation($"Update UI for user {trackedTriggerEntity.New.Id}");;
return Task.CompletedTask;
}
}
This class is the same as the previous one, this more for example purposes, except it has a different message and also, it will track all changes to ApplicationUser
entities regardless of their state.
Registering the Triggers
Now that we have written up our triggers, it’s time to register them. To register multiple implementations of the same interface or base class, all we need to do is make a change in the Startup.ConfigureServices
method (or wherever you’re registering your services) as follows:
services.TryAddEnumerable(new []
{
ServiceDescriptor.Transient<ITrigger, AttendanceTrigger>(),
ServiceDescriptor.Transient<ITrigger, UiTrigger>(),
});
This way, you can have triggers of differing lifetimes, as many as you want (though they should be in line with the lifetime of your context, else you will get an error), and easy to maintain. You could even have a configuration file to enable at will certain triggers :D.
Modifying the DbContext
Here, I will show two cases which can be useful depending on your requirement. You will also see that the implementation is the same, the difference being a convenience since for simple cases, all you need to do is inherit, for complex cases, you would need to make these changes manually.
Use a Base Class
If your context only inherits from DbContext
, then you could use the following base class:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DbBroadcast.Data.Triggers;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
public abstract class TriggerDbContext : DbContext
{
private readonly IServiceProvider _serviceProvider;
public TriggerDbContext(DbContextOptions<ApplicationDbContext> options,
IServiceProvider serviceProvider)
: base(options)
{
_serviceProvider = serviceProvider;
}
public override async Task<int> SaveChangesAsync
(CancellationToken cancellationToken = new CancellationToken())
{
IEnumerable<ITrigger> triggers =
_serviceProvider?.GetServices<ITrigger>()?.ToArray() ?? Enumerable.Empty<ITrigger>();
foreach (ITrigger userTrigger in triggers)
{
userTrigger.RegisterChangedEntities(ChangeTracker);
}
int saveResult = await base.SaveChangesAsync(cancellationToken);
foreach (ITrigger userTrigger in triggers)
{
await userTrigger.TriggerAsync();
}
return saveResult;
}
}
Things to point out here are as follows:
- We inject the
IServiceProvider
so that we can reach out to our triggers. - We override the
SaveChangesAsync
(same would go for all the other save methods of the context, though this one is the most used nowadays) and implement the changes.
- We get the triggers from the
ServiceProvider
(we could even filter them out for a specific trigger type but it’s better to have them as is cause it keeps it simple) - We run through each trigger and save the entities that have changes according to our trigger registration logic.
- We run the actual save inside the database to ensure that everything worked properly (is there a database error, then the trigger would get cancelled due to the exception bubbling)
- We then run each trigger.
- We return the result as if nothing happened :D.
Keep in mind that given this implementation you wouldn’t want to have a trigger that updates the same entity or you might end up in a loop, so either you must have firm rules for your trigger or just don’t change the same entity inside the trigger.
Using Your Existing Context
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using DbBroadcast.Data.Triggers;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using DbBroadcast.Models;
using Microsoft.Extensions.DependencyInjection;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
{
private readonly IServiceProvider _serviceProvider;
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options,
IServiceProvider serviceProvider)
: base(options)
{
_serviceProvider = serviceProvider;
}
public override async Task<int> SaveChangesAsync
(CancellationToken cancellationToken = new CancellationToken())
{
IEnumerable<ITrigger> triggers =
_serviceProvider?.GetServices<ITrigger>()?.ToArray() ?? Enumerable.Empty<ITrigger>();
foreach (ITrigger userTrigger in triggers)
{
userTrigger.RegisterChangedEntities(ChangeTracker);
}
int saveResult = await base.SaveChangesAsync(cancellationToken);
foreach (ITrigger userTrigger in triggers)
{
await userTrigger.TriggerAsync();
}
return saveResult;
}
}
As you can see, this is nearly identical to the base class but since this context already inherits from IdentityDbContext
, then you have to implement your own.
To implement your own, you need to both update your constructor to accept a ServiceProvider
and override the appropriate save methods.
Conclusion
For this to work, we’ve taken advantage of inheritance, the strategy pattern for the triggers, playing with the ServiceProvider
and multiple registrations.
I hope you enjoyed this as much as I did tinkering with it, and I’m curious to find out what kinda trigger you might come up with.
Thank you and happy coding.
CodeProject