Flutter API Integration: Robuste REST-Architektur

14 Min. Lesezeit9. Februar 2026Aktualisiert: 9. März 2026
Flutter API integrationFlutter REST APIFlutter repository patternDio FlutterFlutter error handlingFlutter network layerFlutter clean architecture apiFlutter backend integration

# Flutter API-Integration: Zuverlässige REST-Service-Architektur

Eine robuste API-Schicht beeinflusst die Nutzererfahrung unmittelbar. In jeder Produktions-App, die ich mit Flutter gebaut habe, ist die API-Schicht die kritischste Infrastrukturkomponente. Wird sie falsch aufgebaut, jagt man zufälligen Abstürzen, inkonsistenten Zuständen und frustrierten Nutzern hinterher. Wird sie richtig aufgebaut, funktioniert jedes Feature, das man darauf aufbaut, einfach.

Dieser Artikel beschreibt die Architektur, die ich in jedem Projekt einsetze: Dio-Konfiguration, Interceptoren, das Repository-Pattern, Fehlerbehandlung mit dem Result-Pattern, Offline-Caching und die Fehler, die ich in Teams immer wieder sehe.

Warum Dio statt http?

Das eingebaute `http`-Paket reicht für Prototypen, aber Produktions-Apps brauchen Interceptoren, Cancel-Tokens, Form-Data-Uploads und feingranulare Timeout-Steuerung. Dio liefert all das von Haus aus.

dart
import class="code-string">'package:dio/dio.dart';

class DioClient {
  late final Dio _dio;

  DioClient({required String baseUrl, String? authToken}) {
    _dio = Dio(
      BaseOptions(
        baseUrl: baseUrl,
        connectTimeout: const Duration(seconds: class="code-number">10),
        receiveTimeout: const Duration(seconds: class="code-number">15),
        sendTimeout: const Duration(seconds: class="code-number">10),
        headers: {
          class="code-string">'Content-Type': class="code-string">'application/json',
          class="code-string">'Accept': class="code-string">'application/json',
          if (authToken != null) class="code-string">'Authorization': class="code-string">'Bearer $authToken',
        },
      ),
    );

    _dio.interceptors.addAll([
      LoggingInterceptor(),
      AuthInterceptor(),
      RetryInterceptor(dio: _dio),
    ]);
  }

  Dio get dio => _dio;
}

Einige wichtige Punkte hierzu: Ich setze alle drei Timeout-Werte immer explizit. Die Standardwerte von Dio sind recht grosszuegig, und bei mobilen Netzen ist es besser, schnell einen Fehler zu melden, als den Nutzer auf einen Spinner starren zu lassen. Das Auth-Token injiziere ich beim Erstellen, aktualisiere es aber ueber einen Interceptor -- den zeige ich gleich.

Interceptoren in der Praxis

Interceptoren sind die grosse Staerke von Dio. In Produktions-Apps richte ich immer Interceptoren fuer drei Dinge ein: Logging, Token-Erneuerung und Retry-Logik.

Logging-Interceptor

dart
class LoggingInterceptor extends Interceptor {
  @override
  void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
    final method = options.method.toUpperCase();
    final url = options.uri.toString();
    debugPrint(class="code-string">'→ $method $url');
    if (options.data != null) {
      debugPrint(class="code-string">'  Body: ${options.data}');
    }
    handler.next(options);
  }

  @override
  void onResponse(Response response, ResponseInterceptorHandler handler) {
    final code = response.statusCode;
    final url = response.requestOptions.uri.toString();
    debugPrint(class="code-string">'← $code $url');
    handler.next(response);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) {
    debugPrint(class="code-string">'✗ ${err.type}: ${err.message}');
    handler.next(err);
  }
}

Auth-Interceptor mit Token-Erneuerung

Das ist der Interceptor, den ich am haeufigsten verwende. Wenn eine 401-Antwort kommt, versucht er eine Token-Erneuerung und wiederholt die urspruengliche Anfrage genau einmal. Schlaegt die Erneuerung selbst fehl, wird der Nutzer abgemeldet.

dart
class AuthInterceptor extends Interceptor {
  final TokenStorage _tokenStorage;
  final AuthService _authService;
  bool _isRefreshing = false;

  AuthInterceptor({
    required TokenStorage tokenStorage,
    required AuthService authService,
  })  : _tokenStorage = tokenStorage,
        _authService = authService;

