Flutter & Dart app architecture
The opinionated default stack for a production Flutter app: feature-first + layered folders,
Riverpod 3 with codegen for shared/async state, a typed go_router, freezed immutable
models, a dio data layer, and explicit Result<T, Failure> error modeling — all on Material 3.
Escape hatches are first-class: Bloc/Cubit instead of Riverpod when the team already runs Bloc,
and raw http/get_it are allowed — but pick one of each per app, never mix two. Pinned versions
this skill targets: Flutter 3.44 / Dart 3.12, Riverpod 3.0, go_router 17.2.x
(+ go_router_builder 4.3.x), freezed 3.x / json_serializable, dio 5.x, mocktail 1.x.
Boundaries
⚠️ SDD new-feature gate — read this first. If this skill fired on a new, non-trivial feature or behaviour change and there is no approved spec + plan under
02-DOCS/wiki/sdd/, STOP — do not write feature code yet. Hand off to../specify/SKILL.mdfirst: it runs brainstorm → spec → plan → tasks before any code, then routes back here once the plan is approved. Build here directly only for a genuinely one-line / low-risk change. Method:../sdd/SKILL.md.
This skill owns the pubspec.yaml subproject and nothing else in the repo. Hand off when the UI is
Compose Multiplatform (compose-multiplatform), SwiftUI/native iOS (swift-ios) or React Native
(react-native); when the work is on a FastAPI/Go/Next.js sibling in the same monorepo (use that
skill). For a pure Dart server/CLI with no widget tree, general Dart applies but skip the
UI/nav/perf references. For a single-file throwaway sample, say architecture is overkill and do not
impose layering.
Around the edges: harness owns the workspace 01-TOOLS/02-DOCS layer and flavor secrets; fastapi,
go and nextjs build the backends this app talks to; secure-coding reviews token handling and
deep-link validation; deployment handles store/CI release; design owns the Material 3 token system.
Decision rules
| Situation | Do this | Not that |
|---|---|---|
| Ephemeral UI state (checkbox, slider, anim) | setState / ValueNotifier locally | a global provider |
| Shared / async state | Riverpod @riverpod Notifier/AsyncNotifier | scattered setState across pages |
| Team already on Bloc | Cubit (simple) / Bloc (event-sourced) | mixing Bloc + Riverpod in one app |
| Multi-state async | AsyncValue / sealed state | bool isLoading + bool isError flags |
| Navigation | one typed go_router | mixing Navigator.push with declarative routes |
| Errors at domain boundary | Result<T, Failure> / sealed | leaking DioException / raw throw to UI |
| Models / DTOs | @freezed abstract class … with _$Name | hand-written mutable classes |
| Cross-feature data | repository behind an interface | widgets calling dio/DB directly |
Project layout
textlib/ main.dart # bootstrap (shared) main_dev.dart # flavored entrypoint -> runApp(const App(flavor: Flavor.dev)) main_prod.dart app.dart # MaterialApp.router + ProviderScope wiring src/ features/ cart/ presentation/ # widgets, screens, Riverpod consumers domain/ # entities, repository interfaces, Result/Failure (zero Flutter imports) data/ # DTOs, dio data sources, repository impls common/ router/ # typed go_router + guards theme/ # ColorScheme.fromSeed, ThemeExtension tokens network/ # dio client + interceptors errors/ # Result, Failure sealed types widgets/ # shared reusable widgets
Dependencies point inward — presentation → domain ← data; domain/ has zero Flutter imports.
See references/architecture-and-state.md for the full layering contract and a worked cart feature.
Dart 3.12 idioms
Null safety — never reach for !:
dart// BAD — bang crashes in prod when user is null final n = user!.name; // GOOD — null-aware + fallback final n = user?.name ?? 'Unknown'; // GOOD — if-case pattern promotes the binding if (user case User(:final name)?) { greet(name); } // GOOD — switch expression over a nullable is exhaustive final label = switch (user) { User(:final name) => name, null => 'Guest', };
late — only for guaranteed-before-first-access, prefer late final:
dart// BAD — defers a null error to runtime late String id; // OK — initialized in initState before any access late final AnimationController _c;
Records + destructuring for concurrent multi-return (parallel, not sequential):
dart// Runs both requests at once; .wait is the Dart 3 record concurrency extension. final (user, count) = await (repo.user(), repo.count()).wait;
Sealed + exhaustive switch eliminates impossible states:
dartsealed class JobState {} final class JobIdle extends JobState {} final class JobRunning extends JobState { const JobRunning(this.pct); final double pct; } final class JobDone extends JobState { const JobDone(this.url); final String url; } Widget build(JobState s) => switch (s) { JobIdle() => const Text('Idle'), JobRunning(:final pct) => LinearProgressIndicator(value: pct), JobDone(:final url) => Link(url), }; // compiler errors if a variant is unhandled
async-gap guard after every await that precedes a context/ref use:
dart// In a State<T>: await repo.save(); if (!context.mounted) return; context.go('/done'); // Inside a Notifier (Riverpod 3): await repo.save(); if (!ref.mounted) return; ref.invalidate(listProvider); // Fire-and-forget must be explicit, not a silently-dropped Future: unawaited(analytics.log('checkout'));
Streams belong in a StreamBuilder, never a manual .listen() in build:
dart// BAD — leaks a subscription on every rebuild @override Widget build(BuildContext context) { stream.listen(_onData); return const SizedBox(); }
Extension types give zero-cost ID type-safety so the compiler rejects raw strings:
dartextension type UserId(String value) {} extension type OrderId(String value) {} void loadUser(UserId id) { /* ... */ } // loadUser('o_42'); // BAD — compile error: String is not a UserId loadUser(const UserId('u_7')); // GOOD
Isolates push CPU-bound work off the UI thread:
dartfinal parsed = await Isolate.run(() => heavyParse(jsonBig));
Error modeling → references/architecture-and-state.md; isolates deep dive → references/performance.md.
State management: Riverpod 3 (default)
dart// Sync Notifier — list mutation. (Function providers for async reads and // AsyncNotifier guarded mutation -> references/architecture-and-state.md.) @riverpod class CartNotifier extends _$CartNotifier { @override List<CartItem> build() => const []; void add(CartItem item) => state = [...state, item]; void remove(String id) => state = state.where((i) => i.id != id).toList(); }
Render AsyncValue with an exhaustive switch; scope rebuilds with .select():
dartfinal view = switch (ref.watch(productsProvider)) { AsyncData(:final value) => ProductList(value), AsyncError(:final error) => ErrorView(error), _ => const CircularProgressIndicator(), }; final count = ref.watch(cartNotifierProvider.select((items) => items.length));
ref.watch rebuilds on change; ref.read is for callbacks only; ref.listen is for side-effects.
Riverpod 3 unifies Notifier/AsyncNotifier, merges autoDispose/family into the single @riverpod
annotation, exposes one Ref type, and adds automatic retry, a Mutation API, and @Riverpod(keepAlive: true).
Legacy StateProvider/ChangeNotifierProvider live in package:riverpod/legacy.dart — not for new code.
Wrap the app root in ProviderScope. Codegen, Mutation, family-as-arg, persistence and the DI graph →
references/architecture-and-state.md. Testing → references/testing.md.
State management: Bloc/Cubit (the alternative)
Cubit for simple state, Bloc (event → state) for complex/event-sourced flows.
dartsealed class AuthState {} final class AuthInitial extends AuthState {} final class AuthLoading extends AuthState {} final class AuthAuthed extends AuthState { const AuthAuthed(this.user); final User user; } final class AuthFailed extends AuthState { const AuthFailed(this.message); final String message; } class AuthCubit extends Cubit<AuthState> { AuthCubit(this._repo) : super(AuthInitial()); final AuthRepository _repo; Future<void> login(String email, String password) async { emit(AuthLoading()); final res = await _repo.login(email, password); emit(res.fold((u) => AuthAuthed(u), (f) => AuthFailed(f.message))); } } // UI: BlocBuilder<AuthCubit, AuthState>( builder: (context, state) => switch (state) { AuthInitial() || AuthLoading() => const CircularProgressIndicator(), AuthAuthed(:final user) => HomeView(user), AuthFailed(:final message) => ErrorView(message), }, );
dart// BAD — a Bloc that depends on another Bloc CartBloc(this.authBloc); // GOOD — share the repository, not the Bloc CartBloc(this.cartRepo);
Pick one per app, never both. Full event-driven Bloc, BlocObserver, and hydrated_bloc →
references/architecture-and-state.md.
UI & navigation (essentials)
- Extract widgets to classes, not
_build*()methods — enablesconst, element reuse andRepaintBoundarygranularity. Useconsteverywhere;ValueKeyin lists, neverUniqueKeyinbuild. - Material 3 theming from a seed; read tokens via
Theme.of(context):
dartfinal theme = ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF6750A4), brightness: Brightness.light), ); // BAD color: Colors.blue // GOOD color: Theme.of(context).colorScheme.primary
- Typed go_router skeleton:
dart@TypedGoRoute<HomeRoute>(path: '/', routes: [TypedGoRoute<DetailRoute>(path: 'detail/:id')]) class HomeRoute extends GoRouteData with $HomeRoute { const HomeRoute(); @override Widget build(BuildContext context, GoRouterState state) => const HomeScreen(); } final router = GoRouter( routes: $appRoutes, refreshListenable: authListenable, redirect: (context, state) => authGuard(context, state), ); const DetailRoute(id: '7').go(context); // typed navigation, no magic strings
Slivers, adaptive/responsive, deep links, StatefulShellRoute, design tokens and a11y →
references/ui-and-navigation.md.
Data layer
dartfinal dio = Dio(BaseOptions( baseUrl: const String.fromEnvironment('API_URL'), connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), )); dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final token = await secureStorage.read(key: 'auth_token'); if (token != null) options.headers['Authorization'] = 'Bearer $token'; handler.next(options); }, onError: (error, handler) async { final isRetry = error.requestOptions.extra['_isRetry'] == true; // one-shot guard if (!isRetry && error.response?.statusCode == 401 && await refreshToken()) { error.requestOptions.extra['_isRetry'] = true; return handler.resolve(await dio.fetch(error.requestOptions)); } handler.next(error); }, ));
dart// GOOD — boundary returns a mapped Result; UI cannot crash on a wire error Future<Result<Cart, Failure>> getCart(); // BAD — leaks DioException into widgets Future<Cart> getCart(); // throws DioException to the UI
DTOs are freezed/json_serializable and mapped via CartDto.toDomain(); DTO ≠ entity. Full
repository + Result/Failure + caching → references/architecture-and-state.md.
Testing (gate)
dart// Unit — Riverpod 3 container helper. final container = ProviderContainer.test(); final cart = container.read(cartNotifierProvider); // Widget — override the controller with a fake. await tester.pumpWidget(ProviderScope( overrides: [cartControllerProvider.overrideWith(FakeCartController.new)], child: const MaterialApp(home: CartScreen()), )); // Golden — deterministic pixel comparison. await expectLater(find.byType(CartCard), matchesGoldenFile('goldens/cart_card.png'));
Every async state transition has a test (loading → data, loading → error). pumpAndSettle hangs on
infinite animations (spinners) — use an explicit pump(const Duration(milliseconds: 300)) there. Full
pyramid, repository tests, blocTest, golden determinism and coverage → references/testing.md.
Performance (essentials)
const+ extract-to-class so only the changing subtree rebuilds.RepaintBoundaryaround independently-animating subtrees;ListView.builderfor long lists.cacheWidth/cacheHeightto decode-at-size; cached network images with placeholder/error.- Scoped consumers via
.select()/BlocSelector/buildWhen. - Profile in
flutter run --profile; DevTools → "Track Widget Rebuilds", raster vs UI thread.
Rebuild/paint/jank workflow, isolates and build flavors → references/performance.md.
Localization & dependency hygiene (essentials)
- l10n via first-party
flutter_localizations+gen_l10n(setgenerate: true, addl10n.yaml); one ARB file per locale, strings read type-safely throughAppLocalizations.of(context). - Plurals/genders use ICU syntax inside the ARB (
{count, plural, =0{…} =1{…} other{…}}), never anif (count == 1)ladder in Dart. - RTL: use
EdgeInsetsDirectional/AlignmentDirectional(auto-mirrors); mirror directional icons, never logos or numbers. Format numbers/dates/currency withintlNumberFormat/DateFormat(locale-aware), never by hand. - Before adding a dependency, check its pub points/popularity/last-publish on pub.dev; audit with
flutter pub outdated. In a multi-package repo, melos orchestrates bootstrap/scripts andpackage:encapsulation (public API vialib/<pkg>.dart, internals underlib/src/, enforced byimplementation_imports).
ARB + ICU plurals, RTL geometry, locale-aware formatting, pub points/pana, melos and workspace
encapsulation → references/i18n-and-dependencies.md.
Production checklist
FlutterError.onError+PlatformDispatcher.instance.onError+ErrorWidget.builderwired to Crashlytics/Sentry.- Secrets via
--dart-define/--dart-define-from-file; tokens in secure storage (Keychain / EncryptedSharedPreferences), never plaintext. - HTTPS only.
- Strict
analysis_options.yaml:strict-casts/strict-inference/strict-raw-types+flutter_lintsorvery_good_analysis. - l10n via
flutter_localizations+ ARB (ICU plurals, RTL-safe geometry, locale-awareintlformatting); a11y (48px targets,Semantics, contrast ≥ 4.5:1). - Dependency hygiene:
pubspec.lockcommitted for apps,flutter pub outdatedaudited on a cadence, dependencies vetted by pub points before adding. - No
print()→dart:developerlog(). - Gate the branch with
scripts/verify.sh, run inside the Flutter project (format / codegen / analyze / tests).
Anti-patterns
| Anti-pattern | Why it fails / do instead |
|---|---|
user! to unwrap | bang crashes in prod; use ?./?? or an if-case pattern. |
_buildHeader() helper methods | extract to a const widget class — enables element reuse + const propagation. |
setState at the top of the page | rebuilds the whole subtree; scope it or .select(). |
Navigator.push mixed into go_router for one screen | one router; mixing breaks deep links + back stack. |
context used after an await | guard context.mounted / ref.mounted; a stale context crashes. |
hardcoded Colors.blue | use colorScheme; hardcoding breaks dark mode + theming. |
ListView(children: [...]) for a feed | use .builder; the concrete form builds all children eagerly. |
catch (e) on everything | use on-typed clauses; never catch Error (it is a bug). |
raw DioException.toString() shown to the user | map to a Failure with a localized message. |
print() for logging | use dart:developer log() — has levels and can be filtered. |
Project grounding (02-DOCS)
In a project with a 02-DOCS/ layer (the harness wiki), this app's decisions
live in 02-DOCS/wiki/stack/flutter.md, indexed in 02-DOCS/wiki/index.md. Read it first and stay
consistent. Missing or stale? Write the real choices there — state management (Riverpod/Bloc), the
architecture layers, routing, the Material 3 token system, codegen setup — index it, and bump its
Updated date in the same change a convention changes, so the next agent inherits it instead of
re-deriving it. No 02-DOCS/ layer? Skip silently: technical conventions are recorded, not gated,
so never block the task on this.


