Skip to content
Kumar Chandrachooda
.NET

Delete Is Never Just Delete

Hard deletes destroy the audit trail; soft deletes clutter live tables forever. Introducing ArchiveKit, a .NET library that intercepts deletions and stores immutable archive snapshots with TTL policies and cascade archiving.

By Kumar Chandrachooda 19 Jul 2025 4 min read
The row leaves the table; the snapshot stays behind

Ask a team what happens when a user deletes an order and you usually get one of two uncomfortable answers.

"It's gone." A hard DELETE, and with it the audit trail, the ability to answer "what did this look like before?", and any chance of recovery that doesn't involve a database restore.

"It's soft-deleted." An IsDeleted flag, a global query filter, and a live table that quietly accumulates every record the business has ever discarded — indexed, backed up, and slowing queries, forever.

Both answers get re-implemented per project, per entity, by hand. ArchiveKit is a library I built to make the third answer cheap: the row really is deleted from the live table, and an immutable archive snapshot is written in the same breath — with configurable metadata, TTL retention, and cascade archiving of related entities.

Opt in with one attribute

An entity opts in by being soft-deletable (so deletion is expressed as intent, not as a vanished row) and carrying an [Archivable] attribute:

[Archivable(TtlDays = 365, Snapshot = true, CascadeArchive = true)]
public class Order : ISoftDeletable
{
    public Guid Id { get; set; }
    public string OrderNumber { get; set; } = string.Empty;
    public decimal Total { get; set; }
    public List<OrderLine> Lines { get; set; } = new();

    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }
}

Each archivable entity pairs with an archive row type — OrderArchive : ArchiveEntry<Order> — where you can surface whichever properties you want as real, queryable columns. A startup scanner discovers the pairs and registers them.

The interceptor does the work

The engine is an EF Core SaveChangesInterceptor. On every save it looks for tracked entities that have just been soft-deleted:

var candidates = db.ChangeTracker
    .Entries<ISoftDeletable>()
    .Where(e => e.State == EntityState.Modified && e.Entity.IsDeleted)
    .ToList();

For each registered candidate it builds the archive row entirely from the change tracker — the property values EF is already holding — so archiving adds zero extra database reads. Matching properties are copied onto the typed archive columns, and every scalar is additionally serialized into a flat jsonb payload, so the archive survives future schema drift even for columns you never promoted.

Then comes my favourite detail. The original entity is flipped to EntityState.Deleted before the archive row is added — reverse the order and EF's relationship fixup will happily cascade-delete the archive row you just created through the FK. One line of ordering, discovered the hard way, and the physical DELETE plus the archive INSERT ship in the same command batch.

Every row archived in a save shares a correlation id and timestamp. When an order goes, its lines (loaded and soft-deleted with it — that's the cascade) land in order_line_archive under the same correlation id as the order in order_archive.

Snapshots of things that no longer exist

For entities marked Snapshot = true, the interceptor also queues a snapshot job. A background service later loads all archive rows sharing the correlation id and assembles a full parent-child JSON tree — reconstructing an object graph whose primary rows are already gone, purely from the archived payloads. Jobs carry retry counts and a terminal failed state, outbox-style.

Retention is the last piece: TtlDays stamps an ExpiresAt on every archive row, and a cleanup service hard-deletes expired archives in batches on a schedule (-1 means keep forever). Both services expose plain public methods too, so on Lambda or a cron container you can drive the same logic without hosted services.

Where the series goes

  1. Why archive-on-delete — this post.
  2. Teaching SaveChanges to archive before it deletes — the SaveChangesInterceptor anatomy, archiving with zero extra reads, and the state-flip ordering trick.
  3. Fourteen rows at a time — sizing inline work against EF's batching, and the deletes that dodge the interceptor entirely.
  4. An archive table that outlives its source — typed columns plus jsonb from one write, and why the archive's EntityId is an index and deliberately not a foreign key.
  5. One attribute, a scanner, and a registry — how entities opt in and the model wires itself up reflectively.
  6. How to delete a family together — correlation ids and cascade archiving through the change tracker, and its current limits.
  7. Rebuilding objects that no longer exist — snapshot assembly of deleted object graphs from archived payloads.
  8. Every archive needs an expiry date — TTL cleanup, batching, and running the whole thing serverless.
  9. Querying the graveyard — reading archives without resurrecting them.

If you have ever written IsDeleted = true and wondered who is ever going to clean that up: part 8 has a timestamp with your name on it.