Skip to content
Kumar Chandrachooda
.NET

How to Delete a Family Together

Correlation ids and cascade archiving in ArchiveKit - how one Guid per save turns scattered archive rows into a reconstructable object graph, why cascade happens through the EF change tracker rather than the database, and two honest limits: a naive root heuristic and a CascadeArchive flag the interceptor doesn't read yet. Part 6 of the Archive-on-Delete with ArchiveKit series.

By Kumar Chandrachooda 14 Nov 2025 5 min read
Different tables, one correlation id, one deletion event

Delete an order with three lines and you haven't performed one deletion — you've performed four, and the only thing that makes them one event is bookkeeping. Lose that bookkeeping and your archive is a junk drawer: order_archive has an order, order_line_archive has three lines, and nothing says they left together. This post is about ArchiveKit's bookkeeping — the correlation id — and about the two honest gaps in how the family gets rounded up.

One Guid per save

The interceptor mints exactly one correlation id and one timestamp per intercepted save, before it loops:

var correlationId = Guid.NewGuid();
var archivedAt    = DateTime.UtcNow;

Every archive row created in that save — whatever table it lands in — carries both. So does the snapshot job ticket. The grain here matters: the correlation id identifies the save operation, not the entity, not the request. It's the archive's answer to "what else happened in the same breath?", and it's indexed on every archive table and every snapshot table precisely because it's the join key for reassembly (part 7 leans on it entirely).

The shared timestamp is a smaller decision with an outsized payoff: every member of the family has the identical ArchivedAt, captured once. Four calls to DateTime.UtcNow would give you four values microseconds apart, and every downstream consumer would eventually write a fuzzy-match tolerance around them. One capture, zero tolerance windows. (Amusingly, the sample's soft-delete side makes the opposite mistake — the endpoint calls DateTime.UtcNow once for the order and again per line, so the DeletedAt values captured into the payloads drift by microseconds even though every ArchivedAt agrees. Harmless, but a nice illustration of why the interceptor hoists the clock read.)

Alongside the id, each row gets a role:

var isRoot = entry == rootEntry;
...
SetProp(archiveEntry, "IsRoot", isRoot);

The root is the entity the deletion was about; everything else is luggage. The root also gets the snapshot job ticket — only isRoot && attr.Snapshot creates one, so deleting an order with fifty lines queues one snapshot job, not fifty-one.

Cascade happens in the tracker, not the database

Here's the design decision that surprises people: ArchiveKit doesn't discover children. There is no graph walk, no Include performed by the library, no database cascade. The cascade is whatever your code soft-deleted into the change tracker. The sample's delete endpoint shows the contract:

var order = await db.Orders
    .Include(o => o.Lines)   // include lines so they're in the change tracker
    .FirstOrDefaultAsync(o => o.Id == id);

if (order is null) return Results.NotFound();

order.IsDeleted = true;
order.DeletedAt = DateTime.UtcNow;

foreach (var line in order.Lines)
{
    line.IsDeleted = true;
    line.DeletedAt = DateTime.UtcNow;
}

await db.SaveChangesAsync();

Load the family, flag the family, save once. The interceptor then sees four Modified && IsDeleted entries, all registered types, and archives all four under one correlation id — the order into order_archive as root, the lines into order_line_archive as non-roots.

Why not walk the graph automatically? Because “the graph” is not a well-defined thing at runtime. Navigations you didn't Include aren't loaded; the library discovering them would mean issuing queries inside an interceptor (the exact class of surprise part 2 refused), and lazy-loading proxies would turn a delete into a query storm. Explicit-in-the-tracker has a failure mode — forget the Include and the lines get orphaned by the database's own ON DELETE CASCADE without being archived — but it's a failure mode you can see in the endpoint code, review, and test. A library that queries behind your back has failure modes you can't.

That orphaning risk deserves a second look, because it's the sharpest edge in this post. The sample maps Order → Lines with DeleteBehavior.Cascade. If you soft-delete only the order, the interceptor flips it to Deleted, the database cascades the physical delete to the lines — and the lines were never soft-deleted, never candidates, never archived. The order's archive row exists; its lines are simply gone. The mitigation today is discipline (always flag the family) plus the snapshot payload capturing what was loaded; the real fix is the library auto-flagging loaded children, which is exactly what the next section is about.

The flag that doesn't do anything yet

Time for the honest part. Order declares:

[Archivable(Snapshot = true, CascadeArchive = true, TtlDays = 365)]

and the attribute's doc comment says related archivable entities in the tracker “will be archived in the same correlation group.” Which is true — but search the interceptor for CascadeArchive and you'll find nothing. The behavior described happens unconditionally: every soft-deleted archivable entity in the tracker joins the correlation group, flag or no flag. Set CascadeArchive = false on Order today and nothing changes.

So what is the flag? Right now, documentation of intent — and a reserved seat for the enforcement I want: when the root has CascadeArchive = true, the interceptor should auto-soft-delete loaded child entities that are themselves [Archivable], closing the forgot-to-flag-the-lines gap above without issuing queries (loaded navigations are already in the tracker; flagging them is memory work). When it's false, tracked-but-flagged children would still archive — you asked for that explicitly — but nothing would be volunteered. That's the semantics the flag's presence promises, and shipping the attribute surface before the enforcement was a deliberate bet: attribute changes ripple through consumers' code, so I wanted the shape settled early. But if you adopt the library today, know that the flag is a comment the compiler checks, not a behavior.

The First() heuristic

Second honest limit. How does the interceptor decide who's root?

// identify root: entity that is not a child of another archivable entry
// in this save operation. Simple heuristic: the first one, or the one
// that has no FK pointing to another candidate.
var rootEntry = archivable.First();

The comment describes the right algorithm — the entity no other candidate points at via FK — and the code ships the lazy version: whoever the change tracker enumerates first. In the sample's flow it's reliably the order, because it entered the tracker first via the query. But change-tracker enumeration order is an implementation detail, and two scenarios break the heuristic outright. Soft-delete two unrelated orders in one save and you get one correlation id (arguably fine — it was one operation), but one arbitrary order becomes root and the other is filed as its luggage, and only the “root” gets a snapshot ticket. Soft-delete a child without its parent and the child is the root — correct, coincidentally.

The fix is mechanical — the metadata to find FK relationships between candidates is sitting right there in entry.Metadata.GetForeignKeys(), and true multi-root saves should mint one correlation id per root subtree. It's unwritten because the honest cost-benefit said ship: the dominant real-world save archives one aggregate, where First() and the correct algorithm agree. But IsRoot is load-bearing for snapshot assembly, so this is the known-sharp edge I'd fix first.

Everything in this post has been about writing the family down consistently. The payoff for all that consistency is what a background service can do with it afterward: put a deleted object graph back together from nothing but archive rows.

Next up: the snapshot service, and rebuilding objects that no longer exist.