Soft-Delete: Datensätze löschen ohne Datenverlust (Teil 5)

Stell dir vor, ein Nutzer löscht versehentlich eine Hörspielserie aus seiner Bibliothek. Beim nächsten Start ist alles weg — die Serie, alle Episoden, alle Abspielstände. Das ist frustrierend und nicht wiederherstellbar. Genau dieses Problem löst das Soft-Delete-Muster: Statt Daten wirklich zu löschen, werden sie nur als gelöscht markiert — und bleiben jederzeit wiederherstellbar.

Was bedeutet Soft-Delete?

Beim klassischen Hard-Delete wird eine Datenbankzeile mit DELETE FROM Series WHERE Id = '...' entfernt. Das ist endgültig — die Daten sind unwiderruflich weg. Beim Soft-Delete bleibt die Zeile erhalten. Stattdessen wird ein Flag IsDeleted auf true gesetzt und der Löschzeitpunkt gespeichert. Die Zeile existiert weiterhin in der Datenbank, erscheint aber bei normalen Abfragen nicht mehr. Für die Anwendung sieht es so aus, als wäre der Datensatz gelöscht — aber unter der Haube ist er noch da.

Die Basis: BaseEntity und ISoftDeletable

Alle persistierten Entitäten in EchoPlay erben von BaseEntity. Diese abstrakte Klasse enthält alle technischen Felder — ID, Zeitstempel und den Soft-Delete-Zustand:

public abstract class BaseEntity : ISoftDeletable
{
    public Guid Id { get; protected set; }
    public DateTime CreatedAt { get; protected set; } = DateTime.UtcNow;
    public DateTime? UpdatedAt { get; protected set; }
    public bool IsDeleted { get; protected set; }
    public DateTime? DeletedAt { get; protected set; }

    public void MarkAsDeleted(DateTime deletedAt)
    {
        if (IsDeleted)
        {
            return;
        }

        IsDeleted = true;
        DeletedAt = deletedAt;
    }

    public void MarkAsUpdated(DateTime updatedAt)
    {
        UpdatedAt = updatedAt;
    }
}

Drei Details verdienen hier besondere Aufmerksamkeit. Erstens: Die Setter sind mit protected set geschützt. Nur Klassen, die von BaseEntity erben, oder BaseEntity selbst dürfen Id, IsDeleted und die anderen Felder setzen. Von außen ist die Entität unveränderlich — Änderungen gehen immer über die Methoden MarkAsDeleted und MarkAsUpdated.

Zweitens: MarkAsDeleted ist idempotent. Das bedeutet, wenn eine Entität bereits gelöscht ist, passiert beim zweiten Aufruf nichts. Das verhindert, dass DeletedAt überschrieben wird — der ursprüngliche Löschzeitpunkt bleibt erhalten.

Drittens: Das Interface ISoftDeletable definiert, welche Properties ein Soft-Delete-fähiges Objekt besitzen muss. Der globale Query Filter im DbContext greift genau auf diese Properties zurück — dazu gleich mehr.

Cascade Soft-Delete: Die Kette nach unten

Das eigentliche Problem entsteht, wenn eine Serie gelöscht wird. Eine Serie hat mehrere Episoden, und jede Episode hat Abspielstände. Wenn die Serie als gelöscht markiert wird, müssen auch alle untergeordneten Objekte mitgelöscht werden — sonst entstehen verwaiste Einträge, die keiner übergeordneten Serie mehr zugeordnet sind. EchoPlay löst das mit einer Transaktion in SeriesDataService.DeleteAsync:

await using (IDbContextTransaction transaction = await _context.Database.BeginTransactionAsync())
{
    try
    {
        series.MarkAsDeleted(DateTime.UtcNow);

        List<Episode> episodes = await _context.Episodes
            .Where(episode => episode.SeriesId == id)
            .ToListAsync();

        // Alle EpisodeIds sammeln — PlaybackStates in einem Query laden (N+1 vermeiden)
        List<Guid> episodeIds = episodes.Select(episode => episode.Id).ToList();

        List<PlaybackState> playbackStates = await _context.PlaybackStates
            .Where(state => episodeIds.Contains(state.EpisodeId))
            .ToListAsync();

        foreach (Episode episode in episodes)
        {
            episode.MarkAsDeleted(DateTime.UtcNow);
        }

        foreach (PlaybackState playbackState in playbackStates)
        {
            playbackState.MarkAsDeleted(DateTime.UtcNow);
        }

        await _context.SaveChangesAsync();
        await transaction.CommitAsync();
    }
    catch (Exception ex)
    {
        await transaction.RollbackAsync();
        throw;
    }
}

