A plugin for Microsoft.EntityFrameworkCore to support automatically recording data changes history.
This fork works for added entities.
Version 9.0 (Microsoft.EntityFrameworkCore.AutoHistoryFork 9.x) supports .NET 8.0 and .NET 9.0 (with their respective EF Core 8/9 versions).
For applications targeting .NET 5.0, .NET 6.0 or .NET 7.0, use package version 7.x of this fork, which supports those frameworks and their corresponding EF Core versions.
Starting with this version the extension methods modelBuilder.EnableAutoHistory() require at least the generic parameter for the DbContext:
Before (v7.x / old API):
modelBuilder.EnableAutoHistory();
modelBuilder.EnableAutoHistory(options => { /*...*/ });
modelBuilder.EnableAutoHistory<CustomAutoHistory>(options => { /*...*/ });Now (v9+):
modelBuilder.EnableAutoHistory<BloggingContext>();
modelBuilder.EnableAutoHistory<BloggingContext>(options => { /*...*/ });
modelBuilder.EnableAutoHistory<BloggingContext, CustomAutoHistory>(options => { /*...*/ });The first generic parameter is ALWAYS your
DbContexttype. The second (optional) is the custom type that inherits fromAutoHistory.
AutoHistoryFork will record all the data changing history in one table named AutoHistories. This table will record data
UPDATE, DELETE history and, optionally, ADD history.
This fork adds two additional fields to the original version: UserName and ApplicationName (and now also optional GroupId).
PM> Install-Package Microsoft.EntityFrameworkCore.AutoHistoryForkUse the generic method providing your DbContext type:
public class BloggingContext : DbContext
{
public BloggingContext(DbContextOptions<BloggingContext> options) : base(options) {}
public DbSet<Blog> Blogs { get; set; }
public DbSet<Post> Posts { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// enable auto history functionality (minimal)
modelBuilder.EnableAutoHistory<BloggingContext>();
}
}bloggingContext.EnsureAutoHistory(); // Modified & Deleted entitiespublic class BloggingContext : DbContext
{
public BloggingContext(DbContextOptions<BloggingContext> options) : base(options) {}
public override int SaveChanges()
{
this.EnsureAutoHistory(); // Modified & Deleted
return base.SaveChanges();
}
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
this.EnsureAutoHistory();
return base.SaveChangesAsync(cancellationToken);
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.EnableAutoHistory<BloggingContext>();
}
}public class BloggingContext : DbContext
{
public override int SaveChanges()
{
var addedEntities = ChangeTracker
.Entries()
.Where(e => e.State == EntityState.Added)
.ToArray();
var hasAdded = addedEntities.Any();
IDbContextTransaction? transaction = null;
if (hasAdded)
transaction = Database.BeginTransaction();
// First ensure history for Modified/Deleted before persistence changes states
this.EnsureAutoHistory();
int changes = base.SaveChanges();
if (hasAdded)
{
// Now Added entities have real keys -> create Added history
this.EnsureAddedHistory(addedEntities);
changes += base.SaveChanges();
transaction!.Commit();
}
return changes;
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.EnableAutoHistory<BloggingContext>();
}
}public override int SaveChanges()
{
this.EnsureAutoHistory(currentUserName);
return base.SaveChanges();
}Added entities:
this.EnsureAddedHistory(addedEntities, currentUserName);public override int SaveChanges()
{
var addedEntities = ChangeTracker.Entries().Where(e => e.State == EntityState.Added).ToArray();
// write Modified/Deleted history to an external history context
this.EnsureAutoHistory(historyDbContext, currentUserName);
var changes = base.SaveChanges();
historyDbContext.EnsureAddedHistory(addedEntities, currentUserName);
historyDbContext.TrySaveChanges(); // (custom extension you implement)
return changes;
}Configure via parameter or options. Remember to specify the DbContext type now.
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.EnableAutoHistory<BloggingContext>("MyApplicationName");
}Disable mapping column:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.EnableAutoHistory<BloggingContext>(options =>
{
options.MapApplicationName = false;
});
}Or keep column & pass fixed name:
modelBuilder.EnableAutoHistory<BloggingContext>("MyFixedAppName");class CustomAutoHistory : AutoHistory
{
public string CustomField { get; set; }
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.EnableAutoHistory<BloggingContext, CustomAutoHistory>(options => { });
}Supplying custom factory when ensuring history:
db.EnsureAutoHistory<BloggingContext, CustomAutoHistory>(() => new CustomAutoHistory
{
CustomField = "CustomValue"
});The properties inherited from
AutoHistoryare automatically populated by the framework.
For Added entities (same overload pattern already available):
this.EnsureAddedHistory<BloggingContext, CustomAutoHistory>(() => new CustomAutoHistory { CustomField = "X" }, addedEntries);Four ways:
- Attribute on property
[ExcludeFromHistory] - Attribute on the class (excludes the entire entity)
- Fluent API to exclude a property (
WithExcludeProperty) - Fluent API to exclude an entity (
WithExcludeFromHistory)
Example:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.EnableAutoHistory<BloggingContext>(options => options
.ConfigureType<Blog>(t =>
{
t.WithExcludeProperty(b => b.ExcludedProperty);
})
.ConfigureType<NotTracked2>(t => t.WithExcludeFromHistory()));
}
public class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
[ExcludeFromHistory]
public string PrivateURL { get; set; }
public string ExcludedProperty { get; set; }
public List<Post> Posts { get; set; }
}
[ExcludeFromHistory]
public class NotTracked { /* ... */ }
public class NotTracked2 { /* ... */ }Rules (summary):
- If only excluded properties changed, no history entry is generated.
- An entirely excluded entity never generates history.
- Final exclusion set = union of attributes + fluent configuration.
Allows grouping (e.g., Blog + Posts) by sharing a logical identifier.
Enable globally and configure per type:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.EnableAutoHistory<BloggingContext>(options => options
.WithGroupId(true)
.ConfigureType<Blog>(t => t.WithGroupProperty(nameof(Blog.BlogId)))
.ConfigureType<Post>(t => t.WithGroupProperty(nameof(Post.BlogId))));
}During SaveChanges (including Added):
public override int SaveChanges()
{
var added = ChangeTracker.Entries().Where(e => e.State == EntityState.Added).ToArray();
var hasAdded = added.Any();
IDbContextTransaction? tx = null;
if (hasAdded) tx = Database.BeginTransaction();
this.EnsureAutoHistory(); // Modified & Deleted
var changes = base.SaveChanges();
if (hasAdded)
{
this.EnsureAddedHistory(added);
changes += base.SaveChanges();
tx!.Commit();
}
return changes;
}Grouped query:
var blogId = 42;
var grouped = context.Set<AutoHistory>()
.Where(h => h.GroupId == blogId.ToString())
.OrderBy(h => h.ChangedOn)
.ToList();Fallback: if WithGroupId was not enabled or a type did not set WithGroupProperty, GroupId remains null.
ChangedHistory is a dictionary: PropertyName -> string[].
- Added: array with 1 slot (new value)
- Deleted: array with 1 slot (old value)
- Modified: array with 2 slots (
[old, new]) Serialized as JSON inAutoHistory.Changed.
- Functional support for Added entities (
EnsureAddedHistory). - Additional fields:
UserName,ApplicationName,GroupId(optional via configuration). - Default size of
Changedexpanded to 8000. - New options in
AutoHistoryOptions:ApplicationName,DateTimeFactory,UserNameMaxLength,ApplicationNameMaxLength,MapApplicationName,UseGroupIdand per-type configurations (ConfigureType). - New JSON format (
ChangedHistory). EnsureAutoHistorycan receiveuserNameand/or anotherDbContextto persist history.- (v9+) API
EnableAutoHistorynow requires theDbContexttype as a generic parameter.
- Add the context type:
modelBuilder.EnableAutoHistory<BloggingContext>(); - For custom type:
modelBuilder.EnableAutoHistory<BloggingContext, CustomAutoHistory>(opts => { ... }); - Verify if any old internal documentation snippet still uses the old form.
- Recreate migrations if the change comes with new columns (e.g.,
GroupId).
| Scenario | Snippet |
|---|---|
| Basic | modelBuilder.EnableAutoHistory<BloggingContext>(); |
| With options | modelBuilder.EnableAutoHistory<BloggingContext>(o => o.WithGroupId()); |
| Custom entity | modelBuilder.EnableAutoHistory<BloggingContext, CustomAutoHistory>(o => { }); |
| Application name | modelBuilder.EnableAutoHistory<BloggingContext>("AppName"); |
| Disable AppName column | modelBuilder.EnableAutoHistory<BloggingContext>(o => o.MapApplicationName = false); |
| GroupId + per type | modelBuilder.EnableAutoHistory<BloggingContext>(o => o.WithGroupId().ConfigureType<Blog>(t=>t.WithGroupProperty(nameof(Blog.BlogId)))); |
- EnsureAutoHistory: Generates history for Modified/Deleted before
SaveChanges. - EnsureAddedHistory: Generates history for Added after
SaveChanges(when keys are available). - GroupId: Optional field to group history of related entities.
- ChangedHistory: Structure used to store the changes (JSON).
(Add license information here, if applicable)