  @override
  void onRequest(
    RequestOptions options,
    RequestInterceptorHandler handler,
  ) async {
    final token = await _tokenStorage.getAccessToken();
    if (token != null) {
      options.headers[class="code-string">'Authorization'] = class="code-string">'Bearer $token';
    }
    handler.next(options);
  }

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode != class="code-number">401 || _isRefreshing) {
      return handler.next(err);
    }

    _isRefreshing = true;
    try {
      final refreshToken = await _tokenStorage.getRefreshToken();
      if (refreshToken == null) throw Exception(class="code-string">'Kein Refresh-Token');

      final newTokens = await _authService.refreshToken(refreshToken);
      await _tokenStorage.saveTokens(newTokens);

      class=class="code-string">"code-comment">// Urspruengliche Anfrage mit neuem Token wiederholen
      final options = err.requestOptions;
      options.headers[class="code-string">'Authorization'] = class="code-string">'Bearer ${newTokens.accessToken}';
      final response = await Dio().fetch(options);
      handler.resolve(response);
    } catch (_) {
      await _tokenStorage.clear();
      handler.reject(err);
    } finally {
      _isRefreshing = false;
    }
  }
}

Retry-Interceptor

Mobile Netzwerkbedingungen sind unvorhersehbar. Ein einfacher Retry mit exponentiellem Backoff spart viele Nutzerbeschwerden.

dart
class RetryInterceptor extends Interceptor {
  final Dio dio;
  final int maxRetries;

  RetryInterceptor({required this.dio, this.maxRetries = class="code-number">2});

  @override
  void onError(DioException err, ErrorInterceptorHandler handler) async {
    final isRetryable = err.type == DioExceptionType.connectionTimeout ||
        err.type == DioExceptionType.receiveTimeout ||
        err.type == DioExceptionType.connectionError;

    if (!isRetryable) return handler.next(err);

    int attempt = err.requestOptions.extra[class="code-string">'retryCount'] ?? class="code-number">0;
    if (attempt >= maxRetries) return handler.next(err);

    attempt++;
    err.requestOptions.extra[class="code-string">'retryCount'] = attempt;

    final delay = Duration(milliseconds: class="code-number">300 * (class="code-number">1 << attempt));
    await Future.delayed(delay);

    try {
      final response = await dio.fetch(err.requestOptions);
      handler.resolve(response);
    } on DioException catch (e) {
      handler.next(e);
    }
  }
}

Vollstaendige API-Schicht-Architektur

Die Architektur, die ich verwende, folgt Clean-Architecture-Prinzipien, ohne in uebertriebene Abstraktion zu verfallen. Der Gesamtueberblick:

UI (Widgets)
  ↓
BLoC / Cubit / Riverpod
  ↓
Repository (Abstraktionen)
  ↓
Remote Data Source ←→ Local Data Source
  ↓                        ↓
Dio (HTTP)             Hive / Drift / SharedPrefs

Data Transfer Objects (DTOs)

DTOs bilden API-Antworten eins-zu-eins ab. Lassen Sie JSON-Strukturen der API niemals in Ihre Domain-Schicht durchsickern.

dart
class UserDto {
  final int id;
  final String userName;
  final String emailAddress;
  final String? avatarUrl;
  final String createdAt;

  UserDto({
    required this.id,
    required this.userName,
    required this.emailAddress,
    this.avatarUrl,
    required this.createdAt,
  });

  factory UserDto.fromJson(Map<String, dynamic> json) => UserDto(
        id: json[class="code-string">'id'] as int,
        userName: json[class="code-string">'user_name'] as String,
        emailAddress: json[class="code-string">'email_address'] as String,
        avatarUrl: json[class="code-string">'avatar_url'] as String?,
        createdAt: json[class="code-string">'created_at'] as String,
      );

  Map<String, dynamic> toJson() => {
        class="code-string">'id': id,
        class="code-string">'user_name': userName,
        class="code-string">'email_address': emailAddress,
        class="code-string">'avatar_url': avatarUrl,
        class="code-string">'created_at': createdAt,
      };

  User toDomain() => User(
        id: id,
        name: userName,
        email: emailAddress,
        avatarUrl: avatarUrl,
        memberSince: DateTime.parse(createdAt),
      );
}

Domain-Modell

dart
class User {
  final int id;
  final String name;
  final String email;
  final String? avatarUrl;
  final DateTime memberSince;

  const User({
    required this.id,
    required this.name,
    required this.email,
    this.avatarUrl,
    required this.memberSince,
  });
}

