Skip to content

Soft Delete

The Soft Delete module provides two strategies for non-destructive deletion: column-based flags and shadow tables. Entities implement ISoftDeletable; the runtime handles the storage strategy transparently.

Quick Start

using Deepstaging.SoftDelete;

// Mark an entity as soft-deletable
public class Order : ISoftDeletable
{
    public OrderId Id { get; set; }
    public string Description { get; set; }
    public bool IsDeleted { get; set; }
    public DateTimeOffset? DeletedAt { get; set; }
}

// Explicit soft-delete via service
await softDeleteService.SoftDeleteAsync(order);

Features

Feature Description
ISoftDeletable Marker interface for soft-deletable entities
ISoftDeleteService<T> Explicit operations — SoftDeleteAsync, SoftDeleteRangeAsync
SoftDeleteStrategy Column (flag-based) or ShadowTable (move to parallel table)
SoftDeleteOptions Strategy selection, shadow table suffix, column names
IDeletedEntityStore Query deleted records for audit/recovery
DeletedRecord Wraps deleted entity with deletion metadata

Strategies

Column Strategy

Marks rows as deleted using a deleted_at column. Requires global query filters to exclude deleted rows from all queries.

orders table:
| id | description | deleted_at          |
|----|-------------|---------------------|
| 1  | Active      | NULL                |
| 2  | Deleted     | 2026-03-15 10:30:00 |

Shadow Table Strategy (default)

Moves deleted rows to a parallel shadow table (e.g., orders_deleted). Keeps hot tables clean with zero query-filter overhead.

orders table:          orders_deleted table:
| id | description |  | id | description | deleted_at          | deleted_by |
|----|-------------|  |----|-------------|---------------------|------------|
| 1  | Active      |  | 2  | Was here    | 2026-03-15 10:30:00 | user-123   |

Configuration

Property Default Description
Strategy ShadowTable Column or ShadowTable
ShadowTableSuffix "_deleted" Suffix for shadow table names
DeletedAtColumn "deleted_at" Timestamp column name
DeletedByColumn "deleted_by" Actor column name

Core Interfaces

// Marker — implement on entities that support soft-delete
public interface ISoftDeletable { }

// Explicit operations (bypasses EF interceptor)
public interface ISoftDeleteService<T> where T : class, ISoftDeletable
{
    Task SoftDeleteAsync(T entity, CancellationToken ct = default);
    Task SoftDeleteRangeAsync(IEnumerable<T> entities, CancellationToken ct = default);
}

Deleted Entity Store

IDeletedEntityStore<T> provides query, restore, and purge operations for soft-deleted entities:

public interface IDeletedEntityStore<T> where T : class, ISoftDeletable
{
    Task<IReadOnlyList<DeletedRecord<T>>> GetDeletedAsync(int limit = 100, CancellationToken ct = default);
    Task<DeletedRecord<T>?> GetDeletedByIdAsync(object id, CancellationToken ct = default);
    Task<T> RestoreAsync(object id, CancellationToken ct = default);
    Task PurgeAsync(object id, CancellationToken ct = default);
    Task<int> PurgeOlderThanAsync(TimeSpan age, CancellationToken ct = default);
}

DeletedRecord\<T>

public sealed record DeletedRecord<T>(T Entity, DateTimeOffset DeletedAt, string? DeletedBy);

Restore Example

// Restore a soft-deleted order
var restored = await deletedStore.RestoreAsync(orderId);
// Order is back in the main table, removed from shadow table

Purge Example

// Permanently remove soft-deleted records older than 90 days
var purged = await deletedStore.PurgeOlderThanAsync(TimeSpan.FromDays(90));
Console.WriteLine($"Purged {purged} records");

Query Deleted Records

// List the 50 most recently deleted orders
var deleted = await deletedStore.GetDeletedAsync(limit: 50);
foreach (var record in deleted)
{
    Console.WriteLine($"{record.Entity.Id} deleted at {record.DeletedAt} by {record.DeletedBy}");
}

Source

src/Core/Deepstaging.Runtime/SoftDelete/