
In den letzten drei Monaten habe ich mich (aufgrund meiner Bachelorarbeit) intensiv mit Domain-Driven Design in der Flutter-App-Entwicklung beschäftigt. Dabei habe ich viel gelernt und möchte die gewonnenen Erkenntnisse in einer Serie von Blog-Beiträgen teilen.
Im ersten Teil dieser Serie habe ich die theoretischen Grundlagen von Domain-Driven Design (DDD) behandelt. Falls du die Konzepte noch nicht kennen solltest empfehle ich dir, den ersten Artikel kurz zu lesen.
In diesem Beitrag zeige ich einen Weg, sich eine Flutter-App anhand der DDD-Prinzipien entwickeln lässt, um sie wartbar, testbar und erweiterbar zu machen.
Bevor mit der Implementierung begonnen wird, sollte das strategische Design angewendet werden. Wenn dieser Schritt übersprungen oder nicht sorgfältig umgesetzt wird, führt das später fast immer zu erheblichem Mehraufwand. Die Entscheidung, in welche Bounded Contexts die Anwendung aufgegliedert wird, ist ein zentraler Teil des strategischen Designs. Diese sollte ich im Team gemeinsam mit allen relevanten Rollen treffen: Entwickler, Fachexperten, Designer usw.
Dafür habe ich kollaborative Modellierungsmethoden wie das Event Storming genutzt. Zuerst empfehle ich das Big-Picture Event Storming durchzuführen, um im Team einen ersten Überblick über den Geschäftsbereich zu gewinnen. Das ist ein Workshop, an dem möglichst alle Projektbeteiligten teilnehmen sollten. Das Big-Picture Event Storming hilft dabei, die Subdomains zu identifizieren. Anschließend werden diese Subdomains in Kategorien (Core, Supporting, Generic) eingeteilt, was hilft, bei der späteren Implementierung die richtigen Prioritäten zu setzen.
Für jede identifizierte Subdomain wird danach ein Detail Event Storming durchgeführt. Dieser Workshop beschränkt sich hauptsächlich auf Entwickler und Fachexperten. Er hilft, ein tieferes, gemeinsames Verständnis für die Domäne zu entwickeln und liefert erste Anhaltspunkte für das taktische Design, also die technische Umsetzung. Auf dieser Basis wird dann die finale Entscheidung getroffen, in welche Bounded Contexts die Anwendung aufgeteilt wird.
Ein Bounded Context ist eine Einheit mit klaren Grenzen, die typischerweise von einem Team entwickelt wird. Ein Team kann auch mehrere Bounded Contexts verantworten, aber ein Bounded Context sollte nicht von mehreren Teams gleichzeitig entwickelt werden.
Ein kleines Beispiel von einer App, die wahrscheinlich die meisten kennen: Instagram. Mögliche Bounded Contexts könnten hier sein:
Die Festlegung der Bounded Contexts ist eine aktive Team-Entscheidung und sollte daher ausführlich diskutiert werden. Bounded Context stehen in Beziehung zueinander, die in der Context Map festgehalten werden. Diese visualisiert die Abhängigkeiten und Verbindungen zwischen den Bounded Contexts.
Sobald das Team entschieden hat, welche Bounded Contexts es gibt und wie sie miteinander in Beziehung stehen, kann mit der technischen Implementierung begonnen werden.
Ziel der technischen Implementierung ist es, die Bounded Contexts so unabhängig wie möglich voneinander zu gestalten. Es sollten verschiedene Teams an der App arbeiten können (pro Bounded Context ein Team), ohne sich im Code in die Quere zu kommen.
Ein entscheidender Schritt, um die Geschäftslogik möglichst unabhängig und separat zu entwickeln, ist deren Auslagerung in eigenständige Dart-Pakete. Diese Pakete enthalten die reine Geschäftslogik der jeweiligen Bounded Contexts (Entities, Value Objects etc.) und haben keine Abhängigkeit zu Flutter. Der Vorteil dabei ist, dass die Domänenlogik komplett vom Frontend entkoppelt ist. Dasselbe Dart-Paket könnte theoretisch auch in einem Dart-Backend wiederverwendet werden. Die Flutter-App importiert diese Pakete dann einfach als Abhängigkeiten.
Die resultierende Struktur der Anwendung ist in folgender Abbildung zu sehen (BC=Bounded Context):

Innerhalb der App wird zwischen einem core-Ordner und den einzelnen Bounded Contexts unterschieden. Die Domänenlogik der Bounded Contexts liegt außerhalb der App. In den jeweiligen Ordnern der Bounded Contexts innerhalb der App wird dann auf diese ausgelagerte Logik zugegriffen. Hier wird der Code für den jeweiligen Bounded Context entwickelt, der nicht die Geschäftslogik implementiert, also Applikationslogik, die Benutzeroberfläche usw. Im core-Ordner liegen zentrale Komponenten wie Konfigurationen, Navigation oder anderer Code, der keinem Bounded Context direkt zugeordnet werden kann.
Die Ordnerstruktur kann dann beispielsweise so aussehen:

Domain-Driven Design ist architektur-agnostisch, es wird also keine Architektur vorgeschrieben. Die konkrete Struktur und die verwendeten Muster für jeden Bounded Context können variieren, je nachdem, wie komplex die Anforderungen an diesen sind.
Für einfachere Bounded Contexts bietet sich eine Schichtenarchitektur an. Hier wird der Bounded Context klassisch in Präsentations-, Applikations-, Domänen- und Infrastrukturschicht aufgeteilt. Die Domänenschicht bildet das Zentrum und hat keine Abhängigkeiten zu den anderen Schichten.

