Delete Without Losing Anything
Archiving instead of deleting, with a recycle bin and restore for free
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.
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:
IsArchivedmust 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.