I built an audit log for EF Core that can actually undo a change

If you’ve shipped a few business apps you’ve probably written the same thing more than once: an audit log. A table that answers “who touched this row, when, and what did it look like before.” It’s never hard, exactly. It’s just tedious, and you end up doing it again on the next project because the last one’s version was tangled into that project’s code.
The part that always bugged me more, though, is that the audit log just sits there. You have a perfect record of what changed, and when someone sets a price to 5 instead of 500, you still go fix it by hand in the database. All that history and you can’t press undo.
So I finally wrote the version I wanted: capture the change and be able to reverse it. This is roughly how it works, and the library’s at the end if you want it.
Capturing the changes
I went with a SaveChangesInterceptor. The appeal is that it lives at the DbContext level, so your entities stay clean — no base class, no IAuditable, no calls scattered through your services. The change tracker already knows everything that’s about to be written; the interceptor just reads it.
If you’re wondering why not a MediatR pipeline behaviour, which is the other common spot for this: MediatR went commercial last year, and a fair number of teams are now trying not to take a hard dependency on it. Keeping audit in the data layer sidesteps that entirely.
The shape is simple enough:
csharppublic override async ValueTask SavedChangesAsync(
SaveChangesCompletedEventData eventData, int result, CancellationToken ct = default)
{
var ctx = eventData.Context!;
foreach (var entry in ctx.ChangeTracker.Entries())
{
// record the action, the key, and the before/after values
// for anything added, modified, or deleted
}
return await base.SavedChangesAsync(eventData, result, ct);
}
The annoying details are the ones that don’t show up in a snippet. An insert’s key isn’t known until after the save, so you can’t grab it in SavingChanges — you read it afterwards. On an update you only want the properties that actually changed. And on a delete you have to keep the whole original row, not just the key, or you’ve got nothing to rebuild it from later.
Undo is the hard part
Capturing is the easy bit. Reversing is where it stops being uniform, because a delete and an update don’t undo the same way. An update means putting the old values back. A delete means recreating the row. A create means deleting it. Fine so far.
Where it gets messy is that not everything reverses cleanly from a stored snapshot. Some rows have derived columns, or relationships, or rules that a blind overwrite would quietly break. I didn’t want to pretend a snapshot is always safe, so the entity owner decides: by default it uses the snapshot, but you can register your own handler for the types that need real logic. The ones that are simple stay simple; the ones that aren’t get an escape hatch.
In the end the calling code is just:
csharpawait reverter.RevertAsync(auditEntryId);
The library
I cleaned it up and put it on NuGet as EfCore.AuditKit. It’s MIT, free, EF Core 10, and it doesn’t drag in MediatR or anything commercial. Install is the usual dotnet add package EfCore.AuditKit, repo’s here: https://github.com/Gamra-hub/AuditKit
I’ll be honest about where it’s at: it’s a v1. It handles scalar properties and reverts one change at a time. The thing I’m working on next is reverting a whole multi-row operation as a unit, with a check so it refuses if someone’s touched the data since.
If you’ve built one of these before, I’d actually want to hear where this falls down — especially the revert side, since that’s the part I’m least sure I’ve got right for every case. Happy to be told I missed something.

Total
0
Shares
Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Post

Beyond the Checklist: Mastering the FDA’s New QMSR Framework

Related Posts