Für sehr komplexe Bounded Contexts kann auch das CQRS-Muster gewählt werden. Dieses trennt, wie in der Abbildung zu sehen, Lese- und Schreib-Operationen. Das hat den großen Vorteil, dass Anforderungen der Benutzeroberfläche (z.B. komplexe Datenansichten) nicht das Domänenmodell verunreinigen.

Eine detaillierte Beschreibung zur Umsetzung von CQRS in Kombination mit Event Sourcing in Flutter folgt im nächsten Blog-Artikel.
Die hier beschriebene Implementierung der Domänenlogik orientiert sich stark an der YouTube-Serie von ResoCoder zu diesem Thema (Link zu ResoCoder).
Besonders wichtig sind hier die Value Objects. Sie werden nach einem bestimmten Muster implementiert, das die Validität der Daten sicherstellt. Dafür wird die dartz-Bibliothek genutzt, die es erlaubt, in einem Objekt entweder einen Fehler (left) oder den korrekten Wert (right) zu speichern.
Hier ist eine abstrakte Basisklasse für alle Value Objects:
@immutable
abstract class ValueObject<T> {
Either<ValueFailure<T>, T> get value;
const ValueObject();
@override
int get hashCode => value.hashCode;
@override
bool operator ==(Object o) {
if (identical(this, o)) return true;
return o is ValueObject<T> && o.value == value;
}
}
@immutable
class ValueFailure<T> {
final T failedValue;
const ValueFailure(this.failedValue);
}Jedes konkrete Value Object, wie zum Beispiel Email, erbt von dieser Klasse.
class Email extends ValueObject<String> {
@override
final Either<ValueFailure<String>, String> value;
factory Email(String input) {
return Email._(validateEmail(input));
}
const Email._(this.value);
}
Either<ValueFailure<String>, String> validateEmail(String input) {
const emailRegex =
r"""^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+""";
if (RegExp(emailRegex).hasMatch(input)) {
return right(input);
} else {
return left(ValueFailure<String>(input));
}
}Wenn ein Email-Objekt erstellt wird, prüft die validate-Funktion mittels eines Regex-Ausdrucks, ob das Format gültig ist. Das erstellte Objekt enthält danach entweder einen ValueFailure (mit dem fehlerhaften Wert) oder den validen String.
Die Validierung findet also direkt im Value Object und damit in der Domänenschicht statt – nicht in den Widgets oder BLoCs. In der UI muss dann nur noch geprüft werden, ob das Email-Objekt einen gültigen Wert enthält, um z.B. eine Fehlermeldung anzuzeigen:
Beispielsweise in einem Textinput Feld:
// Validator-Funktion in einem Textinput
validator: (value) {
return state.email.value.fold(
(failure) => failure.message,
(_) => null,
);
},Diese Struktur stellt sicher, dass die gesamte Validierungslogik an einem zentralen Ort in der Domänenschicht liegt.
Entities haben eine eindeutige Identität und erben von einer simplen abstrakten Klasse, die sicherstellt, dass eine ID vorhanden ist:
abstract class Entity {
String get id;
}Die anderen Bausteine wie Repositories, Factories und Domain Events sind ebenfalls Teil der Domänenschicht, werden hier aber nicht weiter detailliert behandelt. Wenn du mehr Informationen über diese haben möchtest, kontaktiere mich gerne.
Ein zentrales Ziel ist die lose Kopplung der Bounded Contexts, damit sie unabhängig voneinander entwickelt werden können. Sie müssen miteinander kommunizieren, ohne direkte Abhängigkeiten aufzubauen.
Dafür empfiehlt sich die Nutzung eines Event Bus. Eine dafür geeignete Bibliothek ist event_but (Link zur Bibliothek). Der Event Bus ist eine zentrale Komponente der Anwendung. Ein Bounded Context kann Domain Events an diesen Bus senden. Andere Komponenten, zum Beispiel andere Bounded Contexts, können auf bestimmte Ereignisse hören und darauf reagieren. Der sendende Bounded Context weiß dabei nichts über seine Zuhörer. So entsteht eine entkoppelte Kommunikation.
Wenn im Instagram-Beispiel ein Beitrag erstellt und eine Person markiert wird, könnte ein Event PersonMarkiert veröffentlicht werden. Ein anderer Bounded Context (z.B. Chat) könnte auf dieses Event hören und automatisch eine Nachricht im Chat zwischen den beiden Personen erstellen. Der Post-Bounded-Context wüsste davon nichts. Das Domain Event enthält immer alle Daten, die für die zuhörenden Personen relevant sind, also die Id der beiden Personen, sowie die Id des Beitrags beispielsweise.

Wenn Bounded Contexts doch einmal direkt und synchron Daten austauschen müssen, geschieht dies über eine klar definierte Schnittstelle (API). Diese API dient dazu, die Implementierungsdetails eines Bounded Contexts nach außen zu verbergen.
Die Anwendung von Domain-Driven Design ist besonders wertvoll für große, komplexe Apps, die in Teams entwickelt und langfristig gewartet werden. Der strukturierte Ansatz zahlt sich aus:
Trotz der Vorteile ist die Anwendung von DDD in der Flutter-Entwicklung mit einem Trade-off verbunden. Es ist definitiv mit einem erheblichen initialen Mehraufwand für Planung und Strukturierung zu rechnen.
Dieser Aufwand lohnt sich vor allem bei langlebigen und fachlich komplexen Apps. Für kleinere Prototypen oder einfache Apps ist es nicht ratsam, alle vorgestellten Konzepte anzuwenden.