Kodokon kodokon.com

Eine App strukturieren: UI, Logik, Daten

Gliedere eine Flutter-App in sauber getrennte Schichten mit dem Repository-Muster und typisierten Sealed-Ergebnissen.

11 Min. · 3 Fragen

Diese Lektion in Kodokon öffnen

Eine 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.

DART
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;
}
Ein Sealed-Typ macht den Fehlerfall in der Signatur explizit.

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.

DART
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);
    }
  }
}
Das Repository mappt, cacht und wandelt Fehler in ein Result um.

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.

DART
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),
              ),
            ),
        };
      },
    );
  }
}
null = lädt, und das switch deckt jeden Fall des Sealed-Typs ab.

Wissenscheck

Stelle sicher, dass du die wichtigsten Punkte dieser Lektion behalten hast.

  1. Warum hängt die UI von der Schnittstelle CourseRepository ab statt von ApiCourseRepository?
    • Um in Tests eine Fake-Implementierung einzusetzen und die Datenquelle zu wechseln, ohne die Widgets anzufassen
    • Weil abstrakte Klassen schneller zu instanziieren sind
    • Weil Dart verbietet, eine konkrete Klasse aus einem Widget heraus zu instanziieren
  2. Welchen Vorteil bietet ein Sealed-Typ wie Result gegenüber einer Exception?
    • Der Compiler prüft, dass das switch erschöpfend ist: Du kannst den Fehlerfall nicht vergessen
    • Es macht asynchronen Code schneller
    • Es vermeidet jede Objektallokation auf dem Heap
    • Es fängt auch die Fehler aus Isolates ab
  3. Wo sollte das Mapping von JSON auf die Entität Course leben?
    • In der Datenschicht, damit die Domäne das Format der API nicht kennen muss
    • Im Widget, so nah wie möglich an der Anzeige
    • In main(), vor runApp