Wie arbeiten Interfaces, DataServices, Mapper und ViewModels zusammen? (Teil 1)
Bevor du eine einzige Zeile Code schreibst, brauchst du eine Architektur. Nicht weil es in irgendeinem Lehrbuch steht, sondern weil du ohne sie nach drei Wochen in deinem eigenen Code ertrinken wirst. Bei EchoPlay helfen vier zentrale Bausteine dabei, Ordnung zu halten: Interfaces, DataServices, Mapper und ViewModels. Sie sorgen dafür, dass die Anwendung flexibel, wartbar und testbar bleibt — und dass keine Schicht zu viel über eine andere wissen muss.
Die vier Bausteine im Überblick
Interfaces beschreiben, welche Methoden ein Dienst anbietet — ohne deren konkrete Umsetzung zu kennen. Die Benutzeroberfläche weiß dadurch nur: „Es gibt eine Methode GetAllAsync().“ Wie die Daten geladen werden, ob aus SQLite, einer anderen Datenbank oder einem Fake für Tests, bleibt ihr verborgen. Dieses Prinzip nennt sich Abstraktion — und es ist der Schlüssel zu austauschbaren Komponenten. Stell dir ein Interface vor wie einen Stecker: Solange die Steckdose die richtige Form hat, ist es egal, ob dahinter ein Kohlekraftwerk oder ein Windrad steckt.
DataServices sind die Brücke zwischen der Benutzeroberfläche und der Datenbank. Sie implementieren das Interface und enthalten die Logik, um Daten zu laden, zu speichern oder zu löschen. Der Rest der Anwendung ruft einfach die Methoden des Services auf und muss sich nicht um SQL-Abfragen oder Entity Framework Core kümmern. Wenn du morgen von SQLite auf PostgreSQL wechseln willst, änderst du nur den DataService — nicht die gesamte Anwendung.
Mapper übersetzen zwischen zwei Datenformaten. In EchoPlay passiert das zum Beispiel, wenn Daten von der Spotify-API ankommen: Sie liegen als SpotifyArtistDto vor — ein reines Datenübertragungsobjekt (DTO), das die Spotify-Struktur widerspiegelt. Der Mapper wandelt das in ein ImportSeries-Modell um, das nur die Informationen enthält, die EchoPlay intern braucht. So wird die Anwendung unabhängig davon, wie Spotify seine Daten strukturiert.
ViewModels sind die Verbindung zwischen den DataServices und der Benutzeroberfläche. Sie laden Daten über den DataService, bereiten sie für die Anzeige auf und stellen Properties bereit, an die das XAML gebunden werden kann. Ein ViewModel enthält keine Datenbanklogik — das ist Aufgabe des DataService.
Die Schichten von EchoPlay
EchoPlay ist in mehrere Projekte aufgeteilt, die jeweils eine klare Aufgabe haben. Das klingt anfangs nach Mehraufwand, zahlt sich aber schnell aus — weil du Änderungen isoliert vornehmen kannst, ohne den Rest der Anwendung zu gefährden.
EchoPlay.Core enthält die fachlichen Abstraktionen und die Scoring-Logik — also die Intelligenz, die entscheidet, ob ein Spotify-Künstler ein Hörspiel ist oder nicht. Dieses Projekt hat absichtlich keine Abhängigkeit zu Entity Framework, zur Benutzeroberfläche oder zu externen APIs. Es ist reines C# ohne äußere Einflüsse. Warum? Weil du die Kernlogik testen können willst, ohne eine Datenbank oder einen Webserver hochzufahren.
EchoPlay.Data kümmert sich um alles, was mit der Datenbank zu tun hat: die Entitäten (also die Klassen, die Datenbanktabellen repräsentieren), die DataServices und die Konfiguration von Entity Framework Core mit SQLite.
EchoPlay.Spotify kapselt die Spotify-Web-API vollständig. API-Clients, Datenübertragungsobjekte (DTOs) und das Mapping von Spotify-Daten in EchoPlay-Modelle liegen hier. Dieses Projekt kennt weder die Datenbank noch die Benutzeroberfläche.
EchoPlay.LocalLibrary ist für die lokale Hörspielsammlung zuständig: Dateisystem-Scanner, Cover-Suche im Ordner und ID3-Tag-Auswertung. Es darf auf EchoPlay.Core zugreifen, aber nicht auf EchoPlay.Data — die Persistierung übernimmt die App-Schicht.
EchoPlay.App ist die eigentliche Windows-Anwendung mit WinUI 3. Hier werden alle Schichten zusammengefügt — das nennt sich Composition Root. Dieser Begriff beschreibt den einen Ort in deiner Anwendung, an dem alle Abhängigkeiten verdrahtet werden. Außerdem liegen hier die ViewModels und die Pages, also die XAML-Dateien für die Benutzeroberfläche.
Ein konkretes Beispiel: Eine Serie anzeigen
Die Benutzeroberfläche möchte alle Hörspielserien als Kacheln darstellen. Dazu greift sie nicht direkt auf die Datenbank zu — das wäre ein Verstoß gegen die Schichtentrennung. Stattdessen geschieht Folgendes: Das SeriesListViewModel besitzt eine Methode LoadAsync(). Sie erstellt einen eigenen DI-Scope, holt sich den ISeriesDataService aus dem Dependency-Injection-Container und ruft GetAllAsync() auf.
public async Task LoadAsync()
{
IsLoading = true;
try
{
// Ein eigener Scope wird erstellt, weil der DbContext als Scoped registriert ist —
// er darf nur für die Dauer eines Vorgangs leben, nicht dauerhaft im Singleton-ViewModel.
using IServiceScope scope = _scopeFactory.CreateScope();
ISeriesDataService service = scope.ServiceProvider
.GetRequiredService<ISeriesDataService>();
IReadOnlyList<Series> dbSeries = await service.GetAllAsync();
// Aus den Entitäten werden einfache ViewModel-Objekte für die Kacheln gebaut.
List<SeriesCardViewModel> cards = new(dbSeries.Count);
foreach (Series series in dbSeries)
{
cards.Add(new SeriesCardViewModel
{
Id = series.Id,
Title = series.Title,
CoverImage = BuildCoverImage(series)
});
}
_allSeries = cards;
ApplyFilter();
}
finally
{
// IsLoading wird immer zurückgesetzt — auch wenn ein Fehler auftritt.
IsLoading = false;
}
}
Das Besondere an IServiceScopeFactory: Der DbContext ist in EchoPlay als Scoped registriert, das heißt, er soll für jeden Datenbankvorgang frisch erstellt und danach wieder freigegeben werden. ViewModels sind aber als Transient registriert — sie haben keinen eigenen Lebenszyklus-Scope. Deshalb erzeugt das ViewModel selbst einen kurzlebigen Scope, holt sich den Service und schließt den Scope danach mit using. So werden keine Datenbankverbindungen offen gehalten.
Das Interface als Vertrag
Das Interface ISeriesDataService definiert alle Methoden, die ein Serien-Dienst anbieten muss. Denk daran wie an einen Arbeitsvertrag: Darin steht, was der Dienst leisten muss — aber nicht, wie er es tut.
public interface ISeriesDataService
{
// Liefert alle aktiven Serien, nach Titel sortiert.
Task<IReadOnlyList<Series>> GetAllAsync();
// Sucht eine bestimmte Serie anhand ihrer ID.
Task<Series?> GetByIdAsync(Guid id);
// Speichert eine neue Serie dauerhaft.
Task AddAsync(Series series);
// Aktualisiert eine vorhandene Serie.
Task UpdateAsync(Series series);
// Löscht eine Serie „soft" — sie bleibt in der Datenbank, wird aber als gelöscht markiert.
Task DeleteAsync(Guid id);
}
Das Rückgabeformat IReadOnlyList<Series> ist bewusst gewählt: Die Liste kann von außen nicht verändert werden. Wer die Methode aufruft, bekommt eine unveränderliche Momentaufnahme der Daten — das verhindert unbeabsichtigte Seiteneffekte. Und das Fragezeichen bei Series? in GetByIdAsync signalisiert: Diese Methode kann null zurückliefern, wenn keine Serie mit dieser ID existiert. Das ist Nullable Reference Types — ein Sicherheitsmechanismus in C#, der dich zwingt, fehlende Werte bewusst zu behandeln.
Soft-Delete: Löschen ohne Datenverlust
EchoPlay löscht Datensätze nicht wirklich aus der Datenbank. Stattdessen wird ein Flag IsDeleted auf true gesetzt und der Zeitpunkt des Löschens gespeichert. Das Konzept heißt Soft-Delete. Entity Framework Core ist so konfiguriert, dass alle Abfragen automatisch nur Datensätze zurückliefern, bei denen IsDeleted = false ist — das ist ein sogenannter Query Filter, der bei jeder Abfrage unsichtbar hinzugefügt wird. Der Vorteil: Versehentlich gelöschte Daten können wiederhergestellt werden. Außerdem bleiben Referenzen auf gelöschte Datensätze erhalten, ohne die Datenbankintegrität zu verletzen.
Mapping: Spotify-Daten in EchoPlay-Modelle übersetzen
Wenn EchoPlay bei Spotify nach Hörspielserien sucht, kommen die Daten als SpotifyArtistDto an — einem einfachen Datencontainer, der die Struktur der Spotify-API widerspiegelt. Der SpotifySeriesMapper übersetzt diese Daten in ein ImportSeries-Objekt, das EchoPlay intern versteht:
public async Task<ImportSeries> MapToImportSeriesAsync(
SpotifyArtistDto artist,
string searchQuery)
{
// Der Scorer entscheidet, ob dieser Spotify-Künstler wahrscheinlich ein Hörspiel ist.
HoerspielScoreResult scoreResult = await _scorer.ScoreAsync(artist, searchQuery);
return new ImportSeries
{
SourceSeriesId = artist.SpotifyArtistId,
Source = "Spotify",
Title = artist.Name,
CoverImageUrl = artist.ImageUrl,
IsHoerspiel = scoreResult.IsHoerspiel,
Score = scoreResult.Score
};
}
Das ImportSeries-Modell kennt keine Spotify-spezifischen Felder mehr — es ist ein neutrales EchoPlay-Modell. Würde morgen Apple Music als Quelle hinzukommen, schreibst du einfach einen neuen Mapper, der Apple-Music-Daten in dieselbe ImportSeries-Struktur übersetzt. Die restliche Anwendung merkt davon nichts. Genau das ist der Sinn von Mapping: Entkopplung von externen Datenformaten.
Die Entität: Die Series-Klasse
Eine Hörspielserie in der Datenbank wird durch die Klasse Series repräsentiert. Sie erbt von BaseEntity, die technische Felder wie Id, CreatedAt, UpdatedAt und IsDeleted bereitstellt:
public class Series : BaseEntity
{
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public string? CoverImageUrl { get; set; }
public string? SpotifyArtistId { get; set; }
public string? AppleMusicArtistId { get; set; }
public bool IsCompleted { get; set; }
public bool IsWatched { get; set; }
public string? LocalFolderPath { get; set; }
}
Das Fragezeichen hinter einem Typ (string?) bedeutet in C#, dass der Wert null sein darf — also dass er nicht vorhanden sein muss. Title dagegen hat kein Fragezeichen und einen Standardwert von string.Empty, weil eine Serie immer einen Titel haben muss. IsWatched markiert Serien, die der Nutzer auf Neuerscheinungen überwacht.
Warum diese Trennung?
Die Benutzeroberfläche kennt keine SQL-Abfragen. Der DataService kennt keine WinUI-Controls. Der Mapper kennt weder die Datenbank noch das ViewModel. Jede Schicht hat genau eine Aufgabe — das nennt sich das Single-Responsibility-Prinzip. Wenn sich die Spotify-API ändert, muss nur der Mapper angepasst werden. Wenn die Datenbank ausgetauscht wird, müssen nur die DataServices angepasst werden. Die Benutzeroberfläche bleibt in beiden Fällen unberührt. Das klingt nach Theorie, aber in der Praxis heißt es: Du kannst eine Komponente ändern, ohne Angst haben zu müssen, dass woanders etwas kaputt geht.
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu