Advanced Entity Framework Core Tips In Practice: Concurrency, Query filters and SaveChanges Method Abilities­čĺ¬

I am describing the practical aspects of working with Entity Framework Core. In this article, I am touching Concurrency token and RowVersion, query filters, and on save changes interceptor with a bunch of useful logic like tracking who and when did the change, versions, validations, and domain events sending.

Concurrency

Every project unless it is student work must have concurrency handling, this is basically is about preventing items from overriding with updates. Imagine user A and user B opened a page with the same article and modified that, so who pressed the save button first will win the concurrency competition, the second one will get a conflict exception which should be handled on the client-side app with displaying the message kind of "Somebody just modified this article, please reload the page!".

So, how to handle this case with the EF core? It is never been so easy as in EF Core.

You can use builder.Property(...).IsConcurrencyToken() for the existing property, the only thing you need to make sure this property changes always on update.

public class TodoItemConfiguration: IEntityTypeConfiguration<TodoItem>
{
    public void Configure(EntityTypeBuilder<TodoItem> b)
    {
        b.Property(t => t.Title).HasMaxLength(200).IsRequired();
        b.Property(t => t.UpdatedAt).IsConcurrencyToken();
    }
}

Another way is to add a new property RowVersion to the model specifically for this need.

...
    public byte[] RowVersion { get; set; }
...
public class TodoItemConfiguration : IEntityTypeConfiguration<TodoItem>
{
    public void Configure(EntityTypeBuilder<TodoItem> b)
    {
        b.Property(t => t.Title).HasMaxLength(200).IsRequired();
        b.Property(t => t.RowVersion).IsRowVersion();
    }
}

Query filters

This is a nice feature that is handy when you need to apply a special filter for all the queries to a specific table or sub-entities. Absolutely useful when implementing multitenancy or soft delete feature. This auto filtering applies globally and can be bound even to a private field or sub-entities.

modelBuilder.Entity<TodoList>().HasQueryFilter(b => EF.Property<string>(b, "_tenantId") == _tenantId);
modelBuilder.Entity<TodoItem>().HasQueryFilter(p => !p.IsDeleted);

Next, when doing queries to TodoLists or TodoItems, EF will automatically apply filtering as you would add .Where() statement to the query.

To disable locally query filters and for example, see all soft-deleted entities you would need to call .IgnoreQueryFilters() on the query.

items = db.TodoItems
    .IgnoreQueryFilters()
    .ToList();

More info can be found here.

On save changes interceptor

Thanks to the ability to overwrite SaveChanges methods and the ability to track entities we can add a bunch of useful logic before and after saving entities.

For example,

  • We can set who added and modified an entity as well as when those actions occurred,
  • Since all changes will be applied as a unit of work it is worth adding validation before saving changes for all tracked entities,
  • In case there is any entity that required versioning we can do this logic right before saving,

After saving changes we can send domain events to other services.

public override async Task <int> SaveChangesAsync(bool acceptAllChangesOnSuccess, ancellationToken ct = default)
{
    HandleAdded();
    HandleModified();
    HandleValidatable();
    HandleVersioned();

    var result = await base.SaveChangesAsync(ct);

    await DispatchEvents();

    return result;
}

private void HandleAdded()
{
    var added = ChangeTracker.Entries<ITrackedEntity>().Where(e => e.State == EntityState.Added);
    foreach(var entry in added)
    {
        entry.Property(x => x.Created).CurrentValue = DateTime.UtcNow;
        entry.Property(x => x.Created).IsModified = true;
        entry.Property(x => x.Updated).CurrentValue = DateTime.UtcNow;
        entry.Property(x => x.Updated).IsModified = true;
    }
}

private void HandleModified()
{
    var modified = ChangeTracker.Entries<ITrackedEntity>().Where(e => e.State == EntityState.Modified);
    foreach(var entry in modified)
    {
        entry.Property(x => x.Updated).CurrentValue = DateTime.UtcNow;
        entry.Property(x => x.Updated).IsModified = true;
        entry.Property(x => x.Created).CurrentValue = entry.Property(x => x.Created).OriginalValue;
        entry.Property(x => x.Created).IsModified = false;
    }
}

private void HandleValidatable(
{
    var validatableModels = ChangeTracker.Entries<IValidatableEntity>();
    foreach(var model in validatableModels)
    model.Entity.ValidateAndThrow();
}

private void HandleVersioned()
{
    foreach(var versionedModel in ChangeTracker.Entries<IVersionedEntity>())
    {
        var versionProp = versionedModel.Property(o => o.Version);
        if (versionedModel.State == EntityState.Added)
        {
            versionProp.CurrentValue = 1;
        }
        else if (versionedModel.State == EntityState.Modified)
        {
            versionProp.CurrentValue = versionProp.OriginalValue + 1;
            versionProp.IsModified = true;
        }
    }
}

private async Task DispatchEvents()
{
    while (true)
    {
        var domainEventEntity = ChangeTracker.Entries<IHasDomainEvent>()
            .SelectMany(entry => entry.Entity.DomainEvents)
            .FirstOrDefault(domainEvent => !domainEvent.IsPublished);
        
        if (domainEventEntity == null) break;
        
        domainEventEntity.IsPublished = true;
        await _domainEventService.Publish(domainEventEntity);
    }
}

To implement such functionality you will require the next interfaces,

public class ITrackedEntity 
{
    DateTime Created { get; set; }
    DateTime Updated { get; set; }
}

public interface IValidatableEntity
{
    ValidateModelResult Validate();
}

public interface IVersionedEntity
{
    int Version { get; set; }
}

public interface IHasDomainEvent
{
    List<DomainEvent> DomainEvents { get; set; }
}

public abstract class DomainEvent
{
    protected DomainEvent()
    {
        DateOccurred = DateTimeOffset.UtcNow;
    }

    public bool IsPublished { get; set; }
    public DateTimeOffset DateOccurred { get; protected set; } = DateTime.UtcNow;
}

Please share more ideas in the comments below for the functionality which can be inbuild in the EF Save Changes method.