Gliedere eine Flutter-App in sauber getrennte Schichten mit dem Repository-Muster und typisierten Sealed-Ergebnissen.
Diese Lektion in Kodokon öffnenEine Flutter-App, die halten soll, trennt drei Zuständigkeiten: Präsentation (Widgets, BuildContext), Logik (State, Anwendungsfälle) und Daten (API, lokale Datenbank). Die oberste Regel ist die Richtung der Abhängigkeiten: Die Präsentation hängt von der Logik ab, die Logik von Daten-Abstraktionen - niemals umgekehrt. Konkret heißt das: Unterhalb der Präsentationsschicht darf weder ein BuildContext noch ein material.dart-Import auftauchen; ein Controller, der seine eigene SnackBar einblendet, ist ein untestbarer Controller. Ein weiterer Punkt, der oft übersehen wird: Fehler sind Teil des Vertrags zwischen den Schichten. Statt untypisierte Exceptions durchsickern zu lassen, modellierst du das Ergebnis mit einer sealed-Klasse - der Compiler zwingt dich dann, jeden Fall zu behandeln.
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
const Success(this.value);
final T value;
}
class Failure<T> extends Result<T> {
const Failure(this.error);
final Object error;
}
class Course {
const Course({required this.id, required this.title});
final String id;
final String title;
}Das Repository ist die Grenze zwischen der Domäne und der Außenwelt. Seine genaue Rolle: Domänen-Entitäten bereitstellen (niemals die rohen DTOs der API), die Zugriffsstrategie verbergen (Netzwerk, Cache, lokale Datenbank) und die Regeln zur Aktualität der Daten an einer Stelle bündeln. In Dart 3 deklarierst du den Vertrag mit abstract interface class: Dieser Modifier erlaubt von außerhalb der Library nur implements - du kannst nicht versehentlich ein Stück Implementierung erben, der Vertrag bleibt also sauber. Das Mapping von JSON auf Entitäten lebt in der Implementierung: Wenn die API morgen ein Feld umbenennt, ändert sich nur die Datenschicht.
abstract interface class CourseApi {
Future<List<Map<String, Object?>>> fetchRaw();
}
abstract interface class CourseRepository {
Future<Result<List<Course>>> getAll();
void invalidate();
}
class ApiCourseRepository implements CourseRepository {
ApiCourseRepository(this._api);
final CourseApi _api;
List<Course>? _cache;
@override
void invalidate() => _cache = null;
@override
Future<Result<List<Course>>> getAll() async {
final cached = _cache;
if (cached != null) {
return Success(cached);
}
try {
final rows = await _api.fetchRaw();
final courses = [
for (final row in rows)
Course(
id: row['id'] as String,
title: row['title'] as String,
),
];
_cache = courses;
return Success(courses);
} on Exception catch (error) {
return Failure(error);
}
}
}Auf der Präsentationsseite reicht sehr oft ein ChangeNotifier, der über den Konstruktor injiziert wird - du brauchst dafür kein Framework. Vermeide globale Singletons: Sie zementieren den Abhängigkeitsgraphen und machen das Austauschen in Tests mühsam. ListenableBuilder baut nur seinen builder neu auf, und das erschöpfende switch über das Result sichert die UI ab: Fügst du dem Sealed-Typ einen dritten Zustand hinzu, schlägt die Kompilierung überall dort fehl, wo er nicht behandelt wird. Genau darin liegt der ganze Wert des Musters - ein Versäumnis wird zum Compile-Fehler statt zum Bug in der Produktion.
import 'package:flutter/material.dart';
class CourseListController extends ChangeNotifier {
CourseListController(this._repository);
final CourseRepository _repository;
Result<List<Course>>? state;
Future<void> load() async {
state = null;
notifyListeners();
state = await _repository.getAll();
notifyListeners();
}
}
class CourseListScreen extends StatelessWidget {
const CourseListScreen({
super.key,
required this.controller,
});
final CourseListController controller;
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
return switch (controller.state) {
null => const Center(
child: CircularProgressIndicator(),
),
Failure() => const Center(
child: Text('Loading error'),
),
Success(value: final courses) =>
ListView.builder(
itemCount: courses.length,
itemBuilder: (context, i) => ListTile(
title: Text(courses[i].title),
),
),
};
},
);
}
}