Flutter-App-Architektur, die über das Tutorial hinaus skaliert
Produktionsreife Flutter-Architekturmuster — von State Management über Navigation bis hin zu Dependency Injection und Feature-First-Projektstruktur.

Tutorial-Flutter-Apps funktionieren hervorragend, bis sie etwa zwanzig Screens erreichen. Dann wird die einzelne main.dart-Datei mit allen Routen unübersichtlich, die setState-Aufrufe verursachen Race Conditions, der Ansatz "einfach durch den Konstruktor durchreichen" erzeugt Widget-Bäume mit zehn Parametern, und jedes neue Feature berührt fünf bestehende Dateien. Ich habe das bei mehreren Projekten beobachtet, einschließlich meiner eigenen, und das Muster ist immer dasselbe — das Team stößt etwa im dritten Monat an eine Wand und muss entweder umstrukturieren oder einen zunehmenden Verlust der Entwicklungsgeschwindigkeit akzeptieren.
Die Architektur, die ich hier beschreibe, ist das, was ich für Produktions-Apps verwende. Es ist nicht der einfachste mögliche Ansatz, und genau das ist der Punkt. Der einfachste Ansatz ist das, was Tutorials lehren, und er funktioniert nicht mehr, sobald Ihre App reale Anforderungen hat.
Warum Tutorial-Architektur scheitert
Die meisten Flutter-Tutorials und -Kurse lehren eine flache Projektstruktur:
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
Das funktioniert für eine To-do-App. Für eine echte App scheitert es aus vorhersehbaren Gründen:
Dateien wachsen ohne natürliche Grenzen. Wenn home_screen.dart das Screen-Layout, das State Management, die API-Aufrufe und die Geschäftslogik enthält, erreicht sie schnell 500+ Zeilen. Es gibt keine strukturelle Orientierung, was extrahiert werden sollte und wohin.
Abhängigkeiten werden implizit. Wenn home_screen.dart direkt ApiService() instanziiert, gibt es keine Möglichkeit, ihn für Tests durch einen Mock zu ersetzen, keine Möglichkeit, ihn pro Umgebung anders zu konfigurieren, und keine Möglichkeit, eine einzelne Instanz über Screens hinweg zu teilen, die dieselben Daten benötigen.
Navigation wird ein Geflecht aus hartcodierten Routen. Deep Links, Authentifizierungs-Guards und bedingte Flows werden alle an eine flache Routenliste angehängt. Wenn Sie dreißig Routen mit Guards und Redirects haben, ist der Navigationscode der fragilste Teil der App.
Testing wird unpraktisch. Ohne Dependency Injection erfordern Widget-Tests Mocking auf HTTP-Ebene statt auf Service-Ebene. Ohne klare Trennung von Geschäftslogik und UI testen Sie am Ende Implementierungsdetails statt Verhalten.
Feature-First-Projektstruktur
Die wirkungsvollste architektonische Entscheidung ist die Ordnerstruktur. Ich verwende einen Feature-First-Ansatz, bei dem jedes Feature seine eigenen Screens, Widgets, States und Models besitzt:
lib/
├── app/
│ ├── app.dart # MaterialApp configuration
│ ├── router.dart # GoRouter configuration
│ └── theme.dart # Theme data
├── core/
│ ├── constants/
│ │ ├── api_constants.dart
│ │ └── app_constants.dart
│ ├── errors/
│ │ ├── app_exception.dart
│ │ └── error_handler.dart
│ ├── network/
│ │ ├── api_client.dart
│ │ ├── interceptors/
│ │ └── network_info.dart
│ └── utils/
│ ├── date_utils.dart
│ └── string_utils.dart
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── auth_repository.dart
│ │ │ └── auth_remote_source.dart
│ │ ├── domain/
│ │ │ ├── user_model.dart
│ │ │ └── auth_state.dart
│ │ ├── presentation/
│ │ │ ├── screens/
│ │ │ │ ├── login_screen.dart
│ │ │ │ └── register_screen.dart
│ │ │ ├── widgets/
│ │ │ │ ├── login_form.dart
│ │ │ │ └── social_login_buttons.dart
│ │ │ └── providers/
│ │ │ └── auth_provider.dart
│ │ └── auth.dart # Barrel file for public API
│ ├── home/
│ │ ├── data/
│ │ ├── domain/
│ │ ├── presentation/
│ │ └── home.dart
│ └── settings/
│ ├── data/
│ ├── domain/
│ ├── presentation/
│ └── settings.dart
└── shared/
├── widgets/
│ ├── app_button.dart
│ ├── app_text_field.dart
│ ├── loading_overlay.dart
│ └── error_view.dart
├── models/
│ └── paginated_response.dart
└── providers/
└── connectivity_provider.dart
Die Regeln
Features importieren niemals von anderen Features. Wenn Feature A Daten von Feature B benötigt, leben diese Daten in einem Shared Model oder einem Shared Service, nicht im Ordner von Feature B. Das verhindert zirkuläre Abhängigkeiten und hält Features unabhängig deploybar (nützlich, falls Sie jemals zu einem modularen Monorepo wechseln).
Das core/-Verzeichnis enthält Infrastruktur. Netzwerk, Fehlerbehandlung, Konstanten, Utilities — Dinge, die jedes Feature benötigen könnte, die aber zu keinem einzelnen Feature gehören.
Das shared/-Verzeichnis enthält wiederverwendbare UI und übergreifende Anliegen. Geteilte Widgets, geteilte Models und Provider, von denen mehrere Features abhängen.
Barrel-Dateien legen die öffentliche API jedes Features offen. Andere Teile der App importieren von features/auth/auth.dart, niemals von internen Dateien. Das ermöglicht es, die interne Struktur eines Features umzustrukturieren, ohne externe Nutzer zu beeinträchtigen.
// features/auth/auth.dart
export 'domain/user_model.dart';
export 'domain/auth_state.dart';
export 'presentation/providers/auth_provider.dart';
// Don't export internal implementation details
State Management: Riverpod
Ich habe Provider, Bloc und Riverpod in Produktionsprojekten verwendet. Jede Lösung hat ihre Kompromisse, und die Wahl hängt mehr von der Vertrautheit des Teams ab als von technischer Überlegenheit. Dennoch greife ich bei neuen Projekten aus bestimmten Gründen zu Riverpod.
Warum Riverpod statt Bloc
Bloc ist hervorragend für Teams mit Enterprise-Hintergrund. Das Event/State-Muster ist vertraut für Entwickler, die mit Redux oder MVI gearbeitet haben. Es erzwingt einen strikten unidirektionalen Datenfluss und erzeugt vorhersagbare, testbare State Machines. Der Nachteil ist die Ausführlichkeit — ein einfaches Feature erfordert eine Event-Klasse, eine State-Klasse und eine Bloc-Klasse, oft verteilt auf drei Dateien. Für Apps mit komplexer Geschäftslogik und mehreren Entwicklern zahlt sich diese Zeremonie aus. Für kleinere Teams, die schnell vorankommen müssen, kann es sich wie Overhead anfühlen.
Provider (das ursprüngliche Paket) funktioniert gut für einfache Dependency Injection, hat aber Schwierigkeiten mit komplexem State. Es hängt vom Widget-Baum für das Scoping ab, was bedeutet, dass der Lebenszyklus Ihres States an Ihre UI-Struktur gebunden ist. Das Refactoring des Widget-Baums kann State Management auf subtile Weise brechen.
Riverpod löst die grundlegenden Einschränkungen von Provider. Provider sind global (nicht Widget-Baum-abhängig), compile-time-sicher und unterstützen fortgeschrittene Muster wie Auto-Disposal, Family-Provider und asynchronen State. Es erzeugt auch weniger Boilerplate als Bloc bei gleichzeitiger Testbarkeit.
Riverpod in der Praxis
So strukturiere ich das State Management für ein typisches Feature:
// features/auth/domain/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user_model.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.initial() = _Initial;
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated(User user) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error(String message) = _Error;
}
// features/auth/presentation/providers/auth_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../data/auth_repository.dart';
import '../../domain/auth_state.dart';
part 'auth_provider.g.dart';
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AuthState build() {
_checkAuthStatus();
return const AuthState.initial();
}
Future<void> _checkAuthStatus() async {
final repository = ref.read(authRepositoryProvider);
final user = await repository.getCurrentUser();
state = user != null
? AuthState.authenticated(user)
: const AuthState.unauthenticated();
}
Future<void> signIn(String email, String password) async {
state = const AuthState.loading();
final repository = ref.read(authRepositoryProvider);
try {
final user = await repository.signIn(email, password);
state = AuthState.authenticated(user);
} on AuthException catch (e) {
state = AuthState.error(e.message);
}
}
Future<void> signOut() async {
final repository = ref.read(authRepositoryProvider);
await repository.signOut();
state = const AuthState.unauthenticated();
}
}
Die Verwendung der @riverpod-Annotation mit Code-Generierung eliminiert den manuellen Provider-Deklarations-Boilerplate. Führen Sie dart run build_runner build aus, um den Provider-Code zu generieren.
Wann Bloc besser ist
Trotz meiner Vorliebe für Riverpod gibt es Szenarien, in denen Bloc die stärkere Wahl ist:
- Große Teams, in denen erzwungene Muster Inkonsistenz verhindern.
- Komplexe State Machines mit vielen Übergängen — Blocs Event-System macht diese explizit und testbar.
- Wenn Sie Replay/Undo-Funktionalität benötigen — Blocs Event-Log macht dies unkompliziert.
- Wenn das Team Bloc bereits kennt. Konsistenz in der gesamten Codebasis ist wichtiger als jeder technische Unterschied zwischen den beiden.
Navigation mit GoRouter
GoRouter ist zum Standard für Flutter-Navigation geworden, und das aus gutem Grund. Es unterstützt deklaratives Routing, Deep Links, Redirects und verschachtelte Navigation von Haus aus.
// app/router.dart
import 'package:go_router/go_router.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'router.g.dart';
@riverpod
GoRouter router(RouterRef ref) {
final authState = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final isAuthenticated = authState is Authenticated;
final isAuthRoute = state.matchedLocation.startsWith('/auth');
if (!isAuthenticated && !isAuthRoute) return '/auth/login';
if (isAuthenticated && isAuthRoute) return '/';
return null;
},
routes: [
GoRoute(
path: '/auth/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/auth/register',
builder: (context, state) => const RegisterScreen(),
),
ShellRoute(
builder: (context, state, child) => AppShell(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsScreen(),
),
GoRoute(
path: '/item/:id',
builder: (context, state) {
final id = state.pathParameters['id']!;
return ItemDetailScreen(itemId: id);
},
),
],
),
],
);
}
Navigation Guards
Der redirect-Callback behandelt Authentifizierungs-Guards global. Aber Sie benötigen möglicherweise komplexere Logik — rollenbasierten Zugriff, Onboarding-Flows, Feature Flags. Ich behandle diese mit einer Redirect-Kette:
redirect: (context, state) {
// Authentication check
final isAuthenticated = authState is Authenticated;
if (!isAuthenticated && !state.matchedLocation.startsWith('/auth')) {
return '/auth/login';
}
// Onboarding check
if (isAuthenticated && !hasCompletedOnboarding && state.matchedLocation != '/onboarding') {
return '/onboarding';
}
// Already authenticated, redirect away from auth screens
if (isAuthenticated && state.matchedLocation.startsWith('/auth')) {
return '/';
}
return null; // No redirect
},
Das Repository Pattern
Repositories sitzen zwischen Ihrer Geschäftslogik und Datenquellen. Sie abstrahieren, ob Daten von einer REST-API, einer lokalen Datenbank, einem Cache oder einer Kombination aus allen dreien kommen.
// features/auth/data/auth_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user_model.dart';
part 'auth_repository.g.dart';
@riverpod
AuthRepository authRepository(AuthRepositoryRef ref) {
return AuthRepository(
remoteSource: ref.read(authRemoteSourceProvider),
localSource: ref.read(authLocalSourceProvider),
);
}
class AuthRepository {
final AuthRemoteSource _remoteSource;
final AuthLocalSource _localSource;
AuthRepository({
required AuthRemoteSource remoteSource,
required AuthLocalSource localSource,
}) : _remoteSource = remoteSource,
_localSource = localSource;
Future<User?> getCurrentUser() async {
// Try local cache first
final cachedUser = await _localSource.getCachedUser();
if (cachedUser != null) return cachedUser;
// Fall back to remote
try {
final user = await _remoteSource.fetchCurrentUser();
if (user != null) await _localSource.cacheUser(user);
return user;
} catch (_) {
return null;
}
}
Future<User> signIn(String email, String password) async {
final user = await _remoteSource.signIn(email, password);
await _localSource.cacheUser(user);
await _localSource.saveToken(user.token);
return user;
}
Future<void> signOut() async {
await _remoteSource.signOut();
await _localSource.clearCache();
}
}
Warum nicht einfach die API direkt aufrufen
Direkte API-Aufrufe aus dem State-Management-Code verursachen mehrere Probleme:
- Caching-Logik vermischt sich mit Geschäftslogik. Prüfen Sie den Cache vor dem API-Aufruf? Wie lange ist der Cache gültig? Das ist eine Datenschicht-Angelegenheit, keine State-Management-Angelegenheit.
- Offline-Support wird zum Komplett-Umbau. Wenn Ihr Bloc direkt
http.getaufruft, bedeutet das Hinzufügen von Offline-Support die Modifikation jedes Blocs. Mit einem Repository fügen Sie Caching und Fallback-Logik an einer Stelle hinzu. - Testing erfordert HTTP-Mocking. Das Mocken eines Repositories ist ein Mock pro Test. Das Mocken von HTTP erfordert das Einrichten von Response-Bodies, Statuscodes und Headern für jeden Testfall.
Service Layer
Für Operationen, die mehrere Repositories umspannen oder komplexe Geschäftslogik beinhalten, verwende ich einen Service Layer:
class OrderService {
final OrderRepository _orderRepo;
final PaymentRepository _paymentRepo;
final NotificationRepository _notificationRepo;
OrderService({
required OrderRepository orderRepo,
required PaymentRepository paymentRepo,
required NotificationRepository notificationRepo,
}) : _orderRepo = orderRepo,
_paymentRepo = paymentRepo,
_notificationRepo = notificationRepo;
Future<Order> placeOrder(Cart cart, PaymentMethod method) async {
// Validate stock
await _orderRepo.validateStock(cart.items);
// Process payment
final payment = await _paymentRepo.charge(method, cart.total);
// Create order
final order = await _orderRepo.create(
items: cart.items,
paymentId: payment.id,
);
// Send confirmation (fire and forget)
_notificationRepo.sendOrderConfirmation(order).ignore();
return order;
}
}
Services enthalten Geschäftslogik, die weder in die UI noch in ein einzelnes Repository gehört. Die State-Management-Schicht (Riverpod Notifier, Bloc) ruft den Service auf, und der Service koordiniert zwischen den Repositories.
Fehlerbehandlung
Konsistente Fehlerbehandlung ist einer der am meisten vernachlässigten Aspekte der Flutter-Architektur. Ich verwende eine Kombination aus typisierten Exceptions und einem Result-Typ.
// core/errors/app_exception.dart
sealed class AppException implements Exception {
final String message;
final String? code;
const AppException(this.message, {this.code});
}
class NetworkException extends AppException {
const NetworkException([String message = 'Network error']) : super(message);
}
class ServerException extends AppException {
final int statusCode;
const ServerException(this.statusCode, String message) : super(message);
}
class AuthException extends AppException {
const AuthException([String message = 'Authentication failed']) : super(message);
}
class ValidationException extends AppException {
final Map<String, String> fieldErrors;
const ValidationException(this.fieldErrors) : super('Validation failed');
}
Der API-Client fängt HTTP-Fehler ab und wandelt sie in typisierte Exceptions um:
// core/network/api_client.dart
class ApiClient {
final Dio _dio;
ApiClient(this._dio);
Future<T> get<T>(String path, {T Function(dynamic)? fromJson}) async {
try {
final response = await _dio.get(path);
return fromJson != null ? fromJson(response.data) : response.data as T;
} on DioException catch (e) {
throw _mapError(e);
}
}
AppException _mapError(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return const NetworkException('Connection timed out');
}
final statusCode = error.response?.statusCode;
if (statusCode == null) return const NetworkException();
return switch (statusCode) {
401 => const AuthException('Session expired'),
403 => const AuthException('Access denied'),
422 => ValidationException(
_parseValidationErrors(error.response?.data),
),
_ => ServerException(statusCode, 'Server error'),
};
}
}
Testing-Architektur
Gute Architektur macht Testing unkompliziert. Jede Schicht hat ihre eigene Testing-Strategie:
Repository-Tests
void main() {
late AuthRepository repository;
late MockAuthRemoteSource mockRemote;
late MockAuthLocalSource mockLocal;
setUp(() {
mockRemote = MockAuthRemoteSource();
mockLocal = MockAuthLocalSource();
repository = AuthRepository(
remoteSource: mockRemote,
localSource: mockLocal,
);
});
group('getCurrentUser', () {
test('returns cached user when available', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => user);
final result = await repository.getCurrentUser();
expect(result, equals(user));
verifyNever(() => mockRemote.fetchCurrentUser());
});
test('fetches from remote when cache is empty', () async {
final user = User(id: '1', name: 'Test');
when(() => mockLocal.getCachedUser()).thenAnswer((_) async => null);
when(() => mockRemote.fetchCurrentUser()).thenAnswer((_) async => user);
when(() => mockLocal.cacheUser(any())).thenAnswer((_) async {});
final result = await repository.getCurrentUser();
expect(result, equals(user));
verify(() => mockLocal.cacheUser(user)).called(1);
});
});
}
Provider-Tests mit Riverpod
void main() {
test('signIn updates state to authenticated', () async {
final user = User(id: '1', name: 'Test');
final mockRepository = MockAuthRepository();
when(() => mockRepository.signIn(any(), any()))
.thenAnswer((_) async => user);
final container = ProviderContainer(
overrides: [
authRepositoryProvider.overrideWithValue(mockRepository),
],
);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.signIn('test@test.com', 'password');
final state = container.read(authNotifierProvider);
expect(state, AuthState.authenticated(user));
});
}
Riverpods ProviderContainer und Override-Mechanismus macht Testing sauber — Sie überschreiben Abhängigkeiten auf Container-Ebene statt auf Widget-Baum-Ebene.
Praktische Kompromisse
Diese Architektur ist nicht kostenlos. Es gibt reale Kosten:
Mehr Dateien. Ein Feature, das in einer Tutorial-App eine Datei wäre, kann in dieser Struktur fünf bis acht Dateien umfassen. Für kleine Apps (unter zehn Screens) kann sich das übertrieben anfühlen.
Lernkurve. Ein Entwickler, der neu im Projekt ist, muss die Schichtgrenzen verstehen und wissen, wo Dinge hingehören. Dokumentation hilft, aber es gibt dennoch Einarbeitungszeit.
Code-Generierung. Riverpods Annotation-System, Freezed für unveränderliche Models und JSON-Serialisierung erfordern alle build_runner. Build-Generierung fügt einen Schritt zur Entwicklung hinzu und kann bei großen Codebasen langsam sein.
Versuchung zur Über-Abstraktion. Wenn Sie eine saubere Architektur haben, ist es verlockend, Abstraktionen "für den Fall" hinzuzufügen. Ein Repository-Interface für eine Datenquelle, die nur jemals eine Implementierung haben wird, fügt Komplexität ohne Nutzen hinzu. Ich erstelle Abstraktionen, wenn ich eine konkrete zweite Implementierung habe, nicht vorher.
Die Architektur lohnt sich, wenn Ihre App mehr als etwa zehn Screens hat, mehr als einen Entwickler oder eine Lebensdauer über einige Monate hinaus. Für ein Hackathon-Projekt oder einen Proof of Concept verwenden Sie das Einfachste, was funktioniert. Aber wenn Sie wissen, dass die App wachsen muss, spart die frühzeitige Investition in Struktur ein Vielfaches der späteren Kosten.
Danil Ulmashev
Full Stack Developer
Interesse an einer Zusammenarbeit?