Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/site/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export default defineConfig({
{ text: 'Endpoints', link: '/guide/endpoints' },
{ text: 'Contracts & DTOs', link: '/guide/contracts' },
{ text: 'Database', link: '/guide/database' },
{ text: 'Soft Delete', link: '/guide/soft-delete' },
],
},
{
Expand Down
197 changes: 197 additions & 0 deletions docs/site/guide/soft-delete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
---
outline: deep
---

# Soft Delete

SimpleModule's soft-delete surface mirrors Laravel's `SoftDeletes` trait. Entities that implement `ISoftDelete` are automatically excluded from queries by a global query filter and are kept in the database when `Remove()` is called. The framework also exposes a recovery surface — `WithTrashed`, `OnlyTrashed`, `Restore`, `ForceDelete` — so admin restore UIs, audit flows, and retention policies do not have to leak the abstraction by reaching for `IgnoreQueryFilters()`.

## ISoftDelete

```csharp
public interface ISoftDelete
{
bool IsDeleted { get; set; }
DateTimeOffset? DeletedAt { get; set; }
string? DeletedBy { get; set; }
}
```

`FullAuditableEntity<TId>` implements this for you. When you call `dbContext.Remove(entity)` on an `ISoftDelete` entity, the `EntityInterceptor` converts the delete to an update that sets `IsDeleted=true`, `DeletedAt=UtcNow`, and `DeletedBy` to the current user. A real `DELETE` statement is never issued unless you opt into [force delete](#force-delete).

## Querying Trashed Rows

By default, soft-deleted rows are hidden from every `DbSet<T>` query.

```csharp
public static class SoftDeleteQueryExtensions
{
public static IQueryable<T> WithTrashed<T>(this IQueryable<T> q)
where T : class, ISoftDelete;

public static IQueryable<T> OnlyTrashed<T>(this IQueryable<T> q)
where T : class, ISoftDelete;
}
```

| Extension | Result |
|-----------|--------|
| (default) | Live rows only — soft-delete filter applied |
| `WithTrashed()` | Live + trashed rows |
| `OnlyTrashed()` | Trashed rows only |

```csharp
// Live rows
var customers = await db.Customers.ToListAsync();

// Live + trashed
var all = await db.Customers.WithTrashed().ToListAsync();

// Trashed only — for an admin "Recently Deleted" view
var trashed = await db.Customers
.OnlyTrashed()
.OrderByDescending(c => c.DeletedAt)
.Take(50)
.ToListAsync();
```

::: tip Multi-tenant safety
`WithTrashed` / `OnlyTrashed` ignore only the soft-delete filter. The multi-tenant filter (and any other named filter) stays active, so tenant isolation is preserved when admins browse the trash.
:::

## ISoftDeleteService<T>

For per-row recovery operations, register `ISoftDeleteService<T>` and inject it into your endpoints or services.

```csharp
public interface ISoftDeleteService<T> where T : class, ISoftDelete
{
Task<int> RestoreAsync(object id, CancellationToken ct = default);
Task<int> ForceDeleteAsync(object id, CancellationToken ct = default);
Task<int> ForceDeleteRangeAsync(IEnumerable<object> ids, CancellationToken ct = default);
Task<int> PurgeOlderThanAsync(TimeSpan age, CancellationToken ct = default);
}
```

Register one per soft-deletable entity in the owning module's `ConfigureServices`:

```csharp
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
services.AddModuleDbContext<CustomersDbContext>(configuration, "Customers");
services.AddSoftDelete<Customer, CustomersDbContext>();
}
```

`AddSoftDelete<T, TContext>()` registers a scoped `ISoftDeleteService<T>` backed by the supplied `DbContext`.

## Restore

```csharp
app.MapPost("/customers/{id:int}/restore",
(int id, ISoftDeleteService<Customer> svc, CancellationToken ct) =>
CrudEndpoints.Restore(() => svc.RestoreAsync(id, ct)));
```

Returns `204 No Content` if a trashed row was restored, `404 Not Found` if the id doesn't match a trashed row.

`RestoreAsync` clears `IsDeleted`, `DeletedAt`, and `DeletedBy`. The `EntityInterceptor` writes a fresh `UpdatedAt`/`UpdatedBy` for the row.

## Force Delete

When you genuinely need to remove a row from the database — GDPR purges, test cleanup, or admin-initiated permanent deletion — call the `ForceDelete` extension instead of `Remove`:

```csharp
db.ForceDelete(customer); // single
db.ForceDeleteRange(customers); // batch
await db.SaveChangesAsync();
```

The interceptor consults a per-context marker set, recognises the entry as opted-out of soft delete, and lets the `DELETE` go through. The marker is consumed on observation, so it never leaks across calls.

For id-based force delete, use the service:

```csharp
await svc.ForceDeleteAsync(customerId, ct);
await svc.ForceDeleteRangeAsync([id1, id2, id3], ct);
```

`ForceDeleteAsync` finds the row regardless of trashed state, so it works on both live and already-trashed records.

## Retention / Purge Job

`PurgeOlderThanAsync(age)` permanently deletes every trashed row whose `DeletedAt` is older than the given window. Wire it up as a recurring job using the `BackgroundJobs` module:

```csharp
public sealed class PurgeOldCustomersJob(
ISoftDeleteService<Customer> softDelete,
ISettingsContracts settings
) : IModuleJob
{
public async Task ExecuteAsync(IJobExecutionContext context, CancellationToken ct)
{
var raw = await settings.GetSettingAsync("SoftDelete.Customer.RetainDays", SettingScope.System);
var days = int.TryParse(raw, out var d) ? d : 90;
var purged = await softDelete.PurgeOlderThanAsync(TimeSpan.FromDays(days), ct);
context.Log($"Purged {purged} customer rows older than {days} days");
}
}

// In your module's ConfigureServices
services.AddModuleJob<PurgeOldCustomersJob>();

// On startup, schedule it
await backgroundJobs.AddRecurringAsync<PurgeOldCustomersJob>(
name: "Purge old customers",
cronExpression: "0 3 * * *"); // every day at 03:00
```

The retention window is read from a per-entity setting (`SoftDelete.{Entity}.RetainDays`), so operators can tune it without redeploying.

## End-to-End Example

```csharp
// Endpoint module
public sealed class RestoreCustomerEndpoint : IEndpoint
{
public void Configure(RouteGroupBuilder group)
{
group.MapPost("/{id:int}/restore",
(int id, ISoftDeleteService<Customer> svc, CancellationToken ct) =>
CrudEndpoints.Restore(() => svc.RestoreAsync(id, ct)))
.RequireAuthorization("Customers.Restore");
}
}

public sealed class ForceDeleteCustomerEndpoint : IEndpoint
{
public void Configure(RouteGroupBuilder group)
{
group.MapDelete("/{id:int}/force",
(int id, ISoftDeleteService<Customer> svc, CancellationToken ct) =>
CrudEndpoints.ForceDelete(() => svc.ForceDeleteAsync(id, ct)))
.RequireAuthorization("Customers.ForceDelete");
}
}
```

## Reference

| Symbol | Purpose |
|--------|---------|
| `WithTrashed<T>()` | Include soft-deleted rows in a query |
| `OnlyTrashed<T>()` | Return only soft-deleted rows |
| `DbContext.ForceDelete(entity)` | Issue a real DELETE for one row |
| `DbContext.ForceDeleteRange(entities)` | Issue real DELETEs for many rows |
| `ISoftDeleteService<T>.RestoreAsync(id)` | Clear soft-delete fields by id |
| `ISoftDeleteService<T>.ForceDeleteAsync(id)` | Hard delete by id |
| `ISoftDeleteService<T>.ForceDeleteRangeAsync(ids)` | Batch hard delete |
| `ISoftDeleteService<T>.PurgeOlderThanAsync(age)` | Retention sweep |
| `CrudEndpoints.Restore` | Endpoint helper, 204/404 |
| `CrudEndpoints.ForceDelete` | Endpoint helper, 204/404 |

## Next Steps

- [Database](/guide/database) -- query filters, schema isolation, and DbContext wiring
- [Background Jobs](/guide/background-jobs) -- scheduling the retention sweep
- [EF Core Interceptors](/advanced/interceptors) -- how the soft-delete interceptor is wired in
22 changes: 22 additions & 0 deletions framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,26 @@ public static async Task<IResult> Delete(Func<Task> delete)
await delete();
return TypedResults.NoContent();
}

/// <summary>
/// Endpoint helper for restoring a soft-deleted row. The <paramref name="restore"/>
/// delegate should call <c>ISoftDeleteService&lt;T&gt;.RestoreAsync(id)</c> and return
/// the number of rows affected (0 → 404, 1 → 204).
/// </summary>
public static async Task<IResult> Restore(Func<Task<int>> restore)
{
var affected = await restore();
return affected > 0 ? TypedResults.NoContent() : TypedResults.NotFound();
}

/// <summary>
/// Endpoint helper for force-deleting a row (bypassing soft delete). The
/// <paramref name="forceDelete"/> delegate should call
/// <c>ISoftDeleteService&lt;T&gt;.ForceDeleteAsync(id)</c>.
/// </summary>
public static async Task<IResult> ForceDelete(Func<Task<int>> forceDelete)
{
var affected = await forceDelete();
return affected > 0 ? TypedResults.NoContent() : TypedResults.NotFound();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Microsoft.EntityFrameworkCore.ChangeTracking;
using Microsoft.EntityFrameworkCore.Diagnostics;
using SimpleModule.Core.Entities;
using SimpleModule.Database.SoftDelete;

namespace SimpleModule.Database.Interceptors;

Expand Down Expand Up @@ -65,6 +66,11 @@ is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)
break;

case EntityState.Deleted when entry.Entity is ISoftDelete sd:
if (ForceDeleteExtensions.IsMarked(eventData.Context, sd))
{
ForceDeleteExtensions.Consume(eventData.Context, sd);
break;
}
entry.State = EntityState.Modified;
sd.IsDeleted = true;
sd.DeletedAt = now;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Runtime.CompilerServices;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
using SimpleModule.Core.Entities;

namespace SimpleModule.Database.SoftDelete;

/// <summary>
/// Extensions that bypass the soft-delete interceptor and issue a real <c>DELETE</c>
/// against the database. Required for GDPR purges, test cleanup, and recovery flows
/// where retaining the row is undesirable.
/// </summary>
/// <remarks>
/// The marker is tracked per-entity in a <see cref="ConditionalWeakTable{TKey, TValue}"/>
/// keyed by <see cref="DbContext"/>, then consumed (and removed) by
/// <c>EntityInterceptor</c> when it inspects each entry. Markers do not survive past
/// the next <c>SaveChanges</c> on that context.
/// <para>
/// If <c>SaveChanges</c> never runs after a <c>ForceDelete</c> call (e.g. an exception
/// aborts the unit of work, or the entity is detached via <c>ChangeTracker.Clear()</c>
/// before save), the marker remains until the <see cref="DbContext"/> itself is collected.
/// This is bounded — scoped contexts are short-lived — but callers using long-lived
/// contexts should not stage force-deletes they don't intend to commit.
/// </para>
/// </remarks>
public static class ForceDeleteExtensions
{
private static readonly ConditionalWeakTable<DbContext, HashSet<object>> Markers = new();

/// <summary>
/// Marks <paramref name="entity"/> for hard delete and removes it from the context.
/// The soft-delete interceptor will leave this entry alone, so a real <c>DELETE</c>
/// statement is issued on <c>SaveChanges</c>.
/// </summary>
public static EntityEntry<T> ForceDelete<T>(this DbContext context, T entity)
where T : class, ISoftDelete
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(entity);

Mark(context, entity);
return context.Remove(entity);
}

/// <summary>
/// Marks each entity for hard delete and removes them from the context.
/// </summary>
public static void ForceDeleteRange<T>(this DbContext context, IEnumerable<T> entities)
where T : class, ISoftDelete
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(entities);

foreach (var entity in entities)
{
Mark(context, entity);
context.Remove(entity);
}
}