Hier arbeiten mehrere Konzepte zusammen. Die Transaktion stellt sicher, dass alle Änderungen erst dann dauerhaft gespeichert werden, wenn CommitAsync() aufgerufen wird. Scheitert irgendetwas dazwischen — ein Datenbankfehler, ein Programmierfehler — wird mit RollbackAsync() alles zurückgesetzt. Entweder wird alles gelöscht oder nichts. Das ist das Alles-oder-nichts-Prinzip, das in der Datenbankwelt als Atomizität bekannt ist.

Außerdem wird hier bewusst das N+1-Problem vermieden. Man könnte für jede Episode einzeln die PlaybackStates laden: einmal für Episode 1, einmal für Episode 2, und so weiter. Das wären bei 50 Episoden 50 Datenbankabfragen. EchoPlay lädt stattdessen alle Episode-IDs in eine Liste und holt alle PlaybackStates in einem einzigen Query mit episodeIds.Contains(state.EpisodeId). Das erzeugt eine WHERE EpisodeId IN (...) Abfrage — deutlich effizienter.

Was passiert mit dem Query Filter?

Wenn eine Entität IsDeleted = true hat, greift der globale Query Filter, der im DbContext in OnModelCreating eingerichtet wird, und blendet sie automatisch aus:

modelBuilder.Entity<Series>().HasQueryFilter(entity => !entity.IsDeleted);

Das bedeutet: Nach dem Soft-Delete erscheint die Serie nicht mehr bei GetAllAsync(), GetByIdAsync() oder irgendeiner anderen normalen Abfrage. Sie ist aus Sicht der Anwendung gelöscht — aber in der Datenbank vorhanden und bei Bedarf wiederherstellbar.

Ein wichtiges Detail dabei: FindAsync berücksichtigt den Query Filter nicht auf dieselbe Weise wie Where-Abfragen. Wenn du _context.Series.FindAsync(id) aufrufst und die Serie mit dieser ID existiert, aber als gelöscht markiert ist, gibt Entity Framework Core sie trotzdem zurück. Das ist kein Fehler, sondern dokumentiertes Verhalten von Entity Framework Core — aber du musst es wissen. In DeleteAsync ist das gewollt: Erst die Serie laden, dann prüfen und markieren.

Warum Soft-Delete und nicht Archivieren?

Eine Alternative wäre, gelöschte Einträge in eine separate Archivtabelle zu verschieben. Das hätte den Vorteil sauberer Haupttabellen, bringt aber massive Nachteile mit sich: Foreign-Key-Referenzen würden kaputt gehen, Queries müssten beide Tabellen berücksichtigen, und die Migrations werden deutlich komplexer. Das Soft-Delete-Muster hält die Datenbankstruktur einfach, ist mit einem globalen Query Filter vollständig transparent, und ermöglicht eine einfache Wiederherstellung durch ein schlichtes IsDeleted = false.

Was nicht gemacht werden sollte

Ein häufiger Fehler wäre, IsDeleted direkt zu setzen statt die Methode zu verwenden:

// FALSCH: Kein Rollback bei Fehler, kein Cascade
series.IsDeleted = true; // IsDeleted hat protected set — kompiliert nicht einmal
await _context.SaveChangesAsync();

Das direkte Setzen von Properties funktioniert hier nicht, weil IsDeleted einen protected set hat. Du kommst an das Löschen nur über MarkAsDeleted() — das ist Absicht und erzwingt den korrekten Ablauf mit Zeitstempel und Idempotenz.

// FALSCH: Hard-Delete — geht nicht zurück
_context.Series.Remove(series);
await _context.SaveChangesAsync();

Ein Hard-Delete würde alle verknüpften Episoden und PlaybackStates entweder mit löschen (wenn ON DELETE CASCADE konfiguriert ist) oder zu einem Foreign-Key-Fehler führen. Soft-Delete vermeidet dieses Problem vollständig, weil die Datensätze in der Datenbank erhalten bleiben und alle Referenzen intakt bleiben.

Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu