Convention based Concurrency Management in Entity Framework Core

Who does not love convention over configuration? Whenever it makes sense I try to use it in my role as a system architect. It helps my programmers write more robust code out of the box.

Writing concurrency safe code is a corner stone in writing robust code today,  without it data quality can not be guaranteed. And when things go wrong you want to know who and when entities were updated so you can investigate what have gone wrong.

So what I did at my latest assignment was to force this onto the entities by convention using two markup interfaces ICreated and IUpdated

public interface ICreated
{
    string CreatedBy { get; set; }
    DateTime CreatedUTC { get; set; }
}
public interface IUpdated
{
    DateTime? UpdatedUTC { get; set; }
    string UpdatedBy { get; set; }
}

The first step is getting all entities implementing these interfaces configured automatic. That can be done from the DbContext.

protected override void OnModelCreating(ModelBuilder builder)
{
    base.OnModelCreating(builder);

    builder.ApplyConfigurationsFromAssembly(typeof(ICreated).Assembly);

    builder
        .Model
        .GetEntityTypes()
        .Where(et => typeof(ICreated).IsAssignableFrom(et.ClrType))
        .ForEach(et =>
        {
            et.FindProperty(nameof(ICreated.CreatedUTC))
                .IsNullable = false;

            et.FindProperty(nameof(ICreated.CreatedBy))
                .IsNullable = false;
        });

    builder
        .Model
        .GetEntityTypes()
        .Where(et => typeof(IUpdated).IsAssignableFrom(et.ClrType))
        .ForEach(et =>
        {
            et.FindProperty(nameof(IUpdated.UpdatedUTC))
                .IsConcurrencyToken = true;
        });
}

Above code first configures required properties for ICreated and then it also registers the updated date from the IUpdated as a Concurrency token. What this means is that the generated update SQL will include the update timestamp and if it have changed zero rows will be updated and an exception will be thrown, classic optimistic locking. For this to work with dates you need to configure the updated date as a datetime2 otherwise resolution will be too low and false positive concurrency conflicts will occur. A more robust solution could be to introduce a third column to the IUpdated for example with the rowversion data type that is updated along side the other properties.

Lastly we need to automatically set these properties when saving. it can be done in many ways, for example overriding SaveChanges on the DbContext. We did it a bit different way with a custom business context that abstracts the DbContext and publishes events when saving / committing transaction etc. However you choose to implement it the actual code being executed is the same and it looks like this.

public void Handle(SavingChangesEvent message)
{
    var now = DateTime.UtcNow;

    foreach (var created in message.Context.Entries<ICreated>().Where(c => c.State == EntityState.Added))
    {
        created.Entity.CreatedBy = message.Context.Username;
        created.Entity.CreatedUTC = now;
    }
}

Basically it gets all ICreated in the entity cache that are in state Added and sets the properties accordingly. The username context is domain specific, it can for example be populated from a Middleware or similar.

Code for IUpdated is pretty similar but we query for entities being modified instead.

Final piece of the puzzle is to validate that developers do not update entities not marked for concurrency management. Otherwise we can not guarantee data quality across the board.

public void Handle(SavingChangesEvent message)
{
    var now = DateTime.UtcNow;
    var erroneousUpdated = message.Context.Entries<object>()
        .Where(c => !(c.Entity is IUpdated) && c.State == EntityState.Modified)
        .Select(c => c.Metadata.Name)
        .Distinct()
        .ToList();

    if (erroneousUpdated.Any()) throw new ApplicationException($"Detected updates to entities not implementing {nameof(IUpdated)}: {erroneousUpdated.ToSeparatedString()}");

    foreach (var updated in message.Context.Entries<IUpdated>().Where(c => c.State == EntityState.Modified))
    {
        updated.Entity.UpdatedBy = message.Context.Username;
        updated.Entity.UpdatedUTC = now;
    }
}

So we look for any objects being modified that does not implement IUpdated and if so throw an application exception telling which entity needs to be upgraded to a IUpdated.

With all this done all you need todo when you want to track a new entities created or update state is to implement these two interfaces, they also work separated from each other. So if a object can never be updated but only created its perfectly fine to only implement ICreated.

2 comments

Leave a comment