Ein Abonnement-Flag: Kleines Feld, große Wirkung (Teil 17)
In EchoPlay sieht der Nutzer im Dashboard und in der Mediathek nur die Serien, die er aktiv abonniert hat. Neue Serien landen zunächst unsichtbar in der Datenbank und erscheinen erst nach einem bewussten Abonnement. Um das zu ermöglichen, bekommt die Series-Entity ein einzelnes Feld: IsSubscribed. Klingt simpel — hat aber überraschend viele architektonische Auswirkungen.
Die Entscheidung: bool statt eigene Tabelle
Man könnte ein Abonnement als eigene Entität modellieren — mit Timestamp, Nutzer-ID und weiteren Metadaten. Das wäre nötig, wenn die App mehrere Nutzerprofile unterstützen würde. EchoPlay ist aber eine Single-User-App. Ein einfaches bool-Flag auf der Series-Entity ist deshalb die richtige Wahl: weniger Komplexität, kein unnötiger Join in jeder Abfrage. Das Flag ist trotzdem vorbereitet für die Zukunft — wenn man später Benutzerprofile einführen möchte, kann man eine eigene Subscription-Tabelle anlegen und IsSubscribed durch eine Navigation ersetzen, ohne die bestehenden Migrationen anfassen zu müssen.
public class Series : BaseEntity
{
// ...
public bool IsSubscribed { get; set; }
}
Der Standardwert ist false. Das ist wichtig für bestehende Daten: Wenn die EF-Migration läuft, werden alle vorhandenen Serien mit IsSubscribed = false befüllt.
Die EF-Migration
Entity Framework Core schreibt das neue Feld als INTEGER in SQLite — der Datentyp, den SQLite für boolesche Werte verwendet (0 = false, 1 = true):
migrationBuilder.AddColumn<bool>(
name: "IsSubscribed",
table: "Series",
type: "INTEGER",
nullable: false,
defaultValue: false);
// Alle bestehenden Serien direkt abonnieren
migrationBuilder.Sql("UPDATE "Series" SET "IsSubscribed" = 1 WHERE "IsDeleted" = 0");
Das defaultValue: false ist entscheidend: Ohne Default würde SQLite für alle vorhandenen Zeilen NULL eintragen, was bei einem bool ohne Nullable-Annotation zu einem Laufzeitfehler führen würde. Das anschließende SQL setzt alle nicht-gelöschten Serien direkt auf abonniert. Warum? Weil jede Serie, die vor dieser Migration existierte, vom Nutzer bewusst importiert wurde — und Import und Abonnement sind in EchoPlay dasselbe Konzept.
Zwei neue Methoden im DataService
Das Interface ISeriesDataService bekommt zwei neue Methoden:
Task<IReadOnlyList<Series>> GetSubscribedAsync();
Task SetSubscribedAsync(Guid seriesId, bool isSubscribed);
GetSubscribedAsync nutzt den globalen Query Filter, der bereits alle soft-gelöschten Serien ausblendet. Zusätzlich filtert sie auf IsSubscribed = true:
return await _context.Series
.AsNoTracking()
.Where(series => series.IsSubscribed)
.OrderBy(series => series.Title)
.ToListAsync();
Das AsNoTracking() ist hier richtig: Die Ergebnisse werden nur gelesen und angezeigt, nicht verändert. Entity Framework Core muss also keinen Change-Tracker-Overhead betreiben.
SetSubscribedAsync aktualisiert den Flag einer einzelnen Serie:
Series? series = await _context.Series.FindAsync(seriesId);
if (series is null)
{
_logger.Warning($"Serie '{seriesId}' nicht gefunden – Update übersprungen.");
return;
}
series.IsSubscribed = isSubscribed;
await _context.SaveChangesAsync();
Wichtig: FindAsync umgeht den globalen Query Filter — es findet eine Serie auch dann, wenn sie soft-gelöscht ist. Das ist hier akzeptabel, weil das Setzen des Flags keine Cascade-Logik auslöst und kein sicherheitskritischer Vorgang ist.
Der Unterschied zwischen FindAsync und Where
FindAsync ist der schnellste Weg, eine Entität per Primärschlüssel zu laden. Er schaut zuerst in den Change-Tracker (falls die Entität schon geladen ist, braucht es keinen Datenbankaufruf) und dann in die Datenbank — und zwar ohne den globalen Query Filter anzuwenden. Das ist bewusstes Verhalten von EF Core: Primärschlüssel-Lookups gelten als direkte, explizite Anfragen, die keine globalen Filter brauchen. Where(series => series.IsSubscribed) hingegen erzeugt eine SQL-Abfrage, die den Query Filter automatisch als zusätzliche AND IsDeleted = 0-Bedingung enthält. Das ist der sichere Standard für alle Listen-Abfragen.
Tests: Was geprüft wird
Die vier Tests in SeriesSubscriptionTests decken genau die Stellen ab, an denen etwas schiefgehen könnte. GetSubscribed_ReturnsOnlySubscribed prüft, dass eine nicht-abonnierte Serie unsichtbar bleibt. SetSubscribed_PersistsFlag macht nach dem Update-Aufruf den Change-Tracker leer und liest die Serie erneut aus der Datenbank — damit ist sichergestellt, dass der Wert wirklich persistiert wurde. GetSubscribed_ExcludesSoftDeleted prüft das Zusammenspiel von IsSubscribed = true und IsDeleted = true. Eine gelöschte Serie darf nie angezeigt werden, auch wenn ihr Flag noch auf true steht. GetSubscribed_SortsAlphabetically prüft implizit das OrderBy in der Implementierung.
[Fact]
public async Task SetSubscribed_PersistsFlag()
{
Series series = await DataBuilder.PersistSeriesAsync("TKKG");
Context.ChangeTracker.Clear();
SeriesDataService service = new(Context, NullLoggerFactory);
await service.SetSubscribedAsync(series.Id, true);
// Change-Tracker leeren — der nächste Zugriff geht wirklich in die DB
Context.ChangeTracker.Clear();
Series? updated = await Context.Series.FindAsync(series.Id);
Assert.True(updated!.IsSubscribed);
}
Das Context.ChangeTracker.Clear() ist ein wichtiges Muster in diesen Tests. EF Core puffert Entitäten im Arbeitsspeicher. Würde man nach SetSubscribedAsync direkt FindAsync aufrufen, ohne den Tracker zu leeren, würde EF Core die gecachte Instanz zurückgeben — und der Test würde grün sein, selbst wenn SaveChangesAsync gar nicht aufgerufen wurde. Mit dem Clear() erzwingt man den echten Datenbankzugriff.
Import = Abonnement: Ein Konzept statt zwei
In einer ersten Version hatte EchoPlay zwei getrennte Konzepte: Importieren und Abonnieren. Das führte zu Verwirrung — wenn eine Serie importiert wurde, aber nicht abonniert war, tauchte sie nirgendwo auf. Die Lösung: Import und Abonnement sind dasselbe. Wer eine Serie importiert, hat sie automatisch abonniert. Das IsSubscribed-Flag wird deshalb direkt beim Anlegen auf true gesetzt:
// Im ImportService — jede importierte Serie ist sofort sichtbar
private static Series MapToSeries(ImportSeries importSeries)
{
return new Series
{
Title = importSeries.Title,
SpotifyArtistId = importSeries.Source == "Spotify" ? importSeries.SourceSeriesId : null,
AppleMusicArtistId = importSeries.Source == "AppleMusic" ? importSeries.SourceSeriesId : null,
IsSubscribed = true
};
}
Dasselbe gilt für den lokalen Scan: Jede Serie, die der Scanner findet und automatisch anlegt, bekommt IsSubscribed = true. Dieses Prinzip macht das System berechenbar — nach jedem Scan oder Import ist das Ergebnis sofort in der Mediathek sichtbar, ohne einen zweiten Klick.
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu