Flutter App Architecture That Scales Beyond the Tutorial
Production Flutter architecture patterns — from state management to navigation, dependency injection, and feature-first project structure.

Tutorial Flutter apps work great until they reach about twenty screens. Then the single main.dart file with all the routes becomes unmanageable, the setState calls create race conditions, the "just pass it down through the constructor" approach produces widget trees with ten parameters, and every new feature touches five existing files. I have watched this happen on multiple projects, including my own, and the pattern is always the same — the team hits a wall around month three and has to either restructure or accept increasing development velocity loss.
The architecture I describe here is what I use for production apps. It is not the simplest possible approach, and that is the point. The simplest approach is what tutorials teach, and it stops working the moment your app has real requirements.
Why Tutorial Architecture Fails
Most Flutter tutorials and courses teach a flat project structure:
lib/
├── main.dart
├── home_screen.dart
├── detail_screen.dart
├── user_model.dart
├── api_service.dart
└── widgets/
├── custom_button.dart
└── loading_indicator.dart
This works for a todo app. It fails for a real app for predictable reasons:
Files grow without natural boundaries. When home_screen.dart has the screen layout, the state management, the API calls, and the business logic, it reaches 500+ lines quickly. There is no structural guidance about what should be extracted and where it should go.
Dependencies become implicit. When home_screen.dart directly instantiates ApiService(), there is no way to swap it for a mock in tests, no way to configure it differently per environment, and no way to share a single instance across screens that need the same data.
Navigation becomes a web of hardcoded routes. Deep links, authentication guards, and conditional flows all get bolted onto a flat route list. By the time you have thirty routes with guards and redirects, the navigation code is the most fragile part of the app.
Testing becomes impractical. Without dependency injection, widget tests require mocking at the HTTP level rather than the service level. Without clear separation of business logic from UI, you end up testing implementation details rather than behavior.
Feature-First Project Structure
The most impactful architectural decision is folder structure. I use a feature-first approach where each feature owns its screens, widgets, state, and models:
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
The Rules
Features never import from other features. If feature A needs data from feature B, that data lives in a shared model or a shared service, not in feature B's folder. This prevents circular dependencies and keeps features independently deployable (useful if you ever move to a modular monorepo).
The core/ directory contains infrastructure. Networking, error handling, constants, utilities — things that every feature might need but that do not belong to any single feature.
The shared/ directory contains reusable UI and cross-cutting concerns. Shared widgets, shared models, and providers that multiple features depend on.
Barrel files expose the public API of each feature. Other parts of the app import from features/auth/auth.dart, never from internal files. This lets you refactor the internal structure of a feature without breaking external consumers.
// 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
I have used Provider, Bloc, and Riverpod on production projects. Each has trade-offs, and the choice depends more on team familiarity than technical superiority. That said, I reach for Riverpod on new projects for specific reasons.
Why Riverpod Over Bloc
Bloc is excellent for teams coming from an enterprise background. The event/state pattern is familiar to developers who have worked with Redux or MVI. It enforces a strict unidirectional data flow and generates predictable, testable state machines. The downside is verbosity — a simple feature requires an event class, a state class, and a bloc class, often across three files. For apps with complex business logic and multiple developers, this ceremony pays off. For smaller teams moving fast, it can feel like overhead.
Provider (the original package) works well for simple dependency injection but struggles with complex state. It depends on the widget tree for scoping, which means your state's lifecycle is tied to your UI structure. Refactoring the widget tree can break state management in subtle ways.
Riverpod solves Provider's fundamental limitations. Providers are global (not widget-tree dependent), compile-time safe, and support advanced patterns like auto-disposal, family providers, and async state. It also generates less boilerplate than Bloc while maintaining testability.
Riverpod in Practice
Here is how I structure state management for a typical 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();
}
}
Using @riverpod annotation with code generation eliminates the manual provider declaration boilerplate. Run dart run build_runner build to generate the provider code.
When Bloc Is Better
Despite my preference for Riverpod, there are scenarios where Bloc is the stronger choice:
- Large teams where enforced patterns prevent inconsistency.
- Complex state machines with many transitions — Bloc's event system makes these explicit and testable.
- When you need replay/undo functionality — Bloc's event log makes this straightforward.
- When the team already knows Bloc. Consistency across the codebase matters more than any technical difference between the two.
Navigation with GoRouter
GoRouter has become the standard for Flutter navigation, and for good reason. It supports declarative routing, deep links, redirects, and nested navigation out of the box.
// 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
The redirect callback handles authentication guards globally. But you might need more complex logic — role-based access, onboarding flows, feature flags. I handle these with a redirect chain:
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
},
The Repository Pattern
Repositories sit between your business logic and data sources. They abstract away whether data comes from a REST API, a local database, a cache, or a combination of all three.
// 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();
}
}
Why Not Just Call the API Directly
Direct API calls from state management code create several problems:
- Caching logic bleeds into business logic. Do you check the cache before the API call? How long is the cache valid? This is data layer concern, not a state management concern.
- Offline support becomes a rewrite. If your Bloc directly calls
http.get, adding offline support means modifying every Bloc. With a repository, you add caching and fallback logic in one place. - Testing requires HTTP mocking. Mocking a repository is one mock per test. Mocking HTTP requires setting up response bodies, status codes, and headers for every test case.
Service Layer
For operations that span multiple repositories or involve complex business logic, I use a 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 contain business logic that does not belong in the UI or in any single repository. The state management layer (Riverpod notifier, Bloc) calls the service, and the service coordinates between repositories.
Error Handling
Consistent error handling is one of the most neglected aspects of Flutter architecture. I use a combination of typed exceptions and a result type.
// 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');
}
The API client catches HTTP errors and converts them to typed exceptions:
// 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 Architecture
Good architecture makes testing straightforward. Each layer has its own testing strategy:
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 with 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));
});
}
Riverpod's ProviderContainer and override mechanism makes testing clean — you override dependencies at the container level rather than at the widget tree level.
Practical Trade-offs
This architecture is not free. There are real costs:
More files. A feature that would be one file in a tutorial app might be five to eight files in this structure. For small apps (under ten screens), this can feel like overkill.
Learning curve. A developer new to the project needs to understand the layer boundaries and where to put things. Documentation helps, but there is still ramp-up time.
Code generation. Riverpod's annotation system, Freezed for immutable models, and JSON serialization all require build_runner. Build generation adds a step to development and can be slow on large codebases.
Temptation to over-abstract. When you have a clean architecture, it is easy to add abstractions "just in case." A repository interface for a data source that will only ever have one implementation adds complexity without value. I create abstractions when I have a concrete second implementation, not before.
The architecture is worth it when your app has more than about ten screens, more than one developer, or a lifespan beyond a few months. For a hackathon project or a proof of concept, use the simplest thing that works. But when you know the app needs to grow, investing in structure early saves multiples of the cost later.
Danil Ulmashev
Full Stack Developer
Need a senior developer to build something like this for your business?