Flutter Architecture Patterns for Production Apps
The widget tree is not your architecture. Here's how to structure Flutter apps that don't collapse under their own weight.
Flutter's widget-first mental model is one of its greatest strengths for prototyping — and one of its greatest risks for production. When business logic leaks into widgets, you end up with a UI layer that's impossible to test, impossible to reason about, and impossible to hand off to another developer. This article documents the architecture patterns I've settled on after shipping Flutter apps to the Play Store.
folder_open Clean Architecture in Flutter
The folder structure communicates intent. I use a feature-first organization within a clean architecture shell:
lib/ ├── core/ # shared infra (router, DI, theme, HTTP) ├── features/ │ ├── auth/ │ │ ├── data/ # repository impl, remote/local sources │ │ ├── domain/ # entities, repository interface, use cases │ │ └── presentation/ # providers, screens, widgets │ └── home/ │ └── ... └── main.dart
The key rule: nothing in presentation/ imports from data/ directly. The domain layer defines interfaces; the data layer implements them. Dependency inversion means you can swap SQLite for Supabase without touching a single widget.
manage_accounts State Management with Riverpod 2
Riverpod's code-generation approach (@riverpod annotations) eliminates boilerplate while making providers statically analyzable. The pattern I use for async feature state:
// Domain: pure functions, no Flutter imports abstract class UserRepository { Future<List<User>> getUsers(); } // Presentation: AsyncNotifier for loading/error/data @riverpod class UsersNotifier extends AsyncNotifier<List<User>> { @override Future<List<User>> build() async { final repo = ref.read(userRepositoryProvider); return repo.getUsers(); } Future<void> refresh() async { state = const AsyncValue.loading(); state = await AsyncValue.guard(() => build()); } }
route Routing with GoRouter
Flutter's Navigator 2.0 is powerful but verbose. GoRouter gives you declarative, URL-based routing that works identically on mobile and web.
final router = GoRouter( initialLocation: '/', redirect: (ctx, state) { final authed = ctx.read(authProvider).isAuthenticated; if (!authed && !state.location.startsWith('/auth')) return '/auth/login'; return null; }, routes: [ GoRoute(path: '/', builder: (_, __) => const HomeScreen()), GoRoute(path: '/profile', builder: (_, __) => const ProfileScreen()), GoRoute(path: '/auth/login', builder: (_, __) => const LoginScreen()), ], );
deployed_code Immutable Data with Freezed
Mutable domain entities are a source of subtle bugs. Freezed generates immutable value objects with copyWith, pattern matching, and JSON serialization:
@freezed class User with _$User { const factory User({ required String id, required String name, required String email, @Default(false) bool isPremium, }) = _User; factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json); }
"A widget that owns state is a widget that you can't reuse without duplicating bugs."
Lift state up, keep widgets dumb, and your widget tree becomes a pure rendering function.
tips_and_updates Production Checklist
- Enable
--obfuscate --split-debug-infoon release builds — reduces binary size and protects logic - Use
flutter_native_splash+flutter_launcher_iconsfor consistent branding - Profile with Flutter DevTools before shipping — jank above 16ms is visible to users
- Set up Firebase Crashlytics from day one — you won't know about crashes unless you're listening
- Use
constconstructors everywhere possible — it's free performance