Domainebene

Die Domainebene ist eine optionale Schicht, die sich zwischen der UI-Ebene und der Datenschicht befindet.

Wenn sie enthalten ist, stellt die optionale Domainebene Abhängigkeiten von der UI-Ebene bereit und hängt von der Datenschicht ab.
Abbildung 1. Die Rolle der Domainebene in der Anwendungsarchitektur

Die Domainebene ist für die Kapselung komplexer Geschäftslogik oder einfacher Geschäftslogik verantwortlich, die von mehreren ViewModels wiederverwendet wird. Diese Ebene ist optional, da diese Anforderungen nicht für alle Apps gelten. Sie sollten sie nur dann verwenden, wenn es erforderlich ist, z. B. um Komplexität zu bewältigen oder die Wiederverwendbarkeit zu bevorzugen.

Eine Domainebene bietet folgende Vorteile:

  • Codeduplizierung wird vermieden.
  • Sie verbessert die Lesbarkeit in Klassen, die Domain-Layer-Klassen verwenden.
  • Sie verbessert die Testbarkeit der App.
  • Durch die Aufteilung der Verantwortlichkeiten werden umfangreiche Klassen vermieden.

Um diese Klassen einfach und unkompliziert zu halten, sollte jeder Anwendungsfall nur für eine einzelne Funktion verantwortlich sein und keine veränderlichen Daten enthalten. Stattdessen sollten Sie veränderliche Daten in Ihrer Benutzeroberfläche oder in den Datenebenen verarbeiten.

Namenskonventionen in diesem Leitfaden

In diesem Leitfaden sind Anwendungsfälle nach der einzelnen Aktion benannt, für die sie verantwortlich sind. Die Konvention lautet wie folgt:

Verb im Präsens + Substantiv/Was (optional) + Anwendungsfall.

Beispiel: FormatDateUseCase, LogOutUserUseCase, GetLatestNewsWithAuthorsUseCase oder MakeLoginRequestUseCase.

Abhängigkeiten

In einer typischen App-Architektur befinden sich Anwendungsfallklassen zwischen ViewModels auf der UI-Ebene und Repositories aus der Datenschicht. Das bedeutet, dass Anwendungsfallklassen normalerweise von Repository-Klassen abhängen und mit der UI-Ebene genauso kommunizieren wie Repositories – mit Callbacks (für Java) oder Koroutinen (für Kotlin). Weitere Informationen dazu finden Sie auf der Seite „Datenschicht“.

Sie haben beispielsweise in Ihrer Anwendung eine Anwendungsfallklasse, die Daten aus einem Nachrichten- und einem Autoren-Repository abruft und diese kombiniert:

class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository
) { /* ... */ }

Da Anwendungsfälle wiederverwendbare Logik enthalten, können sie auch für andere Anwendungsfälle verwendet werden. Es ist normal, dass auf der Domainebene mehrere Anwendungsfälle vorhanden sind. Für den im folgenden Beispiel definierten Anwendungsfall kann beispielsweise der Anwendungsfall FormatDateUseCase verwendet werden, wenn mehrere Klassen auf der UI-Ebene Zeitzonen benötigen, um die richtige Meldung auf dem Bildschirm anzuzeigen:

class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository,
  private val formatDateUseCase: FormatDateUseCase
) { /* ... */ }
GetRecentNewsWithAuthorsUseCase hängt von Repository-Klassen aus der Datenschicht ab, aber auch von FormatDataUseCase, einer weiteren Anwendungsfallklasse, die sich ebenfalls auf der Domainebene befindet.
Abbildung 2. Beispiel für eine Abhängigkeitsgrafik für einen Anwendungsfall, der von anderen Anwendungsfällen abhängt.

Anwendungsfälle in Kotlin aufrufen

In Kotlin können Sie Anwendungsfallklasseninstanzen als Funktionen aufrufen, indem Sie die invoke()-Funktion mit dem operator-Modifikator definieren. Hier ein Beispiel:

class FormatDateUseCase(userRepository: UserRepository) {

    private val formatter = SimpleDateFormat(
        userRepository.getPreferredDateFormat(),
        userRepository.getPreferredLocale()
    )