Remote Data Source

dart
abstract class UserRemoteDataSource {
  Future<UserDto> getUser(int id);
  Future<List<UserDto>> getUsers({int page = class="code-number">1, int limit = class="code-number">20});
  Future<UserDto> updateUser(int id, Map<String, dynamic> data);
}

class UserRemoteDataSourceImpl implements UserRemoteDataSource {
  final Dio _dio;

  UserRemoteDataSourceImpl(this._dio);

  @override
  Future<UserDto> getUser(int id) async {
    final response = await _dio.get(class="code-string">'/users/$id');
    return UserDto.fromJson(response.data);
  }

  @override
  Future<List<UserDto>> getUsers({int page = class="code-number">1, int limit = class="code-number">20}) async {
    final response = await _dio.get(
      class="code-string">'/users',
      queryParameters: {class="code-string">'page': page, class="code-string">'limit': limit},
    );
    final list = response.data[class="code-string">'data'] as List;
    return list.map((e) => UserDto.fromJson(e)).toList();
  }

  @override
  Future<UserDto> updateUser(int id, Map<String, dynamic> data) async {
    final response = await _dio.put(class="code-string">'/users/$id', data: data);
    return UserDto.fromJson(response.data);
  }
}

Repository

Das Repository ist das Tor zwischen Domain- und Datenschicht. Es entscheidet, ob Daten aus dem Netzwerk oder aus dem Cache geholt werden, und wandelt DTOs in Domain-Modelle um.

dart
abstract class UserRepository {
  Future<Result<User>> getUser(int id);
  Future<Result<List<User>>> getUsers({int page = class="code-number">1});
  Future<Result<User>> updateUser(int id, Map<String, dynamic> data);
}

class UserRepositoryImpl implements UserRepository {
  final UserRemoteDataSource _remoteDataSource;
  final UserLocalDataSource _localDataSource;

  UserRepositoryImpl({
    required UserRemoteDataSource remoteDataSource,
    required UserLocalDataSource localDataSource,
  })  : _remoteDataSource = remoteDataSource,
        _localDataSource = localDataSource;

  @override
  Future<Result<User>> getUser(int id) async {
    try {
      final dto = await _remoteDataSource.getUser(id);
      await _localDataSource.cacheUser(dto);
      return Result.success(dto.toDomain());
    } on DioException catch (e) {
      class=class="code-string">"code-comment">// Bei Netzwerkfehler auf Cache zurueckfallen
      final cached = await _localDataSource.getCachedUser(id);
      if (cached != null) return Result.success(cached.toDomain());
      return Result.failure(NetworkFailure.fromDioException(e));
    } catch (e) {
      return Result.failure(UnexpectedFailure(e.toString()));
    }
  }

  @override
  Future<Result<List<User>>> getUsers({int page = class="code-number">1}) async {
    try {
      final dtos = await _remoteDataSource.getUsers(page: page);
      return Result.success(dtos.map((d) => d.toDomain()).toList());
    } on DioException catch (e) {
      return Result.failure(NetworkFailure.fromDioException(e));
    }
  }

  @override
  Future<Result<User>> updateUser(int id, Map<String, dynamic> data) async {
    try {
      final dto = await _remoteDataSource.updateUser(id, data);
      await _localDataSource.cacheUser(dto);
      return Result.success(dto.toDomain());
    } on DioException catch (e) {
      return Result.failure(NetworkFailure.fromDioException(e));
    }
  }
}

Fehlerbehandlungsstrategie

Exceptions ueber Schichtgrenzen hinweg zu werfen, fuehrt zu vergessenen Catch-Bloecken und Abstuerzen in Produktion. Stattdessen verwende ich einen `Result`-Typ, der den Aufrufer zwingt, sowohl Erfolg als auch Fehler explizit zu behandeln.

Der Result-Typ

dart
sealed class Result<T> {
  const Result();

  factory Result.success(T data) = Success<T>;
  factory Result.failure(Failure failure) = Err<T>;

  R when<R>({
    required R Function(T data) success,
    required R Function(Failure failure) failure,
  });
}

class Success<T> extends Result<T> {
  final T data;
  const Success(this.data);

  @override
  R when<R>({
    required R Function(T data) success,
    required R Function(Failure failure) failure,
  }) => success(data);
}

class Err<T> extends Result<T> {
  final Failure failure;
  const Err(this.failure);

  @override
  R when<R>({
    required R Function(T data) success,
    required R Function(Failure failure) failure,
  }) => failure(this.failure);
}

