Audit fields in EntityFramework Core entities

Audit fields in EntityFramework Core entities

Updated:

The value generation code  for the DateCreated and DateModified audit fields has been removed. This is now done in the overridden version of the SaveChanges() of the DbContext derived class.

public abstract class BaseEntityTypeConfiguration<T> : IEntityTypeConfiguration<T>
    where T: class
{
    public virtual void Configure(EntityTypeBuilder<T> builder)
    {
        builder.Property<DateTime>("DateCreated");
        builder.Property<DateTime>("DateModified");
        builder.Property<Boolean>("IsDeleted")
            .IsRequired()
            .HasDefaultValue(false);
        builder.Property<byte[]>("Version")
            .ValueGeneratedOnAddOrUpdate()
            .IsRowVersion();
        builder.Property<string>("ModifiedBy");
        builder.Property<string>("CreatedBy");
    }
}

The problem

Defining audit fields in the primary entities of an application I am currently building is one of the requirements that I am trying to accomplish. I have several options at my disposal:

  1. Define a base entity class
  2. Create an IAuditable interface containing the required audit fields.
  3. Implement IEntityTypeConfiguration<T> in conjuction with Shadow properties.

I went with the IEntityTypeConfiguration<T> option since I liked its simplicity as well as its encouragement of the Single Responsibility Principle (SRP) and the Interface Segregation Principle (ISP).

As for Shadow properties, here's the entry about it in the EntityFramework Core docs.

Shadow properties are properties that are not defined in your .NET entity class but are defined for that entity type in the EF Core model. The value and state of these properties is maintained purely in the Change Tracker.

Just perfect! We wouldn't want our business layer to set values to these properties. This should only be managed at the data layer.

IEntityTypeConfiguration<T>

The IEntityTypeConfiguration<T> is a generic interface that

allows configuration for an entity type to be factored into a separate class, rather than in-line in OnModelCreating(ModelBuilder).

It contains a single method, Configure(), with a single parameter EntityTypeBuilder<T>.

public void Configure(EntityTypeBuilder<T> builder);

The builder enables the configuration of entities using the EntityFramework Core Fluent API. Neat!

public class BillEntityConfiguration : IEntityTypeConfiguration<Bill>
{
    public void Configure(EntityTypeBuilder builder)
    {
        builder.Property(p => p.Id).ValueGeneratedOnAdd();
    }
}

However, implementing this interface in all the entity type configurations may not be so DRY. A generic base configuration, therefore, is in order. This enables the definition of common entity type configuration in one place.

Generic BaseEntityTypeConfiguration<T>

public abstract class BaseEntityTypeConfiguration<T> : IEntityTypeConfiguration<T>
    where T: class
{
    public virtual void Configure(EntityTypeBuilder<T> builder)
    {
        ...
    }
}

The fully-configured base entity type configuration is as follows. Depending on your needs, you may have to add more or remove some of the audit fields defined here.

public abstract class BaseEntityTypeConfiguration<T> : IEntityTypeConfiguration<T>
    where T: class
{
    public virtual void Configure(EntityTypeBuilder<T> builder)
    {
        builder.Property<DateTime>("DateCreated")
            .ValueGeneratedOnAdd()
            .HasDefaultValueSql("CURRENT_TIMESTAMP(6)");
        builder.Property<DateTime>("DateModified")
            .ValueGeneratedOnAddOrUpdate()
            .HasDefaultValueSql("CURRENT_TIMESTAMP(6)");
        builder.Property<Boolean>("IsDeleted")
            .IsRequired()
            .HasDefaultValue(false);
        builder.Property<byte[]>("Version")
            .ValueGeneratedOnAdd()
            .IsRowVersion();
        builder.Property<string>("ModifiedBy");
        builder.Property<string>("CreatedBy");
    }
}

Note that the Configure() method has been marked virtual. This makes it possible for child entity type configuration classes to define their own configuration on top of what the base class already provides.

public class BillConfiguration: BaseEntityTypeConfiguration<Bill>
{
    public override void Configure(EntityTypeBuilder<Bill> builder)
    {
        builder.Property(p => p.Id).ValueGeneratedOnAdd();

        ...

        base.Configure(builder);
    }
}

Finally, in the OnModelCreating() method of the derived DbContext class, utilize the ApplyConfiguration() of the ModelBuilder class to apply the child entity type configuration.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.ApplyConfiguration(new BillConfiguration());

    base.OnModelCreating(modelBuilder);
}

That's it. Running dotnet ef migrations should result to a migration that contains the following snippet.

migrationBuilder.CreateTable(
    name: "Bill",
    columns: table => new
    {
        Id = table.Column<int>(nullable: false)
            .Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
        CreatedBy = table.Column<string>(nullable: true),
        DateCreated = table.Column<DateTime>(nullable: false, defaultValueSql: "CURRENT_TIMESTAMP(6)"),
        DateModified = table.Column<DateTime>(nullable: false, defaultValueSql: "CURRENT_TIMESTAMP(6)"),
        IsDeleted = table.Column<bool>(nullable: false, defaultValue: false),
        ModifiedBy = table.Column<string>(nullable: true),
        Version = table.Column<DateTime>(rowVersion: true, nullable: true)
    },
    constraints: table =>
    {
        table.PrimaryKey("PK_Bill", x => x.Id);
    });

Populating audit fields

With the audit fields setup, we need to be able to populate these fields when a creation, update or deletion is done to our entities. We can do this in a central area by overriding the SaveChanges() method of the derived DbContext class.

public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
    foreach (var entry in ChangeTracker.Entries()
        .Where(e => e.State == EntityState.Added ||
        e.State == EntityState.Modified ||
        e.State == EntityState.Deleted))
    {
        var today = _dateTimeManager.Today;
        var currentUser = _contextData.CurrentUser;

        entry.Property("DateModified").CurrentValue = today;
        entry.Property("ModifiedBy").CurrentValue = currentUser;
        if (entry.State == EntityState.Added)
        {
            entry.Property("DateCreated").CurrentValue = today;
            entry.Property("CreatedBy").CurrentValue = currentUser;
        }

        if (entry.State == EntityState.Deleted)
        {
            entry.State = EntityState.Modified;
            entry.Property("IsDeleted").CurrentValue = true;
        }
    }

    return base.SaveChangesAsync(cancellationToken);
}

Giving credit where credit is due

I wouldn't be able to utilize this technique without the original answer from this post in Stack Overflow, How to add the same column to all entities in EF Core? by Gert Arnold. Cheers!

Photo by Austin Distel on Unsplash

Lhar Gil

Lhar Gil

Tech-savvy software developer and street photography enthusiast. Exploring the world through code and candid shots of daily life. 📸 All opinions are my own.
England