Teaching SaveChanges to Archive Before It Deletes
The anatomy of ArchiveKit's SaveChangesInterceptor - detecting soft-deleted entities in the change tracker, building archive rows with zero extra database reads, and the one line of state-flip ordering that keeps EF from cascade-deleting the archive you just wrote. Part 2 of the Archive-on-Delete with ArchiveKit series.
If you want to archive every deletion in an application, the first design decision is where you stand. Stand in the database with triggers and you get bulletproof coverage but logic your application can't see, test, or version with the code. Stand in a repository layer and you get testability but a rule that only holds as long as every developer remembers to use the repository. Stand in domain events and you get elegance plus a two-phase failure mode where the delete commits and the archive doesn't.
ArchiveKit stands in EF Core's SaveChangesInterceptor, and this post is the anatomy of that choice: how the interceptor finds deletions, how it builds archive rows without a single extra database read, and the one line of ordering that took me an evening of cascade-deleted archives to discover.
Deletion is expressed as intent
The interceptor never looks for EntityState.Deleted. It looks for entities that have just been soft-deleted:
var candidates = db.ChangeTracker
.Entries<ISoftDeletable>()
.Where(e => e.State == EntityState.Modified && e.Entity.IsDeleted)
.ToList();
This is deliberate. In application code, a delete in ArchiveKit's world looks like this:
order.IsDeleted = true;
order.DeletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
You express intent — “this order is deleted” — and the interceptor decides what that physically means. The entity is Modified (a flag changed), not Deleted, when the interceptor sees it. That distinction buys two things. First, the full current state of the entity is still sitting in the change tracker, which is about to matter a great deal. Second, code that doesn't opt in keeps working: a plain soft-deletable entity without archive types registered just stays soft-deleted, filtered out by the global query filter, exactly as before.
The candidates are then narrowed to types actually registered as archivable:
var archivable = candidates
.Where(e => ArchiveRegistry.IsArchivable(e.Entity.GetType()))
.ToList();
If nothing qualifies, the interceptor gets out of the way — one LINQ pass over tracked entries and done. That's the fast path, and it runs on every save, so it has to be cheap.
Zero extra reads
Here is the property of interceptor-based archiving that I find genuinely elegant: by the time you soft-delete an entity, EF is already holding everything the archive needs. You loaded the order to delete it. The change tracker has every scalar's current value. Archiving becomes a memory-to-memory copy:
var archiveType = ArchiveRegistry.GetArchiveType(entityType);
var archiveEntry = Activator.CreateInstance(archiveType)!;
SetProp(archiveEntry, "CorrelationId", correlationId);
SetProp(archiveEntry, "ArchivedAt", archivedAt);
SetProp(archiveEntry, "IsRoot", isRoot);
SetProp(archiveEntry, "EntityId", GetProp<Guid>(entry.Entity, "Id"));
SetProp(archiveEntry, "ScalarPayload", ScalarSerializer.Serialize(entry));
if (attr.TtlDays >= 0)
SetProp(archiveEntry, "ExpiresAt", archivedAt.AddDays(attr.TtlDays));
No SELECT to fetch what the row looked like. No second context. The typed columns come next — a reflective copy of every tracked scalar whose name matches a property on the archive class:
foreach (var prop in entry.Properties)
{
if (prop.Metadata.IsShadowProperty()) continue;
var name = prop.Metadata.Name;
if (name is "IsDeleted" or "DeletedAt") continue;
var archiveProp = archiveType.GetProperty(name);
archiveProp?.SetValue(archiveEntry, prop.CurrentValue);
}
Notice what's iterated: entry.Properties, EF's tracked properties — not GetType().GetProperties(). Shadow properties (concurrency tokens and friends) are skipped, and IsDeleted/DeletedAt are excluded from the typed copy because an archive row is the deletion record; a flag would be redundant.
The ScalarPayload serializer works from the same tracked entries, which is what makes it safe to run inside an interceptor:
public static string Serialize(EntityEntry entry)
{
var dict = new Dictionary<string, object?>();
foreach (var prop in entry.Properties)
{
if (prop.Metadata.IsShadowProperty()) continue;
dict[prop.Metadata.Name] = prop.CurrentValue;
}
return JsonSerializer.Serialize(dict, _options);
}
It never touches navigation properties, so there is no lazy-loading landmine — serializing order.Lines inside SavingChangesAsync is exactly the kind of thing that works in the demo and deadlocks in production. Every scalar goes into a flat jsonb payload alongside the typed columns; part 4 covers why both exist.
The ticket for later
If the entity is the root of the save and its attribute says Snapshot = true, the interceptor also creates a work ticket, inline, in the same batch:
if (isRoot && attr.Snapshot)
{
var snapshotType = ArchiveRegistry.GetSnapshotType(entityType);
snapshotJob = Activator.CreateInstance(snapshotType)!;
SetProp(snapshotJob, "CorrelationId", correlationId);
SetProp(snapshotJob, "CreatedAt", archivedAt);
SetProp(snapshotJob, "Status", SnapshotStatus.Pending);
db.Add(snapshotJob);
}
The expensive work — assembling a full JSON tree of the deleted object graph — is not done here. The interceptor writes a Pending row and moves on; a background service picks it up later (part 7). The archive row records the link as a bare Guid? SnapshotId rather than a navigation property, because ArchiveEntry<TEntity> is a generic base class and giving it a typed navigation to SnapshotJob<TEntity> drags you into circular generic constraints that fight EF's model builder. A foreign key column with no navigation turned out to be the calm answer.
The one line that matters
Then comes the ordering trick, and I want to show it with its comment intact because the comment is the scar tissue:
// convert to Deleted BEFORE adding the archive entry,
// so EF doesn't try to cascade-delete the archive via the
// required Order→OrderArchive FK.
entry.State = EntityState.Deleted;
db.Add(archiveEntry);
My first version added the archive row first, then flipped the entity to Deleted. Every archive row silently vanished. The culprit is EF's relationship fixup: the archive entry carries the entity's id, and if EF can relate the new row to the entity you are about to delete, marking the parent Deleted invites cascade behavior to take the archive row down with it — in the very same save that created it. Flip the state first, then add the archive row, and EF treats them as unrelated operations: a DELETE for the entity, an INSERT for the archive. (The model configuration in part 4 also severs the relationship entirely — EntityId is deliberately not a real FK — which is the belt to this suspender.)
The payoff for doing all of this inside SavingChangesAsync is atomicity for free. The interceptor mutates the change tracker and steps aside; EF then does what it always does — wraps the save in a transaction and batches the statements. The physical DELETE, the archive INSERTs, and the snapshot-ticket INSERT ship together in one round trip, and either all of it commits or none of it does. There is no window where the row is gone and the archive isn't. Triggers give you the same guarantee; domain events and background copiers do not.
What the reflection costs
Honest ledger time. Activator.CreateInstance and PropertyInfo.SetValue per archived entity are not free. For the intended workload — a handful of entities archived per user-facing save — the cost disappears into the noise of a database round trip. Delete ten thousand rows in one save and you will feel it, but you will hit a different wall first: EF's command batching, which is exactly what the interceptor's InlineArchiveLimit guards. Compiled expression setters (or the source generator this library's doc comments keep promising) are the obvious upgrade path, and the reflection is contained in two private helpers when that day comes.
The subtler cost is that everything above hangs off one assumption: the deletion flows through the async save pipeline of a tracked DbContext. Some deletions don't — and what happens then is not a corner case, it's the subject of the next post.
Next up: the inline archive limit, EF's batching math, and every way a delete can dodge your interceptor.