Typisierte Fehler

dart
sealed class Failure {
  final String message;
  const Failure(this.message);
}

class NetworkFailure extends Failure {
  final int? statusCode;

  const NetworkFailure(super.message, {this.statusCode});

  factory NetworkFailure.fromDioException(DioException e) {
    return switch (e.type) {
      DioExceptionType.connectionTimeout =>
        const NetworkFailure(class="code-string">'Verbindungs-Timeout'),
      DioExceptionType.receiveTimeout =>
        const NetworkFailure(class="code-string">'Server hat zu lange gebraucht'),
      DioExceptionType.badResponse => NetworkFailure(
          e.response?.data?[class="code-string">'message'] ?? class="code-string">'Serverfehler',
          statusCode: e.response?.statusCode,
        ),
      DioExceptionType.connectionError =>
        const NetworkFailure(class="code-string">'Keine Internetverbindung'),
      _ => NetworkFailure(class="code-string">'Netzwerkfehler: ${e.message}'),
    };
  }
}

class CacheFailure extends Failure {
  const CacheFailure(super.message);
}

class UnexpectedFailure extends Failure {
  const UnexpectedFailure(super.message);
}

Result in der UI-Schicht verwenden

dart
class UserCubit extends Cubit<UserState> {
  final UserRepository _repository;

  UserCubit(this._repository) : super(const UserState.loading());

  Future<void> loadUser(int id) async {
    emit(const UserState.loading());
    final result = await _repository.getUser(id);
    result.when(
      success: (user) => emit(UserState.loaded(user)),
      failure: (failure) => emit(UserState.error(failure.message)),
    );
  }
}

Dieses Pattern eliminiert die Moeglichkeit unbehandelter Exceptions. Jede Aufrufstelle muss sich mit dem Fehlerfall befassen, und der Compiler hilft dabei.

Offline-Unterstuetzung und Caching

Mobile Nutzer verlieren staendig die Verbindung. Eine gute API-Schicht degradiert elegant, statt Fehlerbildschirme anzuzeigen.

Local Data Source mit Hive

dart
abstract class UserLocalDataSource {
  Future<void> cacheUser(UserDto user);
  Future<UserDto?> getCachedUser(int id);
  Future<void> cacheUsers(List<UserDto> users);
  Future<List<UserDto>> getCachedUsers();
  Future<void> clearCache();
}

class UserLocalDataSourceImpl implements UserLocalDataSource {
  final Box<Map> _userBox;

  UserLocalDataSourceImpl(this._userBox);

  @override
  Future<void> cacheUser(UserDto user) async {
    await _userBox.put(user.id.toString(), user.toJson());
  }

  @override
  Future<UserDto?> getCachedUser(int id) async {
    final json = _userBox.get(id.toString());
    if (json == null) return null;
    return UserDto.fromJson(Map<String, dynamic>.from(json));
  }

  @override
  Future<void> cacheUsers(List<UserDto> users) async {
    final entries = {for (var u in users) u.id.toString(): u.toJson()};
    await _userBox.putAll(entries);
  }

  @override
  Future<List<UserDto>> getCachedUsers() async {
    return _userBox.values
        .map((e) => UserDto.fromJson(Map<String, dynamic>.from(e)))
        .toList();
  }

  @override
  Future<void> clearCache() async => await _userBox.clear();
}

Cache-First-Strategie

In der Praxis verwende ich ein Cache-First-Pattern fuer Daten, die sich selten aendern (Nutzerprofile, Einstellungen), und ein Network-First-Pattern fuer alles andere. Das Repository entscheidet, welche Strategie angewandt wird:

dart
Future<Result<User>> getUser(int id) async {
  class=class="code-string">"code-comment">// Zuerst Cache pruefen
  final cached = await _localDataSource.getCachedUser(id);
  if (cached != null) {
    class=class="code-string">"code-comment">// Cached-Daten sofort zurueckgeben, im Hintergrund aktualisieren
    _refreshUserInBackground(id);
    return Result.success(cached.toDomain());
  }

  class=class="code-string">"code-comment">// Kein Cache, muss vom Netzwerk holen
  try {
    final dto = await _remoteDataSource.getUser(id);
    await _localDataSource.cacheUser(dto);
    return Result.success(dto.toDomain());
  } on DioException catch (e) {
    return Result.failure(NetworkFailure.fromDioException(e));
  }
}

