Clean Architecture in Flutter: Skalierbare Anwendungen entwickeln
# Clean Architecture in Flutter: Skalierbare Anwendungen entwickeln
Clean Architecture ist mehr als eine Ordnerstruktur. Es ist eine Denkweise, die Ihre Geschaeftslogik unabhaengig von UI-Frameworks, Datenbanken und Drittanbieter-Diensten haelt. In Flutter-Projekten, die ueber eine Handvoll Bildschirme hinauswachsen, zahlt sich dieser Ansatz innerhalb weniger Wochen aus.
Warum Clean Architecture?
Jeder Flutter-Entwickler kennt den Moment, in dem eine "kleine Aenderung" dutzende Dateien betrifft. Ein neues API-Feld bricht ein Widget. Ein Wechsel des State Managements erzwingt das Umschreiben der halben App. Clean Architecture verhindert diese Situationen durch strikte Grenzen zwischen den Schichten.
Die drei Kernschichten
1. Domain-Schicht (Innerste)
Das Herzstueck Ihrer Anwendung. Hier befinden sich Entities, Use Cases und Repository-Vertraege. Sie hat null Abhaengigkeiten zu Flutter, Paketen oder externen Bibliotheken. Nur reines Dart.
2. Data-Schicht
Diese Schicht implementiert die Repository-Vertraege aus der Domain-Schicht. Sie weiss, wie Daten von APIs abgerufen, aus lokalen Datenbanken gelesen und rohe Antworten in Domain-Entities umgewandelt werden.
3. Presentation-Schicht (Aeusserste)
Widgets, Seiten, State Management (BLoC, Riverpod, Cubit usw.) und alles Flutter-Spezifische lebt hier. Diese Schicht haengt von der Domain-Schicht ab, aber niemals direkt von der Data-Schicht.
Projektordner-Struktur
Hier ist die Ordnerstruktur, die ich in produktiven Flutter-Projekten verwende. Jedes Feature bekommt sein eigenes, in sich geschlossenes Verzeichnis:
lib/
core/
error/
exceptions.dart
failures.dart
network/
network_info.dart
usecases/
usecase.dart
di/
injection_container.dart
features/
authentication/
data/
datasources/
auth_remote_data_source.dart
auth_local_data_source.dart
models/
user_model.dart
repositories/
auth_repository_impl.dart
domain/
entities/
user.dart
repositories/
auth_repository.dart
usecases/
login_user.dart
register_user.dart
get_current_user.dart
presentation/
bloc/
auth_bloc.dart
auth_event.dart
auth_state.dart
pages/
login_page.dart
register_page.dart
widgets/
auth_form.dart
products/
data/
...
domain/
...
presentation/
...Das `core/`-Verzeichnis enthaelt gemeinsam genutzte Hilfsmittel. Jedes `features/`-Unterverzeichnis spiegelt die Drei-Schichten-Struktur wider. So bleibt zusammengehoeriger Code beieinander, und ein komplettes Feature laesst sich leicht entfernen oder extrahieren.
Code-Beispiele fuer jede Schicht
Entity (Domain-Schicht)
Entities sind schlichte Dart-Klassen. Keine Annotationen, keine Framework-Abhaengigkeiten.
class User {
final String id;
final String email;
final String displayName;
final DateTime createdAt;
const User({
required this.id,
required this.email,
required this.displayName,
required this.createdAt,
});
bool get isProfileComplete => displayName.isNotEmpty;
}Repository-Vertrag (Domain-Schicht)
Eine abstrakte Klasse, die definiert, welche Operationen existieren, ohne festzulegen, wie sie funktionieren. Der Rueckgabetyp verwendet `Either` aus dem dartz-Paket, um Fehler explizit zu behandeln.
import class="code-string">'package:dartz/dartz.dart';
abstract class AuthRepository {
Future<Either<Failure, User>> login(String email, String password);
Future<Either<Failure, User>> register(String email, String password, String name);
Future<Either<Failure, User>> getCurrentUser();
Future<Either<Failure, void>> logout();
}Use Case (Domain-Schicht)
Jeder Use Case tut genau eine Sache. Das macht Testen trivial und haelt Geschaeftsregeln auffindbar.
class LoginUser {
final AuthRepository repository;
LoginUser(this.repository);
Future<Either<Failure, User>> call(LoginParams params) {
return repository.login(params.email, params.password);
}
}
class LoginParams {
final String email;
final String password;
const LoginParams({required this.email, required this.password});
}Model (Data-Schicht)
Models werden auf Entities gemappt und uebernehmen die JSON-Serialisierung. Sie sind die Bruecke zwischen rohen API-Daten und sauberen Domain-Objekten.
class UserModel {
final String id;
final String email;
final String displayName;
final DateTime createdAt;
const UserModel({
required this.id,
required this.email,
required this.displayName,
required this.createdAt,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json[class="code-string">'id'] as String,
email: json[class="code-string">'email'] as String,
displayName: json[class="code-string">'display_name'] as String,
createdAt: DateTime.parse(json[class="code-string">'created_at'] as String),
);
}
Map<String, dynamic> toJson() {
return {
class="code-string">'id': id,
class="code-string">'email': email,
class="code-string">'display_name': displayName,
class="code-string">'created_at': createdAt.toIso8601String(),
};
}
User toEntity() {
return User(
id: id,
email: email,
displayName: displayName,
createdAt: createdAt,
);
}
}Data Source (Data-Schicht)
Data Sources kuemmern sich um die rohen Ein-/Ausgabeoperationen. Remote Sources kommunizieren mit APIs, lokale Sources mit Datenbanken oder Caches.
abstract class AuthRemoteDataSource {
Future<UserModel> login(String email, String password);
Future<UserModel> register(String email, String password, String name);
}
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
final http.Client client;
AuthRemoteDataSourceImpl({required this.client});
@override
Future<UserModel> login(String email, String password) async {
final response = await client.post(
Uri.parse(class="code-string">'$baseUrl/auth/login'),
body: jsonEncode({class="code-string">'email': email, class="code-string">'password': password}),
headers: {class="code-string">'Content-Type': class="code-string">'application/json'},
);
if (response.statusCode == class="code-number">200) {
return UserModel.fromJson(jsonDecode(response.body));
} else {
throw ServerException(message: class="code-string">'Anmeldung fehlgeschlagen');
}
}
@override
Future<UserModel> register(String email, String password, String name) async {
final response = await client.post(
Uri.parse(class="code-string">'$baseUrl/auth/register'),
body: jsonEncode({class="code-string">'email': email, class="code-string">'password': password, class="code-string">'name': name}),
headers: {class="code-string">'Content-Type': class="code-string">'application/json'},
);
if (response.statusCode == class="code-number">201) {
return UserModel.fromJson(jsonDecode(response.body));
} else {
throw ServerException(message: class="code-string">'Registrierung fehlgeschlagen');
}
}
}Repository-Implementierung (Data-Schicht)
Die Repository-Implementierung faengt Exceptions von Data Sources ab und wandelt sie in typisierte Failures um. Hier koordinieren Sie auch zwischen Remote und Local Sources fuer Caching-Strategien.
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteDataSource remoteDataSource;
final AuthLocalDataSource localDataSource;
final NetworkInfo networkInfo;
AuthRepositoryImpl({
required this.remoteDataSource,
required this.localDataSource,
required this.networkInfo,
});
@override
Future<Either<Failure, User>> login(String email, String password) async {
if (await networkInfo.isConnected) {
try {
final userModel = await remoteDataSource.login(email, password);
await localDataSource.cacheUser(userModel);
return Right(userModel.toEntity());
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
}
} else {
return const Left(NetworkFailure(class="code-string">'Keine Internetverbindung'));
}
}
}BLoC (Presentation-Schicht)
Die Presentation-Schicht konsumiert Use Cases. Sie spricht niemals direkt mit Data Sources oder Repositories.
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final LoginUser loginUser;
final GetCurrentUser getCurrentUser;
AuthBloc({
required this.loginUser,
required this.getCurrentUser,
}) : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
final result = await loginUser(
LoginParams(email: event.email, password: event.password),
);
result.fold(
(failure) => emit(AuthError(failure.message)),
(user) => emit(AuthAuthenticated(user)),
);
}
}Dependency Injection einrichten
Dependency Injection ist der Klebstoff, der alle Schichten verbindet, ohne harte Abhaengigkeiten zu erzeugen. Ich verwende `get_it` fuer Service Location und `injectable` fuer Code-Generierung, aber das folgende manuelle Setup verdeutlicht das Konzept.
import class="code-string">'package:get_it/get_it.dart';
final sl = GetIt.instance;
Future<void> initDependencies() async {
class=class="code-string">"code-comment">// Externe Abhaengigkeiten
sl.registerLazySingleton(() => http.Client());
sl.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl());
class=class="code-string">"code-comment">// Data Sources
sl.registerLazySingleton<AuthRemoteDataSource>(
() => AuthRemoteDataSourceImpl(client: sl()),
);
sl.registerLazySingleton<AuthLocalDataSource>(
() => AuthLocalDataSourceImpl(sharedPreferences: sl()),
);
class=class="code-string">"code-comment">// Repositories
sl.registerLazySingleton<AuthRepository>(
() => AuthRepositoryImpl(
remoteDataSource: sl(),
localDataSource: sl(),
networkInfo: sl(),
),
);
class=class="code-string">"code-comment">// Use Cases
sl.registerLazySingleton(() => LoginUser(sl()));
sl.registerLazySingleton(() => RegisterUser(sl()));
sl.registerLazySingleton(() => GetCurrentUser(sl()));
class=class="code-string">"code-comment">// BLoCs
sl.registerFactory(
() => AuthBloc(loginUser: sl(), getCurrentUser: sl()),
);
}Beachten Sie die Registrierungsreihenfolge: zuerst externe Abhaengigkeiten, dann Data Sources, Repositories, Use Cases und zuletzt BLoCs. BLoCs werden als Factory registriert, damit jeder Bildschirm eine frische Instanz erhaelt.
Wann Clean Architecture uebertrieben ist
Nicht jedes Projekt braucht Clean Architecture. Hier eine ehrliche Einschaetzung.
Verzichten Sie darauf, wenn:
Setzen Sie darauf, wenn:
Nach meiner Erfahrung mit produktiven Flutter-Apps liegt der Break-even-Punkt etwa beim dritten oder vierten Feature. Davor fuehlt sich der Boilerplate-Code uebertrieben an. Danach sehen Sie bei jedem neuen Feature Rendite, weil die Muster bereits etabliert sind und neuer Code sich praktisch von selbst schreibt.
Haeufige Architektur-Fehler
1. Framework-Abhaengigkeiten in die Domain-Schicht einschleppen
Ihre Domain-Schicht sollte reines Dart sein. Sobald Sie `package:flutter/material.dart` oder ein Flutter-spezifisches Paket in einer Entity oder einem Use Case importieren, haben Sie die Architektur gebrochen. Das gilt auch fuer Annotationen von Paketen wie `json_serializable` -- die gehoeren zu den Models in der Data-Schicht, nicht zu den Entities.
2. Die Use-Case-Schicht ueberspringen
Es ist verlockend, Repositories direkt aus BLoCs aufzurufen. Das funktioniert, bis Sie mehrere Repository-Aufrufe kombinieren, Validierung hinzufuegen oder Geschaeftsregeln durchsetzen muessen. Use Cases sind schnell geschrieben und bewahren Sie davor, Logik ueber den Presentation-Code zu verstreuen.
3. Aufgeblaehte Repositories
Ein Repository, das Caching, Retries, Logging, Analytics und Fehler-Mapping gleichzeitig erledigt, macht zu viel. Halten Sie Repositories auf Datenkoordination fokussiert. Lagern Sie Querschnittsbelange in Decorators oder Middleware aus.
4. Ein BLoC fuer alles
Ein einziger riesiger BLoC pro Feature fuehrt zu State-Management-Albtraeumen. Teilen Sie nach Bildschirm oder Verantwortung auf. Ein `AuthBloc`, der Login, Registrierung, Passwort-Reset und Profilaktualisierung gleichzeitig verwaltet, sind vier BLoCs in einem Trenchcoat.
5. Die Abhaengigkeitsregel ignorieren
Abhaengigkeiten sollten nur nach innen zeigen. Presentation haengt von Domain ab. Data haengt von Domain ab. Domain haengt von nichts ab. Wenn Ihre Entity aus der Data-Schicht importiert, bricht die Architektur zusammen. Das ist die wichtigste Regel ueberhaupt.
Praktischer Migrations-Leitfaden
Wenn Sie eine bestehende Flutter-App haben und Clean Architecture einfuehren moechten, finden Sie hier einen schrittweisen Ansatz, den ich bei mehreren Projekten erfolgreich eingesetzt habe.
Schritt 1: Bestandsaufnahme des aktuellen Codes
Listen Sie alle Features auf. Identifizieren Sie diejenigen mit der meisten Geschaeftslogik und den meisten Bugs. Beginnen Sie mit einem Feature, das wichtig, aber nicht geschaeftskritisch ist -- Sie wollen die Muster ohne Hochdruck erlernen.
Schritt 2: Ordnerstruktur anlegen
Richten Sie die Verzeichnisse `core/` und `features/` ein. Erstellen Sie die Unterverzeichnisse `data/`, `domain/` und `presentation/` fuer Ihr gewaehltes Feature. Verschieben Sie noch keinen Code.
Schritt 3: Entities extrahieren
Finden Sie die Datenklassen, die Ihr Feature verwendet. Erstellen Sie saubere Entity-Versionen in der Domain-Schicht. Entfernen Sie Serialisierungslogik, Annotationen und Framework-Imports. Verschieben Sie Serialisierungsbelange in Model-Klassen der Data-Schicht.
Schritt 4: Repository-Vertraege definieren
Schauen Sie sich an, wie Ihr Feature Daten abruft und speichert. Schreiben Sie eine abstrakte Repository-Klasse in der Domain-Schicht, die diese Operationen beschreibt. Verwenden Sie `Either
Schritt 5: Use Cases schreiben
Erstellen Sie fuer jede Aktion Ihres Features eine Use-Case-Klasse. Selbst wenn der Use Case nur an das Repository delegiert, etabliert er das Muster und bietet einen Ort fuer spaetere Geschaeftsregeln.
Schritt 6: Repository implementieren
Erstellen Sie das konkrete Repository in der Data-Schicht. Verschieben Sie bestehende API-Aufrufe und Datenbankoperationen in Data-Source-Klassen. Das Repository koordiniert zwischen ihnen.
Schritt 7: Dependency Injection verdrahten
Registrieren Sie Ihre neuen Klassen im DI-Container. Ersetzen Sie direkte Instanziierung in Ihren Widgets durch injizierte Abhaengigkeiten.
Schritt 8: Presentation-Schicht refactoren
Aktualisieren Sie Ihre Widgets und Ihr State Management, damit sie die neuen Use Cases verwenden, anstatt APIs direkt aufzurufen. Das ist meistens die kleinste Aenderung, weil die Schnittstelle aehnlich bleibt.
Schritt 9: Tests schreiben
Da jede Schicht jetzt isoliert ist, schreiben Sie Unit-Tests fuer Use Cases (reine Logik), Repository-Implementierungen (Data Sources mocken) und BLoCs (Use Cases mocken). Sie werden sofort merken, wie viel einfacher das Testen geworden ist.
Schritt 10: Fuer das naechste Feature wiederholen
Nehmen Sie Ihre Erfahrungen und wenden Sie sie auf das naechste Feature an. Jede Migration wird schneller, je mehr Sie die Muster verinnerlichen.
Nach meiner Erfahrung mit produktiven Flutter-Apps dauert eine vollstaendige Migration fuer eine mittelgrosse App etwa 2-4 Wochen, wenn sie inkrementell neben der regulaeren Feature-Arbeit durchgefuehrt wird. Der Schluessel ist, waehrend des Refactorings niemals aufzuhoeren, Mehrwert zu liefern. Jedes migrierte Feature sollte in einem funktionsfaehigen Zustand ausgeliefert werden.
Fazit
Clean Architecture ist eine Investition. Das initiale Setup kostet Zeit, und der Boilerplate-Code kann sich anfangs schwer anfuehlen. Aber fuer jedes Flutter-Projekt, das laenger als ein paar Monate lebt, summieren sich die Ertraege. Die Testbarkeit steigt, das Onboarding neuer Entwickler wird schneller, und die Angst vor Code-Aenderungen verschwindet.
Beginnen Sie mit einem Feature, beweisen Sie den Nutzen, und expandieren Sie von dort aus. Die Architektur sollte Ihrem Team dienen, nicht umgekehrt.
Ich unterstuetze Sie gern bei einem pragmatischen, auf Ihr Projekt zugeschnittenen Migrationsplan.
Verwandte Artikel
Flutter State Management: Riverpod, Provider und Bloc im Vergleich
Vergleichen Sie State-Management-Ansätze in Flutter. Verstehen Sie Riverpod, Provider und Bloc mit klaren Entscheidungskriterien.
Dart Best Practices: Sauberer und wartbarer Code
Lernen Sie praxiserprobte Dart-Methoden für lesbaren, testbaren und wartbaren Code.
Flutter Testing: Unit-, Widget- und Integrationstests
Erstellen Sie eine praktikable Flutter-Teststrategie mit klaren Rollen für Unit-, Widget- und Integrationstests.
Haben Sie ein Flutter-Projekt?
Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.
Kontakt aufnehmen