Flutter Architecture Skill — Production Patterns
Triết lý: Flutter UI dễ, architecture mới khó. Skill này = state management + data flow + error handling + folder structure mà sẽ scale từ 1 màn → 100 màn không vỡ. Source: distilled từ lme-ui, lsite-mobile, itg-mobile production codebases.
1. AUTO-TRIGGER
DÙNG khi:
✓ Chạm file .dart KHÔNG phải UI widget (lib/data/, lib/domain/, lib/presentation/state/)
✓ File import: package:flutter_riverpod, package:dio, package:go_router, package:freezed_annotation
✓ User nói "viết repository/service/notifier/provider/route/interceptor/use case"
✓ User nói "state management/error handling/data layer/auth flow"
✓ Project có pubspec.yaml với riverpod/dio/go_router
KHÔNG dùng khi:
✗ UI widget styling/layout — dùng mobile-design hoặc lme-flutter
✗ iOS/Android native (Swift/Kotlin)
✗ React Native
2. BẢY LUẬT CỨNG
LUẬT 1 — Immutable models với Freezed
// ❌ SAI — mutable, hard to reason about
class User {
String name;
int age;
User(this.name, this.age);
}
// ✅ ĐÚNG — Freezed
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required int age,
@Default([]) List<String> roles,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// Usage
final updated = user.copyWith(name: 'New Name'); // creates new instance
Rules:
- Mọi data model: Freezed (
@freezed) HOẶC manual immutable vớicopyWith - Equality auto (Freezed handles
==andhashCode) - JSON serialization:
json_serializablepackage - KHÔNG mutate model fields trực tiếp
LUẬT 2 — State qua Riverpod, KHÔNG setState global
// ❌ SAI — global state qua singleton
class AuthService {
static User? currentUser;
}
// ✅ ĐÚNG — Riverpod provider
@riverpod
class AuthNotifier extends _$AuthNotifier {
@override
AsyncValue<User?> build() {
return const AsyncValue.data(null);
}
Future<void> login(String email, String password) async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final user = await ref.read(authRepositoryProvider).login(email, password);
return user;
});
}
void logout() {
state = const AsyncValue.data(null);
}
}
// Widget consume
final auth = ref.watch(authNotifierProvider);
Rules:
- Local widget state:
useState/StatefulWidget(OK) - Cross-widget state: Riverpod provider
- Global state: Riverpod provider (KHÔNG singleton, KHÔNG InheritedWidget tự build)
- Async state:
AsyncValue<T>(loading/data/error built-in) - Use
ref.watch(rebuild on change) vsref.read(one-time read) - Riverpod 2.0+: code generation với
@riverpodannotation
LUẬT 3 — Dio interceptor cho auth + retry + log
final dioProvider = Provider<Dio>((ref) {
final dio = Dio(BaseOptions(
baseUrl: ref.read(envProvider).apiUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 30),
));
// Auth interceptor — inject JWT
dio.interceptors.add(InterceptorsWrapper(
onRequest: (options, handler) async {
final token = await ref.read(secureStorageProvider).getToken();
if (token != null) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
},
onError: (error, handler) async {
// Handle 401 — refresh token
if (error.response?.statusCode == 401) {
final refreshed = await ref.read(authNotifierProvider.notifier).refresh();
if (refreshed) {
// Retry original request
final clone = await dio.fetch(error.requestOptions);
return handler.resolve(clone);
} else {
// Force logout
ref.read(authNotifierProvider.notifier).logout();
}
}
handler.next(error);
},
));
// Logging interceptor (debug only)
if (kDebugMode) {
dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: true));
}
// Retry interceptor (network failures)
dio.interceptors.add(RetryInterceptor(
dio: dio,
retries: 3,
retryDelays: const [Duration(seconds: 1), Duration(seconds: 2), Duration(seconds: 4)],
));
return dio;
});
Rules:
- 1 Dio instance, configured qua provider
- Auth interceptor inject JWT, handle 401 refresh
- Log interceptor CHỈ trong
kDebugMode - Retry với exponential backoff cho network errors
- Timeout config: connect 10s, receive 30s (adjust theo use case)
LUẬT 4 — GoRouter type-safe + guards
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}
@TypedGoRoute<OrderDetailRoute>(path: '/orders/:orderId')
class OrderDetailRoute extends GoRouteData {
const OrderDetailRoute({required this.orderId});
final String orderId;
@override
Widget build(BuildContext context, GoRouterState state) =>
OrderDetailScreen(orderId: orderId);
}
// Auth guard
final routerProvider = Provider<GoRouter>((ref) {
final auth = ref.watch(authNotifierProvider);
return GoRouter(
initialLocation: '/',
redirect: (context, state) {
final loggedIn = auth.value != null;
final loggingIn = state.matchedLocation == '/login';
if (!loggedIn && !loggingIn) return '/login';
if (loggedIn && loggingIn) return '/';
return null; // no redirect
},
routes: $appRoutes, // generated from @TypedGoRoute
);
});
// Navigate type-safely
const OrderDetailRoute(orderId: '123').go(context);
const OrderDetailRoute(orderId: '123').push(context);
Rules:
- Type-safe routes via
go_router_buildercodegen (@TypedGoRoute) - KHÔNG
Navigator.pushNamedvới string magic — dùng generated routes - Auth guard ở
redirect— observe Riverpod auth state - Deep link supported automatically với GoRouter
LUẬT 5 — Result type cho error handling
// ❌ SAI — exceptions thrown across layers
Future<User> getUser(String id) async {
final response = await dio.get('/users/$id');
if (response.statusCode != 200) throw Exception('Failed'); // bubble up
return User.fromJson(response.data);
}
// ✅ ĐÚNG — Result type
sealed class Result<T> {
const Result();
}
class Success<T> extends Result<T> {
final T data;
const Success(this.data);
}
class Failure<T> extends Result<T> {
final AppError error;
const Failure(this.error);
}
// Repository
Future<Result<User>> getUser(String id) async {
try {
final response = await dio.get('/users/$id');
return Success(User.fromJson(response.data));
} on DioException catch (e) {
return Failure(_mapDioError(e));
} catch (e, st) {
logger.error('Unexpected', error: e, stackTrace: st);
return Failure(AppError.unknown());
}
}
// Use case / notifier
final result = await ref.read(userRepoProvider).getUser('123');
switch (result) {
case Success(:final data):
state = AsyncValue.data(data);
case Failure(:final error):
state = AsyncValue.error(error, StackTrace.current);
}
Rules:
- Repository return
Result<T>HOẶC throw typed exception (AppError) - KHÔNG bubble Dio/DB exceptions up to UI
- AppError sealed class với specific cases (NetworkError, AuthError, ValidationError, ServerError)
- UI layer pattern match → display correct message
LUẬT 6 — Repository pattern (data sources behind interface)
// Domain layer (no dependencies)
abstract class OrderRepository {
Future<Result<List<Order>>> getOrders({int page = 1});
Future<Result<Order>> getOrder(String id);
Future<Result<Order>> createOrder(CreateOrderRequest request);
}
// Data layer (implementation)
class OrderRepositoryImpl implements OrderRepository {
final Dio _dio;
final OrderLocalDataSource _local;
final OrderRemoteDataSource _remote;
OrderRepositoryImpl(this._dio, this._local, this._remote);
@override
Future<Result<List<Order>>> getOrders({int page = 1}) async {
// Try cache first
final cached = await _local.getOrders();
if (cached.isNotEmpty) {
return Success(cached);
}
// Fetch remote
final result = await _remote.getOrders(page: page);
return result.map((orders) {
_local.cacheOrders(orders); // side-effect cache
return orders;
});
}
// ...
}
// Provider
final orderRepoProvider = Provider<OrderRepository>((ref) {
return OrderRepositoryImpl(
ref.read(dioProvider),
ref.read(orderLocalDataSourceProvider),
ref.read(orderRemoteDataSourceProvider),
);
});
Rules:
- Repository = interface in domain layer
- Implementation in data layer
- DataSource: chia local (Hive/SharedPrefs) vs remote (Dio)
- Repository orchestrate: cache strategy, error mapping, transformation
- Notifier/use case CHỈ depend vào repository interface
LUẬT 7 — Feature-based folder structure
lib/
├── main.dart
├── app.dart # MaterialApp.router với GoRouter
├── core/
│ ├── env/ # Env config provider
│ ├── network/ # Dio provider, interceptors
│ ├── error/ # AppError sealed class
│ ├── result/ # Result<T> type
│ ├── theme/ # ThemeData
│ ├── router/ # GoRouter config + guards
│ └── storage/ # SecureStorage, Hive setup
├── features/
│ ├── auth/
│ │ ├── data/
│ │ │ ├── datasources/ # AuthRemoteDataSource, AuthLocalDataSource
│ │ │ ├── models/ # AuthDto, UserDto (Freezed + JSON)
│ │ │ └── repositories/ # AuthRepositoryImpl
│ │ ├── domain/
│ │ │ ├── entities/ # User entity (pure Dart)
│ │ │ ├── repositories/ # AuthRepository abstract
│ │ │ └── usecases/ # LoginUseCase, LogoutUseCase
│ │ └── presentation/
│ │ ├── providers/ # AuthNotifier (Riverpod)
│ │ ├── screens/ # LoginScreen, RegisterScreen
│ │ └── widgets/ # LoginForm, AuthButton
│ ├── orders/
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ └── ...
└── shared/
├── widgets/ # Reusable: AppButton, AppTextField
├── extensions/ # Context extensions, Date extensions
└── constants/ # AppColors (or use theme), AppDurations
Rules:
- Feature-based, KHÔNG layer-based (avoid
lib/screens/,lib/services/flat) - Mỗi feature có data/domain/presentation
core/cho cross-cutting concerns (network, theme, error)shared/cho widgets/utils dùng cross-feature- Riverpod providers gần consumer (presentation/providers/) thay vì global
3. PATTERNS BẮT BUỘC
Async UI states
final ordersAsync = ref.watch(ordersNotifierProvider);
return ordersAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => ErrorView(
error: error,
onRetry: () => ref.invalidate(ordersNotifierProvider),
),
data: (orders) {
if (orders.isEmpty) return const EmptyOrdersView();
return ListView.builder(
itemCount: orders.length,
itemBuilder: (_, i) => OrderTile(order: orders[i]),
);
},
);
Mọi screen consume async data PHẢI handle 4 states: loading / error / empty / data.
Form validation
final emailFieldProvider = StateProvider.autoDispose<String>((_) => '');
class LoginScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final email = ref.watch(emailFieldProvider);
final isValid = _validateEmail(email);
return TextFormField(
onChanged: (v) => ref.read(emailFieldProvider.notifier).state = v,
validator: (v) => isValid ? null : 'Email không hợp lệ',
decoration: const InputDecoration(labelText: 'Email'),
);
}
}
Pagination với pull-to-refresh + infinite scroll
@riverpod
class OrdersNotifier extends _$OrdersNotifier {
int _currentPage = 1;
bool _hasMore = true;
final List<Order> _orders = [];
@override
Future<List<Order>> build() async {
return _loadPage(1);
}
Future<List<Order>> _loadPage(int page) async {
final repo = ref.read(orderRepoProvider);
final result = await repo.getOrders(page: page);
return switch (result) {
Success(:final data) => data,
Failure(:final error) => throw error,
};
}
Future<void> loadMore() async {
if (!_hasMore) return;
_currentPage++;
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final newOrders = await _loadPage(_currentPage);
_hasMore = newOrders.isNotEmpty;
_orders.addAll(newOrders);
return _orders;
});
}
Future<void> refresh() async {
_currentPage = 1;
_orders.clear();
state = const AsyncLoading();
state = await AsyncValue.guard(() => _loadPage(1));
}
}
Dependency injection hierarchy
// Bottom layer: pure functions / config
final envProvider = Provider<Env>((ref) => Env.fromEnv());
// Network layer
final dioProvider = Provider<Dio>((ref) { ... });
// Storage layer
final secureStorageProvider = Provider<SecureStorage>((ref) { ... });
// Data sources
final orderRemoteDsProvider = Provider<OrderRemoteDataSource>((ref) {
return OrderRemoteDataSource(ref.read(dioProvider));
});
// Repositories
final orderRepoProvider = Provider<OrderRepository>((ref) {
return OrderRepositoryImpl(ref.read(orderRemoteDsProvider), ...);
});
// State / Notifiers (consume repos)
final ordersNotifierProvider = AsyncNotifierProvider<...>(...);
Hierarchy: env → network → storage → data sources → repos → notifiers → UI.
4. ERROR HANDLING
sealed class AppError {
const AppError();
String get userMessage;
}
class NetworkError extends AppError {
final String detail;
const NetworkError(this.detail);
@override
String get userMessage => 'Lỗi kết nối. Vui lòng thử lại.';
}
class AuthError extends AppError {
final String reason;
const AuthError(this.reason);
@override
String get userMessage => 'Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.';
}
class ValidationError extends AppError {
final Map<String, String> fields; // field name → error message
const ValidationError(this.fields);
@override
String get userMessage => 'Dữ liệu không hợp lệ.';
}
class ServerError extends AppError {
final int statusCode;
final String message;
const ServerError(this.statusCode, this.message);
@override
String get userMessage => message.isNotEmpty ? message : 'Lỗi máy chủ. Vui lòng thử lại sau.';
}
class UnknownError extends AppError {
const UnknownError();
@override
String get userMessage => 'Đã có lỗi xảy ra.';
}
// Mapping from Dio
AppError _mapDioError(DioException e) {
return switch (e.type) {
DioExceptionType.connectionTimeout || DioExceptionType.receiveTimeout
=> const NetworkError('Timeout'),
DioExceptionType.connectionError
=> const NetworkError('No internet'),
DioExceptionType.badResponse => switch (e.response?.statusCode) {
401 => AuthError(e.response?.data['detail'] ?? 'Unauthorized'),
422 => ValidationError(_parseValidationErrors(e.response?.data)),
>= 500 => ServerError(
e.response?.statusCode ?? 500,
e.response?.data['detail'] ?? '',
),
_ => ServerError(
e.response?.statusCode ?? 0,
e.response?.data['detail'] ?? '',
),
},
_ => const UnknownError(),
};
}
5. TESTING
// Unit test notifier với mocked repo
test('login success updates state', () async {
final mockRepo = MockAuthRepository();
when(() => mockRepo.login('test@example.com', 'pass'))
.thenAnswer((_) async => Success(testUser));
final container = ProviderContainer(overrides: [
authRepositoryProvider.overrideWithValue(mockRepo),
]);
final notifier = container.read(authNotifierProvider.notifier);
await notifier.login('test@example.com', 'pass');
expect(container.read(authNotifierProvider).valueOrNull, testUser);
});
// Widget test
testWidgets('LoginScreen shows error on auth failure', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
authNotifierProvider.overrideWith(() =>
FakeAuthNotifier(initialState: const AsyncValue.error(
AuthError('Wrong password'), StackTrace.empty,
))),
],
child: const MaterialApp(home: LoginScreen()),
),
);
expect(find.text('Phiên đăng nhập hết hạn. Vui lòng đăng nhập lại.'), findsOneWidget);
});
6. ANTI-PATTERNS
| ✗ Sai | ✓ Đúng | Lý do |
|---|---|---|
setState cho global state |
Riverpod provider | Tied to widget lifecycle |
| Multiple Dio instances | 1 Dio qua provider | Hard to add interceptor consistently |
Navigator.pushNamed('/route') |
TypedGoRoute generated | Type-safe + refactor-safe |
| Throw exception across layers | Result hoặc AppError typed | UI never sees Dio/DB exceptions |
| Service singleton | Riverpod provider | Testable + scoped |
| Mutable model fields | Freezed + copyWith | Predictable state |
print() debug |
logger với level | Filter / disable production |
| HTTP call trong widget | Notifier + repository | Separation of concerns |
| Same widget loading + data + error | when/match 4 states | UI predictable |
| Fetch trong initState | Notifier auto-build | Provider handles lifecycle |
| Hardcoded strings | l10n / app_en.arb | i18n ready |
| Magic numbers | Constants/Theme | Maintainable |
| Material Icons mọi nơi | Custom icon set / LME | Brand consistency |
7. SELF-CHECK TRƯỚC COMMIT
□ flutter analyze pass (no warnings)?
□ Mọi async function return Future<Result<T>> hoặc throw AppError typed?
□ Provider hierarchy đúng (no circular dep)?
□ ref.watch trong build, ref.read trong callback?
□ Dispose properly (autoDispose providers, controller dispose)?
□ Error states handled trong UI (when/match)?
□ Loading state có CircularProgressIndicator hoặc skeleton?
□ Empty state có user guidance?
□ JSON serialization tested (fromJson/toJson roundtrip)?
□ No print() — use logger?
□ No hardcoded strings (use l10n)?
□ No setState cho cross-widget state — use provider?
8. REFERENCE FILES
| File | Khi nào đọc |
|---|---|
references/riverpod-patterns.md |
Specific Riverpod patterns: family, autoDispose, listen, invalidate |
references/dio-recipes.md |
Dio interceptors: auth refresh, retry, cache, multipart upload |
9. STARTER pubspec.yaml DEPS
dependencies:
flutter:
sdk: flutter
# State management
flutter_riverpod: ^2.5.0
riverpod_annotation: ^2.5.0
# Network
dio: ^5.4.0
# Routing
go_router: ^13.0.0
# Storage
flutter_secure_storage: ^9.0.0
shared_preferences: ^2.2.0
hive: ^2.2.3
hive_flutter: ^1.1.0
# Models
freezed_annotation: ^2.4.0
json_annotation: ^4.8.0
# Utils
logger: ^2.0.0
intl: ^0.19.0
dev_dependencies:
build_runner: ^2.4.0
riverpod_generator: ^2.4.0
freezed: ^2.4.0
json_serializable: ^6.7.0
go_router_builder: ^2.4.0
mocktail: ^1.0.0
flutter_test:
sdk: flutter
# Generate code (Freezed + Riverpod + GoRouter)
flutter pub run build_runner build --delete-conflicting-outputs
# Watch mode
flutter pub run build_runner watch --delete-conflicting-outputs