void _refreshUserInBackground(int id) async {
  try {
    final dto = await _remoteDataSource.getUser(id);
    await _localDataSource.cacheUser(dto);
  } catch (_) {
    class=class="code-string">"code-comment">// Stiller Fehler — gecachte Daten sind weiterhin gueltig
  }
}

Konnektivitaetsueberwachung

Ich kombiniere Caching mit einem Konnektivitaets-Listener, damit die UI ein Offline-Banner anzeigen und sinnlose Netzwerkanfragen vermeiden kann:

dart
class ConnectivityService {
  final Connectivity _connectivity = Connectivity();

  Stream<bool> get onlineStream =>
      _connectivity.onConnectivityChanged.map(
        (result) => result != ConnectivityResult.none,
      );

  Future<bool> get isOnline async {
    final result = await _connectivity.checkConnectivity();
    return result != ConnectivityResult.none;
  }
}

JSON-Serialisierung

Fuer alles jenseits eines trivialen Projekts verwende ich `json_serializable` mit `freezed`. Ein praktisches Setup:

dart
class=class="code-string">"code-comment">// pubspec.yaml dependencies
class=class="code-string">"code-comment">// json_annotation, freezed_annotation
class=class="code-string">"code-comment">// dev_dependencies: json_serializable, freezed, build_runner

@freezed
class ApiResponse<T> with _$ApiResponse<T> {
  const factory ApiResponse({
    required bool success,
    required T data,
    String? message,
    ApiMeta? meta,
  }) = _ApiResponse;

  factory ApiResponse.fromJson(
    Map<String, dynamic> json,
    T Function(Object?) fromJsonT,
  ) => _$ApiResponseFromJson(json, fromJsonT);
}

@freezed
class ApiMeta with _$ApiMeta {
  const factory ApiMeta({
    required int currentPage,
    required int lastPage,
    required int perPage,
    required int total,
  }) = _ApiMeta;

  factory ApiMeta.fromJson(Map<String, dynamic> json) =>
      _$ApiMetaFromJson(json);
}

Codegenerierung ausfuehren mit:

bash
dart run build_runner build --delete-conflicting-outputs

Haeufige API-Integrationsfehler

Das sind Probleme, die ich wiederholt in Codebasen gesehen habe, die ich reviewt oder uebernommen habe.

1. API-Aufrufe direkt aus Widgets

Das ist der haeufigste Fehler. Wenn Dio direkt im Widget aufgerufen wird, verliert man Testbarkeit, Wiederverwendbarkeit und jede Moeglichkeit, die Datenquelle spaeter auszutauschen.

dart
class=class="code-string">"code-comment">// Schlecht: API-Aufruf im Widget
class UserScreen extends StatefulWidget { ... }
class _UserScreenState extends State<UserScreen> {
  Future<void> _loadUser() async {
    final response = await Dio().get(class="code-string">'https:class=class="code-string">"code-comment">//api.example.com/users/class="code-number">1');
    setState(() { _user = User.fromJson(response.data); });
  }
}

class=class="code-string">"code-comment">// Gut: Ueber ein Repository gehen
class _UserScreenState extends State<UserScreen> {
  Future<void> _loadUser() async {
    final result = await userRepository.getUser(class="code-number">1);
    result.when(
      success: (user) => setState(() { _user = user; }),
      failure: (f) => _showError(f.message),
    );
  }
}

2. Abbruch ignorieren

Wenn ein Nutzer die Seite verlaesst, waehrend eine Anfrage laeuft, sollte man sie abbrechen. Sonst verschwendet man Bandbreite und riskiert, `setState` auf einem disposed Widget aufzurufen.

dart
class _UserScreenState extends State<UserScreen> {
  final CancelToken _cancelToken = CancelToken();

  Future<void> _loadUser() async {
    final response = await dio.get(
      class="code-string">'/users/class="code-number">1',
      cancelToken: _cancelToken,
    );
    class=class="code-string">"code-comment">// ...
  }

  @override
  void dispose() {
    _cancelToken.cancel(class="code-string">'Widget disposed');
    super.dispose();
  }
}

3. Fehlende Paginierung

Alle Datensaetze auf einmal zu laden, funktioniert in der Entwicklung mit 10 Eintraegen. In Produktion mit 10.000 bricht es zusammen. Implementieren Sie von Anfang an Cursor- oder Offset-basierte Paginierung.

dart
class PaginatedResult<T> {
  final List<T> items;
  final int currentPage;
  final int totalPages;
  final bool hasMore;