    operator fun invoke(date: Date): String {
        return formatter.format(date)
    }
}

In diesem Beispiel können Sie mit der Methode invoke() in FormatDateUseCase Instanzen der Klasse so aufrufen, als wären sie Funktionen. Die Methode invoke() ist nicht auf eine bestimmte Signatur beschränkt. Sie kann eine beliebige Anzahl von Parametern annehmen und jeden Typ zurückgeben. Sie können invoke() auch mit verschiedenen Signaturen in Ihrer Klasse überladen. In diesem Fall würden Sie den Anwendungsfall aus dem obigen Beispiel wie folgt aufrufen:

class MyViewModel(formatDateUseCase: FormatDateUseCase) : ViewModel() {
    init {
        val today = Calendar.getInstance()
        val todaysDate = formatDateUseCase(today)
        /* ... */
    }
}

Weitere Informationen zum Operator invoke() finden Sie in der Kotlin-Dokumentation.

Lebenszyklus

Anwendungsfälle haben keinen eigenen Lebenszyklus. Stattdessen werden sie der Klasse zugeordnet, die sie verwendet. Das bedeutet, dass Sie Anwendungsfälle aus Klassen auf der UI-Ebene, aus Diensten oder aus der Klasse Application selbst aufrufen können. Da Anwendungsfälle keine änderbaren Daten enthalten sollten, sollten Sie jedes Mal eine neue Instanz einer Anwendungsfallklasse erstellen, wenn Sie sie als Abhängigkeit übergeben.

Threading

Anwendungsfälle aus der Domainebene müssen hauptsicher sein, d. h., sie müssen sicher aus dem Hauptthread aufgerufen werden können. Wenn Anwendungsfallklassen lang andauernde Blockiervorgänge ausführen, sind sie dafür verantwortlich, diese Logik in den entsprechenden Thread zu verschieben. Prüfen Sie jedoch vorher, ob diese blockierenden Vorgänge besser auf anderen Ebenen in der Hierarchie platziert werden sollten. In der Regel finden in der Datenschicht komplexe Berechnungen statt, um die Wiederverwendbarkeit oder das Caching zu fördern. Ein ressourcenintensiver Vorgang auf einer großen Liste ist beispielsweise besser in der Datenschicht als auf der Domainebene zu finden, wenn das Ergebnis im Cache gespeichert werden muss, um es auf mehreren Bildschirmen der Anwendung wiederzuverwenden.

Das folgende Beispiel zeigt einen Anwendungsfall, der seine Arbeit an einem Hintergrundthread ausführt:

class MyUseCase(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {

    suspend operator fun invoke(...) = withContext(defaultDispatcher) {
        // Long-running blocking operations happen on a background thread.
    }
}

Allgemeine Aufgaben

In diesem Abschnitt wird beschrieben, wie Sie gängige Aufgaben der Domainebene ausführen.

Wiederverwendbare einfache Geschäftslogik

Sie sollten die auf der UI-Ebene vorhandene wiederholbare Geschäftslogik in einer Anwendungsfallklasse kapseln. Das macht es einfacher, Änderungen überall dort anzuwenden, wo die Logik verwendet wird. Außerdem können Sie die Logik isoliert testen.

Betrachten Sie das zuvor beschriebene FormatDateUseCase-Beispiel. Wenn sich Ihre geschäftlichen Anforderungen an die Datumsformatierung in Zukunft ändern, müssen Sie den Code nur an einem zentralen Ort ändern.

Repositories kombinieren

In einer Nachrichtenanwendung können Sie die Klassen NewsRepository und AuthorsRepository verwenden, die Nachrichten- bzw. Autorendatenvorgänge verarbeiten. Die Klasse Article, die von NewsRepository verfügbar gemacht wird, enthält nur den Namen des Autors. Sie möchten jedoch, dass auf dem Bildschirm weitere Informationen zum Autor angezeigt werden. Informationen zum Autor finden Sie in der AuthorsRepository.

GetRecentNewsWithAuthorsUseCase hängt von zwei verschiedenen Repository-Klassen aus der Datenschicht ab: NewsRepository und AuthorsRepository.
Abbildung 3. Abhängigkeitsdiagramm für einen Anwendungsfall, der Daten aus mehreren Repositories kombiniert

Da die Logik mehrere Repositories umfasst und komplex werden kann, erstellen Sie eine GetLatestNewsWithAuthorsUseCase-Klasse, um die Logik aus ViewModel zu abstrahieren und sie lesbarer zu machen. Dies erleichtert auch das isolierte Testen der Logik und die Wiederverwendbarkeit in verschiedenen Teilen der App.

/**
 * This use case fetches the latest news and the associated author.
 */
class GetLatestNewsWithAuthorsUseCase(
  private val newsRepository: NewsRepository,
  private val authorsRepository: AuthorsRepository,
  private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend operator fun invoke(): List<ArticleWithAuthor> =
        withContext(defaultDispatcher) {
            val news = newsRepository.fetchLatestNews()
            val result: MutableList<ArticleWithAuthor> = mutableListOf()
            // This is not parallelized, the use case is linearly slow.
            for (article in news) {
                // The repository exposes suspend functions
                val author = authorsRepository.getAuthor(article.authorId)
                result.add(ArticleWithAuthor(article, author))
            }
            result
        }
}

Die Logik ordnet alle Elemente in der news-Liste zu. Auch wenn die Datenschicht zuverlässig ist, sollte der Hauptthread dabei nicht blockiert werden, da Sie nicht wissen, wie viele Elemente verarbeitet werden. Aus diesem Grund wird im Anwendungsfall die Arbeit mithilfe des Standard-Dispatcher in einen Hintergrundthread verschoben.

Andere Verbraucher

Neben der UI-Ebene kann die Domainebene von anderen Klassen wie Diensten und der Klasse Application wiederverwendet werden. Wenn andere Plattformen wie TV oder Wear eine Codebasis mit der mobilen App teilen, können auf ihrer UI-Ebene auch Anwendungsfälle wiederverwendet werden, um alle oben genannten Vorteile der Domainebene zu nutzen.

Zugriffsbeschränkung für Datenschicht

Ein weiterer Aspekt beim Implementieren der Domainebene ist, ob Sie weiterhin direkten Zugriff auf die Datenschicht über die UI-Ebene zulassen oder alles durch die Domainebene erzwingen sollten.

Die UI-Ebene kann nicht direkt auf die Datenschicht zugreifen. Sie muss die Domain-Ebene durchlaufen.
Abbildung 4: Abhängigkeitsdiagramm, das zeigt, wie auf der UI-Ebene der Zugriff auf die Datenschicht verweigert wird.

Diese Einschränkung hat den Vorteil, dass Ihre UI dann keine Logik der Domainebene umgeht. Dies ist beispielsweise der Fall, wenn Sie für jede Zugriffsanfrage auf die Datenschicht ein Analyse-Logging durchführen.

Der möglicherweise erhebliche Nachteil besteht jedoch darin, dass Sie der Datenschicht auch dann Anwendungsfälle hinzufügen müssen, wenn es sich nur um einfache Funktionsaufrufe handelt. Dadurch kann die Komplexität erhöht werden, ohne dass ein Vorteil entsteht.

Es empfiehlt sich, Anwendungsfälle nur bei Bedarf hinzuzufügen. Wenn Sie feststellen, dass Ihre UI-Ebene fast ausschließlich über Anwendungsfälle auf Daten zugreift, kann es sinnvoll sein, nur auf diese Weise auf Daten zuzugreifen.

Die Entscheidung, den Zugriff auf die Datenschicht einzuschränken, hängt letztendlich von Ihrer individuellen Codebasis ab und davon, ob Sie strenge Regeln oder einen flexibleren Ansatz bevorzugen.

Testen

Beim Testen der Domainebene gelten die allgemeinen Testrichtlinien. Bei anderen UI-Tests verwenden Entwickler in der Regel fiktive Repositories. Es empfiehlt sich, diese auch beim Testen der Domainebene zu verwenden.

Produktproben

In den folgenden Google-Beispielen wird die Verwendung der Domainebene veranschaulicht. Sehen Sie sich diese Tipps in der Praxis an: