← All articles

Archiving: delete without losing data

One interface turns DELETE into soft delete: archived rows vanish from every query, stay reachable on purpose via ?archived=, and come back with a plain save - every step runnable against the sample API.
Archiving: delete without losing data

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. In Regira Entities it's one interface, and the global-filter machinery from the previous article does the rest.

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.

How the hiding works (and why it reaches everywhere)

Two mechanisms cooperate, and knowing which does what saves real debugging time.

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

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's your trash-can UI, your audit view and your restore screen, without writing a query. One habit to build: any 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 plain save with the flag cleared:

curl -X PUT "$URL/vehicles/5" -H "Content-Type: application/json" \
     -d '{"id":5,"isArchived":false,"code":"VAN-002","model":"Vito 116","brandId":3,"vehicleTypeId":2}'
curl "$URL/vehicles"                     # all 5 again
curl "$URL/vehicles/5"                   # 200

The write path resolves archived rows server-side, so the update lands even though reads 404. 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 sample's VehicleInputDto carries the flag with a comment saying exactly that. (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 pattern generalizes into a simple decision rule: 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 discipline of the input-DTO rule above. The payoff is that "undo" stops being a backup-restore ticket.

Next in the series, the filter everyone actually asks for — the search box: Free-text search that hits the index.

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