
Feature fertig, aber Code unwartbar? Genau dieses Gefühl frisst Produktivität und Motivation. In der Hektik des Alltags wachsen Widgets zu Gott-Objekten, Services kennen „irgendwie“ alles und jeder kleine Fix löst an anderer Stelle eine Regression aus. Was fehlt, ist nicht Fleiß, sondern Struktur. Clean Architecture ist dafür kein akademischer Elfenbeinturm, sondern ein sehr pragmatisches Werkzeug: Sie zwingt dich, Fachlogik vom Rahmenwerk zu trennen, Abhängigkeiten sauber zu schneiden und Entscheidungen dorthin zu legen, wo sie hingehören in die Domäne.
In diesem Beitrag zeige ich dir, warum Clean Architecture dein Flutter-Projekt stabiler, testbarer und skalierbarer macht, ohne dich in Dogmen zu verlieren. Wir starten mit den Symptomen von Spaghetti-Code, übersetzen dann die Kernprinzipien (Entities, Use Cases, Interfaces) in die Praxis und mappen das Ganze auf Flutter-typische Schichten: Presentation, Domain und Data. Anschließend schauen wir uns das gemeinsam anhand eines Login-Flows an, inklusive UseCase, Repository und Service und überlegen uns die passenden Tests samt Mocks für jede Schicht.
Zum Schluss bekommst du einen realistischen Migrationspfad für bestehende Codebasen: Schritt für Schritt, ohne Big Bang. Wenn du magst, kannst du dir danach ein Beispiel-Repo ansehen und ein kompaktes Cheatsheet mitnehmen, perfekt, um Clean Architecture in deinem nächsten „layered architecture app“-Setup zu verankern. Suchst du nach Clean Architecture für Flutter? Dann ist das hier dein praktischer Leitfaden, der dich vom Bauchweh-Code zur belastbaren Codebasis bringt.
Wenn sich dein Projekt nach jedem Merge ein kleines bisschen instabiler anfühlt, wenn du beim Öffnen eines Files kurz seufzt und wenn simples Refactoring plötzlich ganze Screens zerschießt, dann hast du sehr wahrscheinlich Spaghetti-Code. Das Gemeine daran: Er entsteht selten aus Faulheit, sondern fast immer aus Zeitdruck und gutem Willen. „Ich baue das schnell hier rein, wir räumen später auf.“ Später kommt nie, bis die Geschwindigkeit sinkt und jeder Change Angst macht.
In Flutter zeigt sich Spaghetti-Code besonders deutlich, weil UI und Businesslogik so bequem im selben File landen können. Du merkst es daran, dass Widgets zu Gott-Objekten werden: Sie bauen UI, sprechen direkt mit Firebase, halten Session-State, validieren Formulare, mappen DTOs zu Models und triggern Navigation – alles in einer Klasse. Ein Bugfix in der Validierung? Plötzlich brechen Unit-Tests (falls überhaupt vorhanden), die Navigation verhält sich seltsam und irgendwo bleibt ein Stream offen.
Typisch sind auch unklare Abhängigkeiten. Services greifen quer aufeinander zu, globale Singletons hängen überall drin und du weißt nicht mehr, wer wen benötigt. Das führt zu versteckten Seiteneffekten: Du änderst eine kleine Hilfsmethode im „Utils“-Ordner und am Ende fliegt dir der Checkout um die Ohren. Dazu kommen Datenmodelle ohne Grenzen – ein API-Response wird direkt als „Model“ im ganzen Code herumgereicht, inklusive aller Felder, die die UI gar nicht kennen sollte. Wenn dann das Backend ein Feld umbenennt, bricht die App an fünf Stellen gleichzeitig.
Ein weiteres starkes Signal ist fehlende Testbarkeit. Du willst die Logik deines Login-Flows testen, aber alles hängt in einem StatefulWidget mit Build-Context, Navigator und SnackBars. Unit-Tests sind praktisch unmöglich, Integration-Tests zu teuer, also testet ihr „per Hand“. Das kostet Zeit, ist fehleranfällig und verhindert, dass ihr mutig refaktoriert. Und wenn ihr doch testet, dann nur die „Happy Paths“, weil Mocks einrichten weh tut und die Struktur keinen modularen Einstieg zulässt.
Auch Asynchronität wird schnell zur Falle: async/await ist über mehrere Schichten verteilt, es gibt kein klares Error-Handling, und Exceptions werden mal geworfen, mal geschluckt, mal als null kodiert. In der UI landen dann kryptische Zustände: Ein Button ist disabled, aber ein Spinner dreht noch; ein SnackBar meldet „Erfolg“, aber das nächste Screen-Push schlägt stillschweigend fehl. Das sind klassische Symptome einer fehlenden Trennung von Domänenlogik und Darstellung.
Wenn du dich erwischst, wie du Copy-Paste betreibst („Ich nehme den Code aus dem Registrieren-Screen und passe drei Zeilen an“), wächst die technische Schuld. Später willst du nur die Passwort-Policy ändern und suchst eine Stunde in vier Dateien nach leicht unterschiedlichen Implementierungen. Ähnlich gefährlich: Ordnerstruktur als Feigenblatt. Ein core/, services/, screens/ wirkt ordentlich, aber innerhalb der Dateien sind Verantwortlichkeiten vermischt. Struktur auf Dateisystem-Ebene ersetzt keine klare architektonische Trennung.
Kurz gesagt, Spaghetti-Code fühlt sich so an:
Änderungen dauern immer länger, obwohl das Team erfahrener wird.
Onboarding neuer Entwickler ist schwer: „Frag am besten Max, der kennt die Stelle.“
Bugs tauchen nach scheinbar harmlosen Refactorings wieder auf (Regressionen).
Tests fehlen, sind brüchig oder werden gemieden, weil sie „nichts bringen“.
Entscheidungen wohin Code gehört, kosten spürbar Energie.
Ein kleines, aber sprechendes Anti-Beispiel:
class LoginScreen extends StatefulWidget { /* ... */ }
class _LoginScreenState extends State<LoginScreen> {
final emailCtrl = TextEditingController();
final passCtrl = TextEditingController();
bool loading = false;
Future<void> _login() async {
setState(() => loading = true);
try {
// Businesslogik + Infrastruktur direkt im UI
final res = await http.post(
Uri.parse('https://api.example.com/login'),
body: {'email': emailCtrl.text, 'password': passCtrl.text},
);
if (res.statusCode == 200) {
// parse + state + navigation vermischt
final token = jsonDecode(res.body)['token'];
await SharedPreferences.getInstance()
.then((sp) => sp.setString('token', token));
if (!mounted) return;
Navigator.of(context).pushReplacementNamed('/home');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Willkommen!')),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Login fehlgeschlagen')),
);
}
} catch (e) {
// Exception Handling im UI
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Fehler: $e')),
);
} finally {
if (mounted) setState(() => loading = false);
}
}
@override
Widget build(BuildContext context) { /* ... */ }
}Auf den ersten Blick „funktioniert“ der Screen. In Wahrheit ist er ein Knoten aus UI, Domänenlogik, Datenzugriff und Seiteneffekten. Du kannst ihn schwer testen, kaum wiederverwenden und nur unter Schmerzen erweitern (z. B. Magic Links, OAuth, MFA). Jede neue Anforderung zieht mehr if/else-Äste in genau diese Datei.
Die Quintessenz: Spaghetti-Code ist kein moralisches Versagen, sondern ein Strukturproblem. Ohne klare Grenzen zwischen Was (Domäne/Regeln) und Wie (Implementierung/Framework) wird jede Code-Basis mit der Zeit fragil. Genau hier setzt Clean Architecture an. Sie trennt Verantwortlichkeiten so, dass du Features schneller bauen, zuverlässiger testen und angstfrei refaktorieren kannst. Im nächsten Kapitel schauen wir uns die Prinzipien dahinter an und übersetzen sie in die Welt von Flutter.
Clean Architecture trennt konsequent zwischen Regeln und Details. Regeln sind deine Geschäftslogik, also das, was wahr bleibt, selbst wenn du morgen von REST auf gRPC, von Firebase auf Supabase oder von Flutter auf irgendein anderes UI-Framework wechselst. Details sind alles, was austauschbar sein sollte: Datenbanken, HTTP-Clients, Frameworks, Storage. Der Kernsatz lautet: Abhängigkeiten zeigen nach innen, niemals nach außen. Die inneren Schichten kennen die äußeren nicht und definieren ihre Erwartungen über Interfaces.
Entities sind die stabilsten Bausteine deines Systems. Sie repräsentieren Geschäftsobjekte und die dazugehörenden Invarianten: ein User, eine Session, eine Order. Wichtig: Entities sind framework-frei, klein und ausdrucksstark. Sie enthalten Logik, die immer gilt, egal, wie die App Daten lädt oder welche UI sie hat.
In Dart sind Entities meist Plain Objects mit Methoden, die Regeln durchsetzen. Vermeide, hier schon HTTP- oder Firebase-Typen einzuschleusen.
class UserId {
final String value;
const UserId(this.value);
}
class User {
final UserId id;
final String email;
final bool emailVerified;
const User({required this.id, required this.email, required this.emailVerified});
bool canStartSession() => emailVerified;
}
class Session {
final UserId userId;
final DateTime startedAt;
const Session({required this.userId, required this.startedAt});
}Die Entitäten wissen nichts über BuildContext, SharedPreferences oder http. Sie sind dein Fels in der Brandung – leicht testbar, ewig gültig.
Use Cases beschreiben was deine App tut (aus Sicht der Domäne), nicht wie. Sie orchestrieren Entitäten und sprechen über Interfaces mit der Außenwelt: „Logge Nutzer ein“, „Lege Bestellung an“, „Berechne Preis“. Jeder Use Case hat einen klaren Input und Output und bildet eine fachlich sinnvolle Einheit. Kein „God-Service“, sondern spezifische Anwendungsfälle.
In Dart bietet es sich an, Use Cases als Klassen mit einer call-Methode zu modellieren. Der Return-Typ ist idealerweise ein Werttyp (z. B. Result<Success, Failure> oder eine sealed class), statt blind Exceptions nach außen zu werfen.
class LoginParams {
final String email;
final String password;
const LoginParams(this.email, this.password);
}
sealed class LoginFailure {
const LoginFailure();
}
class InvalidCredentials extends LoginFailure {}
class NetworkIssue extends LoginFailure {}
class LoginSuccess {
final Session session;
const LoginSuccess(this.session);
}
abstract class AuthRepository {
Future<LoginSuccess> login(String email, String password);
Future<void> logout();
Future<User?> currentUser();
}
class LoginUseCase {
final AuthRepository _auth;
LoginUseCase(this._auth);
Future<Either<LoginFailure, LoginSuccess>> call(LoginParams p) async {
try {
final success = await _auth.login(p.email, p.password);
if (!success.session.userId.value.contains('-')) {
// Beispielhafte Regelprüfung – normalerweise in Entities/Policies gekapselt
}
return Right(success);
} on UnauthorizedException {
return Left(InvalidCredentials());
} on NetworkException {
return Left(NetworkIssue());
}
}
}Der Use Case kennt nur Interfaces (hier AuthRepository) und keine konkrete Datenquelle. Dadurch kannst du Implementierungen tauschen, ohne den Use Case anzufassen: heute Firebase, morgen dein eigenes Backend.
Interfaces definieren die Verträge zwischen den Schichten. Die zentrale Regel: Interfaces gehören nach innen. Das heißt, die Domäne bestimmt, was sie braucht, nicht die Datenquelle. Die Data-Schicht implementiert diese Interfaces und übersetzt externe Details in Domänenobjekte.
Typische Beispiele:
AuthRepository: Login/Logout/Session-Zugriff
UserRepository: CRUD für Nutzer, Paginierung, Suche
Clock, UuidGenerator, NetworkInfo: kleine, austauschbare Systemdienste
SecureStorage: Tokens lesen/schreiben, ohne eine konkrete Storage-Library zu leaken
Das Interface lebt in der Domain, die Implementierung in Data:
// domain/auth_repository.dart
abstract class AuthRepository {
Future<LoginSuccess> login(String email, String password);
Future<void> logout();
}
// data/firebase_auth_repository.dart
class FirebaseAuthRepository implements AuthRepository {
final FirebaseAuth _fa;
final SecureStorage _storage;
FirebaseAuthRepository(this._fa, this._storage);
@override
Future<LoginSuccess> login(String email, String password) async {
final cred = await _fa.signInWithEmailAndPassword(email: email, password: password);
final user = cred.user!;
final session = Session(userId: UserId(user.uid), startedAt: DateTime.now());
await _storage.write('session_user', user.uid);
return LoginSuccess(session);
}
@override
Future<void> logout() async {
await _fa.signOut();
await _storage.delete('session_user');
}
}So bleibt die Domäne Chef im Ring. Die Data-Schicht ist ein Adapter: Sie passt externe Typen (SDK-Objekte, JSON) an deine Entitäten an, nie umgekehrt.
Clean Architecture setzt die Dependency Inversion Principle (DIP) durch: Hochstufige Policies (Use Cases, Entities) hängen von Abstraktionen ab, nicht von konkreten Details. Die konkreten Details hängen wiederum von diesen Abstraktionen ab. In Flutter bedeutet das:
Die UI injiziert Use Cases (z. B. via DI/Service Locator), ruft sie auf und rendert Ergebnisse.
Use Cases rufen nur Interfaces an.
Data implementiert die Interfaces und wird bei App-Start verdrahtet.
Ein praktischer Guardrail-Satz, den du bei Code-Reviews im Kopf behalten kannst:
Entitäten kennen nur Entitäten.
Use Cases kennen Entitäten und Interfaces.
Interfaces liegen in der Domain, Implementierungen in Data.
Präsentation kennt Use Cases (und Mapper), aber keine Data-Details.
Weil du so testbare, austauschbare und erweiterbare Software bekommst. Du kannst einen Use Case als reine Funktionalität testen, indem du das Repository per Fake/Mock ersetzt. Du kannst Datenquellen wechseln, ohne die Domäne zu berühren. Und du kannst Features schneiden, ohne dass der UI- oder Daten-Layer explodiert. Kurz: Du machst Regeln haltbar und Details billig.
Im nächsten Kapitel mappen wir diese Prinzipien konkret auf Flutter-Schichten – Presentation, Domain und Data – und zeigen dir, wie du das ohne Overhead in deinem Projekt strukturierst.
Clean Architecture wird in Flutter dann richtig mächtig, wenn du sie konsequent auf drei Schichten abbildest: Presentation (Widgets & State), Domain (Regeln & Anwendungsfälle) und Data (Adapter zu APIs, DB, SDKs). Die goldene Regel bleibt: Abhängigkeiten zeigen von außen nach innen. Die UI kennt nur Use Cases, Use Cases kennen nur Interfaces und die Data-Schicht implementiert genau diese Interfaces.
Die Präsentationsschicht beantwortet ausschließlich die Frage: Wie interagiert der Nutzer und was soll die UI anzeigen? Hier gehören Widgets, Router/Navigator, Form-Validierung auf UI-Ebene und dein State-Management hin (z. B. Riverpod, Bloc, Stacked, ValueNotifier).
Wichtig ist die Trennung: Die UI ruft Use Cases auf und übersetzt deren Ergebnisse in View-State. Keine HTTP-Calls, keine SDK-Typen, keine Token-Speicherung.
Ein minimalistisches Beispiel mit Riverpod (analog funktioniert es mit Bloc/Stacked):
// presentation/login_controller.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../domain/auth/login_usecase.dart';
import '../domain/auth/login_result.dart';
final loginControllerProvider =
AutoDisposeNotifierProvider<LoginController, AsyncValue<void>>(
LoginController.new,
);
class LoginController extends AutoDisposeNotifier<AsyncValue<void>> {
late final LoginUseCase _login = ref.read(loginUseCaseProvider);
@override
AsyncValue<void> build() => const AsyncValue.data(null);
Future<void> submit(String email, String password) async {
state = const AsyncValue.loading();
final result = await _login(LoginParams(email, password));
state = result.fold(
(failure) => AsyncValue.error(failure, StackTrace.current),
(_) => const AsyncValue.data(null),
);
}
}Das Widget konsumiert nur den Controller-State:
// presentation/login_screen.dart
class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(loginControllerProvider);
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: switch (state) {
AsyncLoading() => const Center(child: CircularProgressIndicator()),
AsyncError(:final error) => Center(child: Text('Fehler: $error')),
_ => _LoginForm(onSubmit: (e, p) {
ref.read(loginControllerProvider.notifier).submit(e, p);
}),
},
);
}
}Die UI bleibt frei von Infrastruktur-Details und damit testbar: Du kannst den Controller mit einem Fake-UseCase füttern und das Rendering separat prüfen.
Die Domäne ist framework-frei. Hier definierst du Entities (mit Invarianten), Value Objects (z. B. Email), Use Cases als Anwendungslogik und die Interfaces (Repositories, Services), die du von außen brauchst. Die Domäne weiß nichts von HTTP, Firebase oder BuildContext. Fehler modellierst du als Failure-Typen statt als rohe Exceptions, damit die UI gezielt reagieren kann.
// domain/auth/entities.dart
class UserId {
final String value;
const UserId(this.value);
}
class User {
final UserId id;
final String email;
final bool emailVerified;
const User({required this.id, required this.email, required this.emailVerified});
bool canLogin() => emailVerified;
}// domain/auth/auth_repository.dart (Interface!)
abstract class AuthRepository {
Future<LoginResult> login(String email, String password);
Future<void> logout();
Future<User?> currentUser();
}// domain/auth/login_usecase.dart
import 'auth_repository.dart';
import 'login_result.dart';
class LoginParams {
final String email;
final String password;
const LoginParams(this.email, this.password);
}
class LoginUseCase {
final AuthRepository _repo;
const LoginUseCase(this._repo);
Future<LoginResult> call(LoginParams p) => _repo.login(p.email, p.password);
}// domain/auth/login_result.dart (Failure/Success klar typisieren)
sealed class LoginFailure {
const LoginFailure();
factory LoginFailure.invalidCredentials() = InvalidCredentials;
factory LoginFailure.network() = NetworkIssue;
factory LoginFailure.unexpected() = UnexpectedFailure;
}
class InvalidCredentials extends LoginFailure { const InvalidCredentials(); }
class NetworkIssue extends LoginFailure { const NetworkIssue(); }
class UnexpectedFailure extends LoginFailure { const UnexpectedFailure(); }
final class LoginSuccess {
final User user;
const LoginSuccess(this.user);
}
// Einfache Either-ähnliche Hülle:
typedef LoginResult = Result<LoginSuccess, LoginFailure>;
class Result<T, E> {
final T? ok; final E? err;
const Result.ok(this.ok) : err = null;
const Result.err(this.err) : ok = null;
R fold<R>(R Function(E) onErr, R Function(T) onOk)
=> ok != null ? onOk(ok as T) : onErr(err as E);
}Diese Schicht ist langlebig. Du kannst später problemlos das Backend wechseln, ohne die Domäne zu berühren.
Die Data-Schicht implementiert die Interfaces der Domäne und ist der „Übersetzer“: Von externen Formaten/Typos/Exceptions in saubere Domänenobjekte und Failure-Typen. Eine klare Unterteilung hilft enorm:
Remote Data Source (HTTP/Dio, gRPC, Firebase Auth, Firestore …)
Local Data Source (Hive/Sqflite/SharedPreferences/Secure Storage)
DTO/Model & Mapper (JSON ↔︎ Entity)
Repository-Implementierung (orchestriert die Quellen, mappt Fehler)
Ein praktikables Beispiel mit HTTP + Secure Storage:
// data/auth/dto/user_dto.dart
class UserDto {
final String id;
final String email;
final bool emailVerified;
const UserDto({required this.id, required this.email, required this.emailVerified});
factory UserDto.fromJson(Map<String, dynamic> j) => UserDto(
id: j['id'] as String,
email: j['email'] as String,
emailVerified: j['emailVerified'] as bool? ?? false,
);
User toEntity() => User(
id: UserId(id),
email: email,
emailVerified: emailVerified,
);
}// data/auth/remote/auth_api.dart
import 'package:dio/dio.dart';
import 'dto/user_dto.dart';
class AuthApi {
final Dio _dio;
AuthApi(this._dio);
Future<(String token, UserDto user)> login(String email, String password) async {
final res = await _dio.post('/login', data: {'email': email, 'password': password});
final data = res.data as Map<String, dynamic>;
return (data['token'] as String, UserDto.fromJson(data['user']));
}
}// data/auth/local/token_storage.dart
abstract class TokenStorage {
Future<void> save(String token);
Future<String?> read();
Future<void> clear();
}// data/auth/auth_repository_impl.dart
import '../../domain/auth/auth_repository.dart';
import '../../domain/auth/login_result.dart';
import '../../domain/auth/entities.dart';
import 'remote/auth_api.dart';
import 'local/token_storage.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthApi _api;
final TokenStorage _storage;
AuthRepositoryImpl(this._api, this._storage);
@override
Future<LoginResult> login(String email, String password) async {
try {
final (token, userDto) = await _api.login(email, password);
await _storage.save(token);
final user = userDto.toEntity();
if (!user.canLogin()) {
return const Result.err(UnexpectedFailure());
}
return Result.ok(LoginSuccess(user));
} on DioException catch (e) {
if (e.response?.statusCode == 401) return const Result.err(InvalidCredentials());
return const Result.err(NetworkIssue());
} catch (_) {
return const Result.err(UnexpectedFailure());
}
}
@override
Future<void> logout() async => _storage.clear();
@override
Future<User?> currentUser() async {
final t = await _storage.read();
if (t == null) return null;
// optional: /me call oder lokal gecachten User laden
return null;
}
}Die Implementierung kennt Details (Dio, JSON-Form, Storage), aber deine Domäne bekommt stets saubere User-Entitäten und typisierte Failures. Dadurch bleiben Tests stabil, selbst wenn sich API-Antworten ändern.
Irgendwo muss entschieden werden, welche Implementierung ein Interface bekommt. Das ist der Job deines Composition Roots (meist in main.dart oder einem bootstrap()), z. B. mit get_it:
// app/di.dart
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../domain/auth/auth_repository.dart';
import '../domain/auth/login_usecase.dart';
import '../data/auth/auth_repository_impl.dart';
import '../data/auth/remote/auth_api.dart';
import '../data/auth/local/token_storage_impl.dart'; // deine konkrete Storage-Implementierung
final sl = GetIt.instance;
void configureDependencies() {
// Low-level
sl.registerLazySingleton(() => Dio(BaseOptions(baseUrl: 'https://api.example.com')));
sl.registerLazySingleton<TokenStorage>(() => SecureTokenStorage());
// Data
sl.registerLazySingleton(() => AuthApi(sl()));
sl.registerLazySingleton<AuthRepository>(() => AuthRepositoryImpl(sl(), sl()));
// Domain
sl.registerFactory(() => LoginUseCase(sl<AuthRepository>()));
}In der UI holst du dir nur die Use Cases (oder Controller/Notifier, die sie konsumieren). Die Data-Schicht bleibt komplett hinter Interfaces versteckt.
Eine klare Struktur hilft dir und jedem neuen Teammitglied, sich sofort zurechtzufinden:
lib/
app/ # bootstrap, di, router
core/ # result/failure, utils, constants (framework-frei)
presentation/
login/ # Screens, Controller/Notifier/Bloc, Mapper UI<->Domain
widgets/ # Shared UI
domain/
auth/
entities/
value_objects/
usecases/
repositories/ # Interfaces!
data/
auth/
dto/
remote/ # APIs/SDKs
local/ # Cache/Storage
repositories/ # *Impl KlassenDie Struktur ist kein Dogma, aber sie erzwingt die richtige Denkrichtung: UI spricht mit Domain, Domain definiert Verträge, Data passt sich an.
SDK-Typen in die Domain leaken (z. B. UserCredential aus Firebase): immer mappen.
Exceptions bis in die UI durchreichen: lieber Failure-Typen und klarer Flow.
Use Cases als „dünne Durchreiche“: Wenn ein Use Case nichts orchestriert, fehlt oft eine Regel oder er gehört in die UI.
Repository kennt Widgets/Context: harte Grenze wahren, keine ScaffoldMessenger/Navigation in Data/Domain.
Mit dieser Aufteilung fühlt sich dein Flutter-Projekt sofort ruhiger an: Die UI bleibt leicht, die Domäne stabil, und Data ist austauschbar. Im nächsten Kapitel setzen wir das am Login-Flow konkret um. Von UseCase über Repository bis zur Service-Implementierung, Schritt für Schritt.
Wir setzen den Login einmal sauber end-to-end auf, von der Domäne bis zum Adapter. Ziel: Die UI ruft nur einen Use Case auf. Der Use Case spricht mit einem Repository (Interface), und das Repository orchestriert Services (HTTP/Firebase, Storage), mappt externe Details auf Domänenobjekte und typisierte Fehler. So bleibt der Kern testbar und die Datenquelle austauschbar.
In der Domäne definieren wir Entitäten, Failure/Result-Typen, das Repository-Interface und den Use Case. Keine Framework-Abhängigkeiten, nur reines Dart.
// domain/auth/entities.dart
class UserId {
final String value;
const UserId(this.value);
}
class User {
final UserId id;
final String email;
final bool emailVerified;
const User({required this.id, required this.email, required this.emailVerified});
bool canLogin() => emailVerified; // einfache, aber testbare Regel
}// domain/core/result.dart
typedef Result<T, E> = _Result<T, E>;
class _Result<T, E> {
final T? ok;
final E? err;
const _Result.ok(this.ok) : err = null;
const _Result.err(this.err) : ok = null;
R fold<R>(R Function(E) onErr, R Function(T) onOk)
=> ok != null ? onOk(ok as T) : onErr(err as E);
}// domain/auth/login_types.dart
sealed class LoginFailure {
const LoginFailure();
}
class InvalidCredentials extends LoginFailure { const InvalidCredentials(); }
class NetworkIssue extends LoginFailure { const NetworkIssue(); }
class UnexpectedFailure extends LoginFailure { const UnexpectedFailure(); }
final class LoginSuccess {
final User user;
const LoginSuccess(this.user);
}
typedef LoginResult = Result<LoginSuccess, LoginFailure>;// domain/auth/auth_repository.dart (Interface!)
import 'login_types.dart';
import '../auth/entities.dart';
abstract class AuthRepository {
Future<LoginResult> login(String email, String password);
Future<void> logout();
Future<User?> currentUser();
}// domain/auth/login_usecase.dart
import 'auth_repository.dart';
import 'login_types.dart';
class LoginParams {
final String email;
final String password;
const LoginParams(this.email, this.password);
}
class LoginUseCase {
final AuthRepository _repo;
const LoginUseCase(this._repo);
Future<LoginResult> call(LoginParams p) => _repo.login(p.email, p.password);
}Diese Klassen ändern sich selten. Egal ob du später von Firebase auf ein eigenes Backend wechselst: Die Domäne bleibt stabil.
Die Data-Schicht implementiert Details: HTTP-Calls/Firebase-SDK, Token-Speicher, DTOs und Mappings. Ein möglicher HTTP-Service mit dio:
// data/auth/dto/user_dto.dart
import '../../domain/auth/entities.dart';
class UserDto {
final String id;
final String email;
final bool emailVerified;
const UserDto({required this.id, required this.email, required this.emailVerified});
factory UserDto.fromJson(Map<String, dynamic> j) => UserDto(
id: j['id'] as String,
email: j['email'] as String,
emailVerified: (j['emailVerified'] as bool?) ?? false,
);
User toEntity() => User(id: UserId(id), email: email, emailVerified: emailVerified);
}// data/auth/remote/auth_remote_service.dart
import 'package:dio/dio.dart';
import '../dto/user_dto.dart';
class AuthRemoteService {
final Dio _dio;
AuthRemoteService(this._dio);
/// Liefert (token, user) oder wirft eine DioException mit Statuscode.
Future<(String token, UserDto user)> login(String email, String password) async {
final res = await _dio.post('/login', data: {'email': email, 'password': password});
final map = res.data as Map<String, dynamic>;
return (map['token'] as String, UserDto.fromJson(map['user']));
}
}// data/auth/local/token_storage.dart
abstract class TokenStorage {
Future<void> save(String token);
Future<String?> read();
Future<void> clear();
}Eine konkrete Storage-Implementierung (z. B. mit flutter_secure_storage) lebt ebenfalls in data/auth/local/… und erfüllt nur dieses Interface.
Das Repository ist der Übersetzer zwischen Domäne und Services. Es ruft den Remote-Service auf, speichert das Token, mappt DTO → Entity und Exceptions → Failure.
// data/auth/auth_repository_impl.dart
import 'package:dio/dio.dart';
import '../../domain/auth/auth_repository.dart';
import '../../domain/auth/login_types.dart';
import '../../domain/auth/entities.dart';
import 'remote/auth_remote_service.dart';
import 'local/token_storage.dart';
class AuthRepositoryImpl implements AuthRepository {
final AuthRemoteService _remote;
final TokenStorage _tokens;
AuthRepositoryImpl(this._remote, this._tokens);
@override
Future<LoginResult> login(String email, String password) async {
try {
final (token, userDto) = await _remote.login(email, password);
await _tokens.save(token);
final user = userDto.toEntity();
if (!user.canLogin()) {
// Domänenregel respektieren
return const LoginResult.err(UnexpectedFailure());
}
return LoginResult.ok(LoginSuccess(user));
} on DioException catch (e) {
if (e.response?.statusCode == 401) return const LoginResult.err(InvalidCredentials());
return const LoginResult.err(NetworkIssue());
} catch (_) {
return const LoginResult.err(UnexpectedFailure());
}
}
@override
Future<void> logout() async {
await _tokens.clear();
}
@override
Future<User?> currentUser() async {
final t = await _tokens.read();
if (t == null) return null;
// Optional: /me Call, um Userdaten zu laden
return null;
}
}Achte darauf, dass hier keine UI-Details auftauchen. Keine Navigationsaufrufe, keine SnackBars. Das Repository liefert ein LoginResult, die UI entscheidet, was zu rendern ist.
Die UI hängt nur von LoginUseCase ab. Ob Riverpod, Bloc oder Stacked, die Idee ist gleich: Controller/Bloc ruft den Use Case auf und übersetzt das Ergebnis in View-State.
// presentation/login_controller.dart (Beispiel mit Riverpod)
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/auth/login_usecase.dart';
import '../../domain/auth/login_types.dart';
final loginControllerProvider =
AutoDisposeNotifierProvider<LoginController, AsyncValue<LoginSuccess?>>(
LoginController.new,
);
class LoginController extends AutoDisposeNotifier<AsyncValue<LoginSuccess?>> {
late final LoginUseCase _login = ref.read(loginUseCaseProvider);
@override
AsyncValue<LoginSuccess?> build() => const AsyncValue.data(null);
Future<void> submit(String email, String password) async {
state = const AsyncValue.loading();
final res = await _login(LoginParams(email, password));
state = res.fold(
(failure) => AsyncValue.error(failure, StackTrace.current),
(ok) => AsyncValue.data(ok),
);
}
}Wenn du Stacked nutzt, übernimmt dein ViewModel exakt dieselbe Aufgabe: Abhängigkeiten injizieren, submit() aufrufen, Statusfelder setzen. Entscheidend ist nur, dass es keine Datenquellen direkt anspricht.
Hier legst du fest, welche Implementierung ein Interface bekommt. Das passiert einmalig beim App-Start.
// app/di.dart – Beispiel mit get_it
import 'package:get_it/get_it.dart';
import 'package:dio/dio.dart';
import '../domain/auth/auth_repository.dart';
import '../domain/auth/login_usecase.dart';
import '../data/auth/auth_repository_impl.dart';
import '../data/auth/remote/auth_remote_service.dart';
import '../data/auth/local/token_storage.dart';
import '../data/auth/local/secure_token_storage.dart'; // deine konkrete Impl
final sl = GetIt.instance;
void configureDependencies() {
// Low-Level
sl.registerLazySingleton(() => Dio(BaseOptions(baseUrl: 'https://api.example.com')));
sl.registerLazySingleton<TokenStorage>(() => SecureTokenStorage());
// Services
sl.registerLazySingleton(() => AuthRemoteService(sl()));
// Repositories
sl.registerLazySingleton<AuthRepository>(() => AuthRepositoryImpl(sl(), sl()));
// Use Cases
sl.registerFactory(() => LoginUseCase(sl<AuthRepository>()));
}
// Optional Provider, falls du Riverpod nutzt:
final loginUseCaseProvider = Provider<LoginUseCase>((_) => sl<LoginUseCase>());Damit ist der Fluss klar: UI → UseCase → AuthRepository (Interface) → AuthRepositoryImpl → AuthRemoteService/TokenStorage. Die Domäne bleibt Boss, Services sind austauschbar.
Die UI bleibt schlank: Sie sammelt Eingaben, zeigt Lade-/Fehlerzustände an und delegiert die eigentliche Arbeit an den LoginUseCase. Wichtig ist, dass die UI keine Infrastruktur kennt. Sie reagiert nur auf den Ergebnis-Typ der Domäne. So wird sie leicht zu testen und austauschbar (z. B. für Web/Desktop).
Im folgenden Beispiel bauen wir ein kompaktes Login-Formular (mit Riverpod). Du kannst das Muster 1:1 auf Bloc oder Stacked übertragen: Der Controller/Das ViewModel ruft den Use Case auf, liefert einen klaren Status und die UI rendert entsprechend.
// presentation/login_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/auth/login_types.dart';
import 'login_controller.dart'; // ruft LoginUseCase auf
class LoginScreen extends ConsumerWidget {
const LoginScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(loginControllerProvider);
ref.listen(loginControllerProvider, (prev, next) {
// Auf erfolgreiche Logins reagieren (einmalig, side-effect-freundlich)
if (next is AsyncData && next.value != null) {
Navigator.of(context).pushReplacementNamed('/home');
}
// Fehler freundlich anzeigen
if (next is AsyncError) {
final msg = _messageFor(next.error);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
}
});
final loading = state.isLoading;
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: AutofillGroup(
child: Padding(
padding: const EdgeInsets.all(16),
child: _LoginForm(loading: loading),
),
),
),
),
);
}
String _messageFor(Object error) {
if (error is InvalidCredentials) return 'E-Mail oder Passwort ist falsch.';
if (error is NetworkIssue) return 'Netzwerkproblem. Bitte später erneut versuchen.';
return 'Unerwarteter Fehler. Bitte versuche es erneut.';
}
}
class _LoginForm extends ConsumerStatefulWidget {
const _LoginForm({required this.loading});
final bool loading;
@override
ConsumerState<_LoginForm> createState() => _LoginFormState();
}
class _LoginFormState extends ConsumerState<_LoginForm> {
final _formKey = GlobalKey<FormState>();
final _email = TextEditingController();
final _password = TextEditingController();
@override
void dispose() {
_email.dispose();
_password.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _email,
autofillHints: const [AutofillHints.username, AutofillHints.email],
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(labelText: 'E-Mail'),
validator: (v) => (v == null || v.isEmpty) ? 'Bitte E-Mail eingeben' : null,
),
const SizedBox(height: 12),
TextFormField(
controller: _password,
autofillHints: const [AutofillHints.password],
obscureText: true,
decoration: const InputDecoration(labelText: 'Passwort'),
validator: (v) => (v == null || v.isEmpty) ? 'Bitte Passwort eingeben' : null,
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: widget.loading ? null : () async {
if (!_formKey.currentState!.validate()) return;
await ref.read(loginControllerProvider.notifier)
.submit(_email.text.trim(), _password.text);
},
child: widget.loading
? const Padding(
padding: EdgeInsets.symmetric(vertical: 6),
child: SizedBox(height: 18, width: 18, child: CircularProgressIndicator(strokeWidth: 2)))
: const Text('Anmelden'),
),
),
],
),
);
}
}Das Entscheidende ist der Fehler- und Zustandsfluss: Der Controller liefert AsyncLoading, AsyncError(LoginFailure) oder AsyncData(LoginSuccess). Die UI setzt den Button disabled, zeigt bei Bedarf einen Loader, mappt LoginFailure auf verständliche Texte und navigiert bei Erfolg. Keine Dio-Exceptions, keine Token-Details, keine SDK-Typen in der UI.
Wenn du Stacked verwendest, bleibt das Muster gleich: Dein LoginViewModel injiziert den LoginUseCase, exponiert z. B. busy/errorMessage/success und eine submit(email, password)-Methode. Die View rendert nur den Zustand und reagiert auf Änderungen (z. B. mit einem AnimatedBuilder/ViewModelWidget). Ein minimaler Ablauf:
LoginViewModel.submit() setzt setBusy(true), ruft useCase(LoginParams), wertet Result aus, setzt errorMessage oder success, dann setBusy(false).
Die LoginView zeigt einen BusyOverlay, rendert Validierungsfehler der TextFormFields und navigiert, wenn success == true.
So bleibt deine UI präzise, dünn und austauschbar. Tests prüfen nur noch Interaktionen (z. B. „zeigt Fehlermeldung bei InvalidCredentials“ oder „navigiert nach LoginSuccess“), ganz ohne echte Netzwerk- oder Storage-Abhängigkeiten.
Im nächsten Kapitel schreiben wir die passenden Tests & Mocks pro Schicht: echte Unit-Tests für Use Cases, Fakes/Mocks fürs Repository und schnelle Verifikation der Mappings, damit dein Login nicht nur funktioniert, sondern langfristig stabil bleibt.
Tests sichern die Domäne und machen Änderungen billig. Ziel: schnelle, deterministische Unit-Tests; wenige, gezielte Integrationstests. Starte innen (Domain), arbeite dich nach außen (Data, Presentation).
Reines Dart, keine Frameworks. Mocke nur Interfaces der Domäne.
// usecase_test.dart
import 'package:mocktail/mocktail.dart';
import 'package:flutter_test/flutter_test.dart';
class MockAuthRepo extends Mock implements AuthRepository {}
void main() {
test('LoginUseCase gibt Success zurück', () async {
final repo = MockAuthRepo();
final uc = LoginUseCase(repo);
when(() => repo.login('a@b.com','pw'))
.thenAnswer((_) async => Result.ok(LoginSuccess(User(/*...*/))));
final r = await uc(LoginParams('a@b.com','pw'));
expect(r.ok, isA<LoginSuccess>());
verify(() => repo.login('a@b.com','pw')).called(1);
});
}Teste Mapping & Fehlerpfade, keine echten Netzwerke. Fake den Remote-Service/Storage.
// auth_repository_impl_test.dart
class FakeRemote extends Fake implements AuthRemoteService {
@override Future<(String, UserDto)> login(String e, String p) async
=> ('tkn', UserDto(id:'1', email:e, emailVerified:true));
}
class FakeTokens extends Fake implements TokenStorage {
String? t; @override Future<void> save(String x) async => t = x;
}
test('Repository speichert Token und liefert Entity', () async {
final repo = AuthRepositoryImpl(FakeRemote(), FakeTokens());
final r = await repo.login('a@b.com','pw');
expect(r.ok!.user.email, 'a@b.com');
});Fehlerfall (401 → InvalidCredentials) prüfst du, indem der Fake eine passende Exception wirft.
Kein UI-Rendering, nur Zustandsübergänge testen. Override den Use Case per Provider/DI.
// login_controller_test.dart (Riverpod)
final fakeLogin = Provider<LoginUseCase>((_) => LoginUseCase(_FakeRepoSuccess()));
test('Controller mappt auf Loading -> Success', () async {
final c = ProviderContainer(overrides: [loginUseCaseProvider.overrideWithProvider(fakeLogin)]);
final noti = c.read(loginControllerProvider.notifier);
expectLater(c.read(loginControllerProvider), isA<AsyncData>());
await noti.submit('a@b.com','pw');
expect(c.read(loginControllerProvider).hasValue, true);
});Bei den Unit-Tests gibt es einige Dinge zu beachten. Hier findest du die wichtigsten Punkte im Überblick:
Kleine, schnelle Unit-Tests > seltene Integrationstests > sehr wenige End-to-End-Tests.
Teste Regeln (Domäne), Mappings & Fehler (Data), Zustände (Presentation).
Nutze mocktail für Mocks; für einfache Komponenten lieber kleine Fakes.
Stabilität: Rückgabewerte/Failure-Typen testen, nicht Texte/Logs.
CI: Tests parallel ausführen, Linter + Coverage (kritische Use Cases ≥ 80%).
Clean Architecture in ein bestehendes Flutter-Projekt zu bringen ist kein Big-Bang-Refactor. Du gehst schrittweise vor, schneidest ein Feature nach dem anderen sauber zu und hältst die App jederzeit lauffähig. Der folgende Ablauf ist praxiserprobt und bewusst konkret formuliert.
Wähle ein Start-Feature
Nimm ein klar abgegrenztes, schmerzhaftes Feature (z. B. Login, Item-Liste, Checkout). Erstelle einen eigenen Branch und notiere den Ist-Zustand: wo steckt Logik, wo treten Bugs auf, wie ist die Abhängigkeitssituation.
Skizziere Ist- und Soll-Fluss
Zeichne, wie das Feature aktuell läuft (UI → Service → überallhin) und wie es aussehen soll (Presentation → Use Case → Repository → Service). Liste die fachlichen Begriffe auf, die später als Entities/Value Objects existieren sollen.
Definiere die Domänenverträge
Formuliere die öffentlichen Verträge der Domäne: welche Use Cases gibt es, welche Daten liefern sie zurück, welche Fehlerfälle sind fachlich relevant, welche Repositories braucht die Domäne. Schreibe diese Verträge als kurze, klare Spezifikation in die Readme des Features (ein Absatz je Vertrag genügt).
Lege einen Anti-Corruption-Adapter um Legacy-Code
Packe bestehende SDK-/HTTP-Aufrufe hinter schmale Adapter, die exakt die neuen Domänenverträge bedienen. Ziel: Die Domäne kann sofort gegen „alt“ laufen, ohne dass du schon alles neu baust. So minimierst du Risiko und hältst die Oberfläche stabil.
Ziehe Geschäftslogik aus UI/Services in die Domäne
Nimm die ersten fachlichen Regeln (Validierungen, Berechnungen, Entscheidungen) aus Widgets und „Gott-Services“ heraus und verlagere sie in Entities/Use Cases. Arbeite in kleinen Schritten, nach jedem Schritt Commit + kurze manuelle Prüfung.
Führe einen Composition Root ein
Bestimme an einer Stelle (z. B. main/Bootstrap), welche Implementierung die Domänen-Interfaces erhält. Verdrahte zunächst die Legacy-Adapter. Hinterlege optional einen Feature-Flag, um bei Bedarf temporär zurückschalten zu können.
Baue die Data-Schicht sauber auf
Teile Infrastruktur in Remote-/Local-Quellen und Mappings auf. Sorge dafür, dass Fehlerfälle in typisierte Failures übersetzt werden und dass keine Framework-Typen in die Domäne gelangen. Das Repository orchestriert nur, es entscheidet nicht fachlich.
Verschlanke die Presentation-Schicht
Reduziere Widgets/Controller/ViewModels auf Interaktion und View-State. Sie sprechen ausschließlich Use Cases an, mappen deren Erfolge/Fehler auf UI-Zustände und Navigation und kennen weder SDKs noch Netzwerkdetails.
Schreibe charakterisierende Tests und sichere die neuen Kanten
Lege kurze Tests an, die das aktuelle Verhalten „einfrieren“ (Happy Path + 1–2 wichtige Fehlerpfade). Teste Use Cases gegen Fakes/Mocks, prüfe Mappings in Data, teste Zustandsübergänge deiner Controller/ViewModels. Halte die Tests klein und schnell.
Führe das Feature inkrementell zusammen
Tausche die UI Schritt für Schritt auf Use-Case-Aufrufe um, bis keine direkten Infrastrukturzugriffe mehr übrig sind. Lasse die App laufen, prüfe Telemetrie/Logs und fixiere das Ergebnis mit einer knappen Architecture-Decision-Notiz (Warum? Welche Verträge? Welche Implementierungen?).
Räume Altlasten auf
Markiere Legacy-Pfade als deprecated, entferne ungenutzte Utils/Singletons, lösche doppelte Mappings. Aktualisiere die Ordnerstruktur, damit neue Teammitglieder sofort erkennen, wo Code hingehört.
Wiederhole das Muster für benachbarte Features
Nimm Features, die vom ersten profitieren (z. B. Registrierung nach Login, Detailansicht nach Liste). Du recycelst Verträge, Mappings und Testmuster – die Migration wird mit jedem Schritt schneller.
Verankere die Arbeitsweise im Team
Hinterlege ein kurzes Cheatsheet fürs Review („Kennt die UI nur Use Cases?“, „Leaken SDK-Typen in die Domäne?“, „Sind Fehler typisiert?“). Ergänze die Projekt-Readme um die Schichtenregeln und ein Beispiel für einen idealen Feature-Schnitt.
Miss den Nutzen
Vergleiche vor/nach: Zeit bis zum Fix, Anzahl der betroffenen Dateien pro Change, Testlaufzeit, Crash-/Error-Rate. Teile die Ergebnisse im Team – sichtbarer Fortschritt motiviert und schützt die Architekturentscheidungen.
Definition of Done für ein migriertes Feature: Die UI ruft ausschließlich Use Cases auf, die Domäne ist framework-frei und kapselt Regeln, das Repository implementiert ein Domänen-Interface und verbirgt alle Details, Fehler sind typisiert, es existieren kleine, schnelle Tests pro Schicht, und die Entscheidung ist in einer kurzen ADR dokumentiert.
Feature fertig, aber Code unwartbar? Du hast jetzt die Werkzeuge, um das zu ändern: klare Schichten, saubere Verträge und ein Weg, bestehende Projekte schrittweise zu heilen. Clean Architecture ist kein Selbstzweck, sondern dein Sicherheitsnetz: Regeln bleiben stabil, Details werden billig und du gewinnst Tempo bei jedem neuen Feature. Gerade in Flutter zahlt sich dieser Schnitt sofort aus, denn deine App fühlt sich ruhiger an, Tests werden trivialer und Onboarding wird einfacher.
Starte heute: Nutze meinen bereitgestellten Migrationspfad für bestehende Projekte und suche dir in einem alten Projekt ein einziges Feature aus. Lege einen Branch an, formuliere den Use Case, ziehe die Logik aus der UI, setze ein Repository davor und lass die Tests laufen. Ich garantiere dir, dass dir die Entwicklung schon bald sehr viel leichter fallen wird und deine Apps besser laufen werden. Viel Spaß bei der Umsetzung!
Kommentare
Bitte melde dich an, um einen Kommentar zu schreiben.