internal static bool IsMarked(DbContext context, object entity) =>
Markers.TryGetValue(context, out var set) && set.Contains(entity);

/// <summary>
/// Removes the marker for <paramref name="entity"/> after the interceptor has
/// observed it. Called by <c>EntityInterceptor</c> per entry to keep the marker
/// set bounded.
/// </summary>
internal static void Consume(DbContext context, object entity)
{
if (Markers.TryGetValue(context, out var set))
{
set.Remove(entity);
}
}

private static void Mark(DbContext context, object entity)
{
var set = Markers.GetValue(context, _ => []);
set.Add(entity);
}
}
44 changes: 44 additions & 0 deletions framework/SimpleModule.Database/SoftDelete/ISoftDeleteService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using SimpleModule.Core.Entities;

namespace SimpleModule.Database.SoftDelete;

/// <summary>
/// Recovery operations for a soft-deletable entity. Mirrors the operations needed by
/// admin restore UIs, GDPR purge flows, and retention policies.
/// </summary>
/// <typeparam name="T">The <see cref="ISoftDelete"/> entity type managed by this service.</typeparam>
public interface ISoftDeleteService<T>
where T : class, ISoftDelete
{
/// <summary>
/// Restores a single soft-deleted row by primary key. The row's <see cref="ISoftDelete.IsDeleted"/>,
/// <see cref="ISoftDelete.DeletedAt"/>, and <see cref="ISoftDelete.DeletedBy"/> fields are cleared.
/// </summary>
/// <returns>1 if a row was restored; 0 if the id was not found among trashed rows.</returns>
Task<int> RestoreAsync(object id, CancellationToken cancellationToken = default);

/// <summary>
/// Issues a real <c>DELETE</c> for the row with the given key, bypassing the soft-delete
/// interceptor. The row is removed from the database whether or not it was previously
/// soft-deleted.
/// </summary>
/// <returns>1 if a row was deleted; 0 if the id was not found.</returns>
Task<int> ForceDeleteAsync(object id, CancellationToken cancellationToken = default);

/// <summary>
/// Issues a real <c>DELETE</c> for each row matching the given keys.
/// </summary>
/// <returns>The number of rows actually deleted.</returns>
Task<int> ForceDeleteRangeAsync(
IEnumerable<object> ids,
CancellationToken cancellationToken = default
);

/// <summary>
/// Permanently deletes every soft-deleted row whose <see cref="ISoftDelete.DeletedAt"/>
/// is older than <paramref name="age"/>. Use this from a scheduled background job to
/// implement retention policies.
/// </summary>
/// <returns>The number of rows purged.</returns>
Task<int> PurgeOlderThanAsync(TimeSpan age, CancellationToken cancellationToken = default);
}
Loading
Loading