  PaginatedResult({
    required this.items,
    required this.currentPage,
    required this.totalPages,
  }) : hasMore = currentPage < totalPages;
}

4. Base-URLs hartcodieren

Verwenden Sie umgebungsspezifische Konfiguration. Ich definiere Base-URLs typischerweise pro Flavor:

dart
enum Environment { dev, staging, prod }

class AppConfig {
  final String baseUrl;
  final Duration timeout;

  const AppConfig._({required this.baseUrl, required this.timeout});

  factory AppConfig.fromEnvironment(Environment env) {
    return switch (env) {
      Environment.dev => const AppConfig._(
          baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//dev-api.example.com',
          timeout: Duration(seconds: class="code-number">30),
        ),
      Environment.staging => const AppConfig._(
          baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//staging-api.example.com',
          timeout: Duration(seconds: class="code-number">15),
        ),
      Environment.prod => const AppConfig._(
          baseUrl: class="code-string">'https:class=class="code-string">"code-comment">//api.example.com',
          timeout: Duration(seconds: class="code-number">10),
        ),
    };
  }
}

5. Fehler stillschweigend verschlucken

Leere Catch-Bloecke sind der Feind der Fehlersuche. Loggen Sie mindestens den Fehler. Besser noch, verwenden Sie das Result-Pattern, damit Fehler immer sichtbar werden.

dart
class=class="code-string">"code-comment">// Furchtbar: stiller Fehler
try {
  await dio.post(class="code-string">'/orders', data: order.toJson());
} catch (_) {}

class=class="code-string">"code-comment">// Besser: als typisierten Fehler weiterreichen
try {
  await dio.post(class="code-string">'/orders', data: order.toJson());
} on DioException catch (e) {
  return Result.failure(NetworkFailure.fromDioException(e));
}

Die API-Schicht testen

Eine sauber geschichtete API-Architektur ist trivial testbar. Mocken Sie die Data Source, testen Sie die Repository-Logik isoliert.

dart
class MockUserRemoteDataSource extends Mock
    implements UserRemoteDataSource {}

void main() {
  late UserRepositoryImpl repository;
  late MockUserRemoteDataSource mockRemote;
  late MockUserLocalDataSource mockLocal;

  setUp(() {
    mockRemote = MockUserRemoteDataSource();
    mockLocal = MockUserLocalDataSource();
    repository = UserRepositoryImpl(
      remoteDataSource: mockRemote,
      localDataSource: mockLocal,
    );
  });

  test(class="code-string">'holt Nutzer vom Server und cached ihn', () async {
    final dto = UserDto(id: class="code-number">1, userName: class="code-string">'Ada', ...);
    when(() => mockRemote.getUser(class="code-number">1)).thenAnswer((_) async => dto);
    when(() => mockLocal.cacheUser(dto)).thenAnswer((_) async {});

    final result = await repository.getUser(class="code-number">1);

    expect(result, isA<Success<User>>());
    verify(() => mockLocal.cacheUser(dto)).called(class="code-number">1);
  });

  test(class="code-string">'faellt bei Netzwerkfehler auf Cache zurueck', () async {
    final dto = UserDto(id: class="code-number">1, userName: class="code-string">'Ada', ...);
    when(() => mockRemote.getUser(class="code-number">1)).thenThrow(
      DioException(type: DioExceptionType.connectionError, ...),
    );
    when(() => mockLocal.getCachedUser(class="code-number">1)).thenAnswer((_) async => dto);

    final result = await repository.getUser(class="code-number">1);

    expect(result, isA<Success<User>>());
  });
}

Fazit

Eine saubere API-Schicht ist das Fundament jeder zuverlaessigen Flutter-App. Die hier beschriebene Architektur -- Dio mit Interceptoren, das Repository-Pattern, typisierte Fehlerbehandlung mit Result und Offline-Caching -- hat mir in Dutzenden von Produktionsprojekten gute Dienste geleistet. Im Vergleich zu rohen HTTP-Aufrufen erfordert sie etwas mehr Einrichtungsaufwand, aber der Gewinn an Wartbarkeit, Testbarkeit und Nutzererfahrung ist enorm.

Ich unterstuetze gern bei Review und Refactoring Ihrer API-Schicht.

Verwandte Artikel

Haben Sie ein Flutter-Projekt?

Ich entwickle hochleistungsfähige Flutter-Anwendungen für iOS, Android und Web.

Kontakt aufnehmen