Archive & restore
Soft delete done deliberately — a nullable ArchivedAt timestamp, explicit query predicates, restore as a first-class command, a real DELETE for retention, and the common alternatives this recipe rejects.
"Delete" in a product UI is usually not DELETE in SQL: the user wants the record out of the way,
and someone else wants it back next quarter. That is a reversible domain state — archived — not
a deletion, and modeling it honestly is the whole trick. There is deliberately no
Elarion.SoftDelete package or [SoftDelete] attribute: the pattern is a recipe composed from
pieces you already have — an entity property, a partial unique index, two small command handlers, a
scheduled job — and a shipped convention could only hide the two
decisions that actually matter (what each query returns, and what delete means in your domain).
This page is the recommended shape, and — because every team has met IsDeleted and
HasQueryFilter before — why the usual alternatives are rejected.
1. The state is a nullable timestamp
One property: ArchivedAt, null while active. A timestamp costs the same as an IsArchived
boolean and answers when for free — which the purge job below needs anyway. Add ArchivedBy only
if the archived-items screen displays it; the audit trail already records
who executed the command.
public sealed class Customer : IArchivable {
public Guid Id { get; set; }
public required string Name { get; set; }
public required string Email { get; set; }
public DateTimeOffset? ArchivedAt { get; set; }
}
[EntityConfiguration]
public sealed class CustomerConfiguration : IEntityTypeConfiguration<Customer> {
public void Configure(EntityTypeBuilder<Customer> builder) {
builder.HasKey(c => c.Id);
// Partial unique index: only ACTIVE customers reserve an email, so archiving one
// frees the address for a new registration. Make this a per-key decision — an
// invoice *number* must stay reserved forever, so its index would have no filter.
builder.HasIndex(c => c.Email)
.IsUnique()
.HasFilter("\"ArchivedAt\" IS NULL");
// Partial index over the archived minority: serves the archived-items screen and
// the purge probe without taxing writes to active rows.
builder.HasIndex(c => c.ArchivedAt)
.HasFilter("\"ArchivedAt\" IS NOT NULL");
}
}IArchivable is a one-property interface in your shared persistence layer; its only job is to let a
named, reusable predicate exist:
public interface IArchivable {
DateTimeOffset? ArchivedAt { get; set; }
}
public static class ArchivableQueryExtensions {
public static IQueryable<T> WhereActive<T>(this IQueryable<T> query) where T : IArchivable =>
query.Where(e => e.ArchivedAt == null);
public static IQueryable<T> WhereArchived<T>(this IQueryable<T> query) where T : IArchivable =>
query.Where(e => e.ArchivedAt != null);
}Archive at the aggregate root. Line items, addresses, and other owned children are only reached through their root's queries, so they need no flag of their own; a flag on every child row is a sign the aggregate boundary — not the archive pattern — needs work.
2. Every query says what it returns
Filtering is explicit at the call site, and each read surface decides deliberately:
// Lists, pickers, lookups, assignment targets: active only — visible in the query.
var rows = await db.Customers.WhereActive()
.OrderBy(c => c.Name)
.Select(c => new Row(c.Id, c.Name))
.ToListAsync(ct);
// The archived-items screen is just the other predicate.
var archived = await db.Customers.WhereArchived()
.OrderByDescending(c => c.ArchivedAt)
.Select(c => new Row(c.Id, c.Name))
.ToListAsync(ct);The detail query deliberately has no filter: a bookmarked link or a reference from an old
invoice should land on the record with its state showing — "this customer is archived", with a
restore action — not a bewildering 404. Return ArchivedAt in the response and let the client
render the state. (Contrast feature gates, where a generic
NotFound is the point because the feature's existence must not leak; an archived record is a
user-visible state, not a secret.)
Why not a global HasQueryFilter
EF Core's model-level filter looks like the safer choice — "nobody can forget the predicate" — and is the most common way teams do this. This recipe rejects it, for three reasons that compound:
- It makes queries stop saying what SQL runs. The same posture that rejects repositories and
IAppDbContext(the database is application logic) applies to rows: a handler readingdb.Customers.ToListAsync()should mean customers, not customers minus whatever the model silently subtracts.WhereActive()is six characters of honesty per query. - The "can't forget" guarantee is false exactly where Elarion sends you. Query filters apply to
LINQ queries only.
SqlQueryrollups, provider functions, bulk COPY, and the AOT SQL tier never see the filter — and raw SQL is an intended path here, not an escape hatch. You end up with two classes of query that silently disagree about which rows exist, which is worse than one class that states its predicate. - Filtered navigations make rows vanish far from the cause. The filter applies wherever the
entity appears, including joins and
Includes: an active order referencing an archived customer reads as "customer missing", and the resulting bug report never mentions archiving. The all-or-nothingIgnoreQueryFilters()escape hatch drops every filter at once (EF 10's named filters soften this one point), but the first two objections stand regardless.
A global filter earns its keep when the predicate is a security boundary that must hold even when a developer forgets — multi-tenancy is the classic case. Archive visibility is not a security boundary; it is per-screen UX, and per-screen decisions belong at the call site.
3. Archive and restore are ordinary commands
Two handlers, nothing exotic: the TransactionDecorator
owns the unit of work, the state change and the integration event commit
together, and both transitions are naturally idempotent — archiving an archived customer converges
to success with no [Idempotent] machinery needed.
[Handler("crm.customers.archive")]
public sealed class ArchiveCustomer(AppDbContext db, TimeProvider clock, IIntegrationEventBus events)
: IHandler<ArchiveCustomer.Command> {
public sealed record Command(Guid Id) : ICommand;
public async ValueTask<Result> HandleAsync(Command command, CancellationToken ct) {
var customer = await db.Customers.FirstOrDefaultAsync(c => c.Id == command.Id, ct);
if (customer is null) {
return AppError.NotFound($"Customer {command.Id} was not found.");
}
if (customer.ArchivedAt is not null) {
return Result.Success(); // already archived — converged, not a conflict
}
customer.ArchivedAt = clock.GetUtcNow();
await events.PublishAsync(new CustomerArchived { CustomerId = customer.Id }, ct);
await db.SaveChangesAsync(ct);
return Result.Success();
}
}Restore is symmetric, plus the one check archiving created: the partial unique index freed the natural key, so someone may have taken it. The in-transaction check produces the friendly error; the index remains the authority if a race slips past it (business checks run inside the transaction):
[Handler("crm.customers.restore")]
public sealed class RestoreCustomer(AppDbContext db, IIntegrationEventBus events)
: IHandler<RestoreCustomer.Command> {
public sealed record Command(Guid Id) : ICommand;
public async ValueTask<Result> HandleAsync(Command command, CancellationToken ct) {
var customer = await db.Customers.FirstOrDefaultAsync(c => c.Id == command.Id, ct);
if (customer is null) {
return AppError.NotFound($"Customer {command.Id} was not found.");
}
if (customer.ArchivedAt is null) {
return Result.Success(); // already active
}
var emailTaken = await db.Customers.WhereActive()
.AnyAsync(c => c.Email == customer.Email, ct);
if (emailTaken) {
return AppError.Conflict(
$"Cannot restore: the email {customer.Email} is now used by an active customer.");
}
customer.ArchivedAt = null;
await events.PublishAsync(new CustomerRestored { CustomerId = customer.Id }, ct);
await db.SaveChangesAsync(ct);
return Result.Success();
}
}Archived means read-only, enforced in handlers. Every mutating command on the aggregate guards the state itself — the frontend hiding an Edit button is a UX projection, never the enforcement:
if (customer.ArchivedAt is not null) {
return AppError.Conflict($"Customer {command.Id} is archived; restore it before editing.");
}The integration events are the projection seam: other modules react after commit, and a consumer can
push a client event hint so open list screens re-query —
the same shape as the time-series dashboard.
Tag the two handlers [Auditable] if archiving is a compliance-relevant
action; the trail then records who archived what, when, with no extra columns on the entity.
4. Delete is still delete
The flag never replaces DELETE; it schedules it. Two forms remain, both real:
-
Retention purge — a scheduled job hard-deletes rows archived beyond your retention window. The window lives in configuration or runtime settings; on a cluster the per-occurrence claims make exactly one node run it.
public sealed class CustomerPurgeJob(AppDbContext db, TimeProvider clock) { [ScheduledJob("crm.customers.purge", Cron = "0 0 3 * * *")] public async ValueTask RunAsync(CancellationToken ct) { var cutoff = clock.GetUtcNow().AddDays(-180); await db.Customers .Where(c => c.ArchivedAt != null && c.ArchivedAt < cutoff) .ExecuteDeleteAsync(ct); } } -
Erasure on request — a GDPR-style erasure obligation is a hard delete (or anonymization) executed now, as its own command. An
ArchivedAtflag does not satisfy it, and neither does waiting for the purge window.
Naming the state archived rather than deleted is what keeps these honest: the UI can say "Archive" and "Delete" and mean two different, truthfully described operations.
Alternatives this recipe rejects
- A global
HasQueryFilter— the closest call, argued above: it hides the predicate from the call site, silently doesn't cover the raw-SQL and bulk paths Elarion treats as first-class, and makes filtered navigations drop rows far from the cause. - An
IsDeletedflag ("soft delete" proper) — the name lies in both directions: the user reads deleted and believes the data is gone (it isn't — a compliance problem), while the code treats it as retained state (so actual deletion never gets designed — the purge and erasure paths above simply don't exist). Name the state what the domain means — archived, deactivated, closed — and reserve delete forDELETE. - Folding archive into a lifecycle enum — adding
ArchivedtoStatus { Draft, Active, Paid }conflates two axes: archive is orthogonal to the domain lifecycle (a paid invoice gets archived and must still read as paid after restore). One nullable timestamp beside the enum keeps both axes; one merged enum multiplies states and loses the pre-archive value. - Moving rows to an
_archivetable — twice the schema to migrate, foreign keys that dangle or duplicate, and restore becomes a cross-table insert racing the unique indexes. Row volume is not a problem this pattern needs to solve at Elarion's target tier; when cold-data volume is genuinely the concern, that is retention/partitioning (see time-series), not archive semantics. - Hard delete now, restore from the audit trail — the audit trail records actions with diffs for compliance; point-in-time reconstruction is an explicit non-goal. "Undelete" via audit forensics means reassembling a row (and its children, and its foreign keys) by hand under pressure. If restore is a product feature, the restorable state must stay a row.
Pagination
Transport-neutral keyset (cursor) and offset paging that produces a Page<T>, with composite sorts and an opaque cursor codec.
SQL migrations
Startup-applied SQL migrations for EF-free (NativeAOT) hosts — embedded scripts, normalized checksums, no repair command, PostgreSQL and SQLite providers.