← All articles

Delete Without Losing Anything

Archiving instead of deleting, with a recycle bin and restore for free

Let users delete a record and still get it back: it disappears from every screen, stays in the database, and restores in one click. A recycle bin and an audit view come with it, no extra code.
Delete Without Losing Anything

Hard deletes are forever, and users press buttons. Somewhere between "we can't just lose data" and "we don't want a Deleted flag polluting every query", most teams hand-roll the same feature: soft delete — a flag on the row, and every list, count and lookup remembering to skip it.

In Regira Entities it is one interface on the entity. An EF Core query filter does the remembering, and a global filter, the kind the previous part introduced, lets a screen ask for the archived rows on purpose. The curls below use the $URL set in part 1's "Run the sample" block.

Universal or Regira? Soft delete is the pattern: a flag instead of a DELETE, and a query filter on the DbContext that hides flagged rows, which plain EF Core supports. IArchivable, the ?archived= switch every search object carries and the write path that turns DELETE into an update are Regira Entities, switched on by UseDefaults().

One property changes what DELETE means

Mark an entity archivable:

public class Vehicle : IEntityWithSerial, IHasNormalizedContent, IArchivable
{
    public int Id { get; set; }
    public bool IsArchived { get; set; }
    // ...
}

From that moment, DELETE no longer removes the row — it sets IsArchived = true. Watch it happen in the sample:

curl -X DELETE "$URL/vehicles/5"   # 200 - VAN-002 is archived, not removed
curl "$URL/vehicles"               # 4 vehicles: TRK-001..TRK-003, VAN-001
curl "$URL/vehicles/5"             # 404

The row is still in the database — open vehicles.db and it's right there, flag set. But every list hides it, every count skips it, and even fetching it by id comes back 404. As far as the API's default surface is concerned, the vehicle is gone. The delete writes the flag and nothing else: rows that depend on the vehicle stay as they are, so archiving TRK-001 keeps its two interventions for a restore.

What DELETE does to an archivable row and how each read sees it afterwards: the row stays in vehicles.db with IsArchived set; the default archived=Excluded view shows four vehicles and a 404 for id 5, archived=Included shows all five with one flagged, archived=Only shows just VAN-002; a PATCH with isArchived false restores it.

How the hiding works (and why it reaches everywhere)

The hiding and the way past it are separate mechanisms, and it helps to know which does what.

The hiding is an EF Core query filter on the DbContext, wired by UseDefaults(): e => !e.IsArchived, applied to every IArchivable entity type. Because it lives at the EF level rather than in the pipeline, it also reaches into Include(...)'d collections — an archived child row disappears from its parent's collection too, which a plain pipeline Where cannot do.

The opt-outs are a pipeline global filter reading one field the base SearchObject carries for every entity:

curl "$URL/vehicles?archived=Included"   # all 5 - VAN-002 has "isArchived": true
curl "$URL/vehicles?archived=Only"       # the recycle bin: just VAN-002
curl "$URL/vehicles/5?archived=Included" # 200 - reaching an archived row on purpose

?archived= takes three values — Excluded (the default), Included and Only — and that is your trash can, your audit view and your restore screen without writing a query. One habit to build: an admin surface that must see archived rows says so explicitly. Everything else stays clean by default.

Restore: the anticlimax

Un-archiving needs no special endpoint. It's a save with the flag cleared, and PATCH sends just that field:

curl -X PATCH "$URL/vehicles/5" -H "Content-Type: application/json" -d '{"isArchived":false}'
curl "$URL/vehicles"                     # all 5 again
curl "$URL/vehicles/5"                   # 200

PATCH looks the row up with archived rows included, so the update lands even though reads 404, and every field the body leaves out keeps its stored value. One modeling decision keeps this loop closed, and it's easy to break by accident:

IsArchived must stay on the entity's input DTO. Trim it away as "server-managed" and restore silently stops working — the cleared flag can no longer travel back.

The front-end half of this loop — a form that loads archived rows and offers a Restore button — is dissected in the useForm deep dive.

What DELETE should mean in your API

The decision rule is short: entities a user can point at and regret deleting get IArchivable; pure machine data — logs, join rows, derived caches — keeps hard delete. The cost is one bool column and the input-DTO rule above. The payoff is that "undo" stops being a backup-restore ticket.

Why not a Deleted flag and a Where in every query? Because the list of queries is never complete: the count, the lookup by id, the child collection loaded through an Include — each is a place to forget the flag. The query filter sits underneath all of them, so there is no list to keep complete.

Next in the series, the filter everyone actually asks for — the search box: A Search Box That Finds Café When You Type Cafe.

Runnable: the series' API sample carries the archivable Vehicle, the archived filter and the restore endpoint — grab it and dotnet run.

Docs: Regira Entities — archiving, global filters, entity services.

Built with

Regira.Entities

The filtering, sorting and paging in this post are what Regira Entities gives you for a search object and one registration line.

What Regira Entities covers Entities documentation Free tier & licensing