Flutter Architecture Patterns Guide
A comprehensive guide to Flutter architecture patterns with detailed explanations, use cases, and practical examples. This guide covers MVVM (recommended by Flutter team), MVI, Clean Architecture, and other important patterns.
Table of Contents
Understanding Architecture Patterns
What are Architecture Patterns?
Architecture patterns are predefined solutions to common software design problems. They provide a structured approach to organizing code, separating concerns, and making applications more maintainable, testable, and scalable.
Why Use Architecture Patterns in Flutter?
Benefits:
- Separation of Concerns: Clear boundaries between different parts of the app
- Testability: Easier to write unit tests and integration tests
- Maintainability: Code is easier to understand and modify
- Scalability: Easier to add new features and handle growth
- Team Collaboration: Consistent patterns across the team
Flutter Team Recommendations
The Flutter team recommends MVVM (Model-View-ViewModel) as the primary architecture pattern for Flutter applications, as it aligns well with Flutter's reactive programming model and widget-based UI system.
MVVM (Model-View-ViewModel)
Understanding MVVM
MVVM is an architectural pattern that separates the UI (View) from the business logic (ViewModel) and data (Model). It's particularly well-suited for Flutter due to its reactive nature.
Components:
- Model: Data and business logic
- View: UI components (Widgets)
- ViewModel: State management and UI logic
MVVM with ChangeNotifier
// Model
class User {
final String id;
final String name;
final String email;
User({
required this.id,
required this.name,
required this.email,
});
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
email: json['email'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'email': email,
};
}
}
// Repository (Data Layer)
abstract class UserRepository {
Future<User> getUser(String id);
Future<List<User>> getUsers();
Future<void> saveUser(User user);
}
class UserRepositoryImpl implements UserRepository {
Future<User> getUser(String id) async {
// Simulate API call
await Future.delayed(Duration(seconds: 1));
return User(id: id, name: 'John Doe', email: 'john@example.com');
}
Future<List<User>> getUsers() async {
// Simulate API call
await Future.delayed(Duration(seconds: 1));
return [
User(id: '1', name: 'John Doe', email: 'john@example.com'),
User(id: '2', name: 'Jane Smith', email: 'jane@example.com'),
];
}
Future<void> saveUser(User user) async {
// Simulate API call
await Future.delayed(Duration(seconds: 1));
print('Saving user: ${user.name}');
}
}
// ViewModel
class UserViewModel extends ChangeNotifier {
final UserRepository _repository;
UserViewModel(this._repository);
User? _user;
List<User> _users = [];
bool _isLoading = false;
String? _error;
// Getters
User? get user => _user;
List<User> get users => _users;
bool get isLoading => _isLoading;
String? get error => _error;
bool get hasError => _error != null;
// Actions
Future<void> loadUser(String id) async {
_setLoading(true);
_clearError();
try {
_user = await _repository.getUser(id);
notifyListeners();
} catch (e) {
_setError(e.toString());
} finally {
_setLoading(false);
}
}
Future<void> loadUsers() async {
_setLoading(true);
_clearError();
try {
_users = await _repository.getUsers();
notifyListeners();
} catch (e) {
_setError(e.toString());
} finally {
_setLoading(false);
}
}
Future<void> saveUser(User user) async {
_setLoading(true);
_clearError();
try {
await _repository.saveUser(user);
_user = user;
notifyListeners();
} catch (e) {
_setError(e.toString());
} finally {
_setLoading(false);
}
}
// Private methods
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void _setError(String error) {
_error = error;
notifyListeners();
}
void _clearError() {
_error = null;
notifyListeners();
}
}
// View
class UserProfileScreen extends StatelessWidget {
final String userId;
const UserProfileScreen({Key? key, required this.userId}) : super(key: key);
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => UserViewModel(
UserRepositoryImpl(),
)..loadUser(userId),
child: Scaffold(
appBar: AppBar(title: Text('User Profile')),
body: Consumer<UserViewModel>(
builder: (context, viewModel, child) {
if (viewModel.isLoading) {
return Center(child: CircularProgressIndicator());
}
if (viewModel.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${viewModel.error}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => viewModel.loadUser(userId),
child: Text('Retry'),
),
],
),
);
}
final user = viewModel.user;
if (user == null) {
return Center(child: Text('No user found'));
}
return Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Name: ${user.name}',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Email: ${user.email}',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Example of updating user
final updatedUser = User(
id: user.id,
name: 'Updated ${user.name}',
email: user.email,
);
viewModel.saveUser(updatedUser);
},
child: Text('Update User'),
),
],
),
);
},
),
),
);
}
}
MVVM with Cubit
// User State
abstract class UserState {
final User? user;
final bool isLoading;
final String? error;
const UserState({
this.user,
this.isLoading = false,
this.error,
});
}
class UserInitial extends UserState {
const UserInitial() : super();
}
class UserLoading extends UserState {
const UserLoading() : super(isLoading: true);
}
class UserLoaded extends UserState {
const UserLoaded(User user) : super(user: user);
}
class UserError extends UserState {
const UserError(String error) : super(error: error);
}
// User Cubit (ViewModel)
class UserCubit extends Cubit<UserState> {
final UserRepository _repository;
UserCubit(this._repository) : super(const UserInitial());
Future<void> loadUser(String id) async {
emit(const UserLoading());
try {
final user = await _repository.getUser(id);
emit(UserLoaded(user));
} catch (e) {
emit(UserError(e.toString()));
}
}
Future<void> saveUser(User user) async {
emit(const UserLoading());
try {
await _repository.saveUser(user);
emit(UserLoaded(user));
} catch (e) {
emit(UserError(e.toString()));
}
}
}
// View with Cubit
class UserProfileScreenWithCubit extends StatelessWidget {
final String userId;
const UserProfileScreenWithCubit({Key? key, required this.userId}) : super(key: key);
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => UserCubit(UserRepositoryImpl())..loadUser(userId),
child: Scaffold(
appBar: AppBar(title: Text('User Profile (Cubit)')),
body: BlocBuilder<UserCubit, UserState>(
builder: (context, state) {
if (state is UserLoading) {
return Center(child: CircularProgressIndicator());
}
if (state is UserError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${state.error}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.read<UserCubit>().loadUser(userId),
child: Text('Retry'),
),
],
),
);
}
if (state is UserLoaded) {
final user = state.user;
return Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Name: ${user.name}',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Email: ${user.email}',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
final updatedUser = User(
id: user.id,
name: 'Updated ${user.name}',
email: user.email,
);
context.read<UserCubit>().saveUser(updatedUser);
},
child: Text('Update User'),
),
],
),
);
}
return Center(child: Text('No user found'));
},
),
),
);
}
}
MVI (Model-View-Intent)
Understanding MVI
MVI is a reactive architecture pattern that emphasizes unidirectional data flow and immutable state. It's particularly good for complex state management and predictable state changes.
Components:
- Model: Immutable state
- View: UI that renders state and sends intents
- Intent: User actions or system events
MVI Implementation
// Intent (User Actions)
abstract class UserIntent {}
class LoadUserIntent extends UserIntent {
final String userId;
LoadUserIntent(this.userId);
}
class SaveUserIntent extends UserIntent {
final User user;
SaveUserIntent(this.user);
}
class RefreshUserIntent extends UserIntent {
final String userId;
RefreshUserIntent(this.userId);
}
// Model (Immutable State)
class UserModel {
final User? user;
final bool isLoading;
final String? error;
final List<String> actions; // For debugging/analytics
const UserModel({
this.user,
this.isLoading = false,
this.error,
this.actions = const [],
});
UserModel copyWith({
User? user,
bool? isLoading,
String? error,
List<String>? actions,
}) {
return UserModel(
user: user ?? this.user,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
actions: actions ?? this.actions,
);
}
UserModel addAction(String action) {
return copyWith(
actions: [...actions, action],
);
}
bool get hasError => error != null;
bool get hasUser => user != null;
}
// ViewModel (Intent Handler)
class UserViewModel extends ChangeNotifier {
final UserRepository _repository;
UserModel _model = const UserModel();
UserViewModel(this._repository);
UserModel get model => _model;
void processIntent(UserIntent intent) {
if (intent is LoadUserIntent) {
_loadUser(intent.userId);
} else if (intent is SaveUserIntent) {
_saveUser(intent.user);
} else if (intent is RefreshUserIntent) {
_refreshUser(intent.userId);
}
}
Future<void> _loadUser(String userId) async {
_updateModel(_model
.addAction('LoadUser')
.copyWith(isLoading: true, error: null));
try {
final user = await _repository.getUser(userId);
_updateModel(_model
.addAction('UserLoaded')
.copyWith(user: user, isLoading: false));
} catch (e) {
_updateModel(_model
.addAction('UserError')
.copyWith(error: e.toString(), isLoading: false));
}
}
Future<void> _saveUser(User user) async {
_updateModel(_model
.addAction('SaveUser')
.copyWith(isLoading: true, error: null));
try {
await _repository.saveUser(user);
_updateModel(_model
.addAction('UserSaved')
.copyWith(user: user, isLoading: false));
} catch (e) {
_updateModel(_model
.addAction('SaveError')
.copyWith(error: e.toString(), isLoading: false));
}
}
Future<void> _refreshUser(String userId) async {
_updateModel(_model.addAction('RefreshUser'));
await _loadUser(userId);
}
void _updateModel(UserModel newModel) {
_model = newModel;
notifyListeners();
}
}
// View
class UserProfileScreenMVI extends StatelessWidget {
final String userId;
const UserProfileScreenMVI({Key? key, required this.userId}) : super(key: key);
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => UserViewModel(UserRepositoryImpl())
..processIntent(LoadUserIntent(userId)),
child: Scaffold(
appBar: AppBar(
title: Text('User Profile (MVI)'),
actions: [
IconButton(
icon: Icon(Icons.refresh),
onPressed: () {
context.read<UserViewModel>().processIntent(
RefreshUserIntent(userId),
);
},
),
],
),
body: Consumer<UserViewModel>(
builder: (context, viewModel, child) {
final model = viewModel.model;
if (model.isLoading) {
return Center(child: CircularProgressIndicator());
}
if (model.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${model.error}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
viewModel.processIntent(LoadUserIntent(userId));
},
child: Text('Retry'),
),
],
),
);
}
if (model.hasUser) {
final user = model.user!;
return Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Name: ${user.name}',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Email: ${user.email}',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
final updatedUser = User(
id: user.id,
name: 'Updated ${user.name}',
email: user.email,
);
viewModel.processIntent(SaveUserIntent(updatedUser));
},
child: Text('Update User'),
),
SizedBox(height: 32),
// Debug: Show recent actions
Text(
'Recent Actions:',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
...model.actions.reversed.take(5).map((action) =>
Text('• $action', style: TextStyle(fontSize: 12))
),
],
),
);
}
return Center(child: Text('No user found'));
},
),
),
);
}
}
Clean Architecture
Understanding Clean Architecture
Clean Architecture is a software design philosophy that emphasizes separation of concerns and dependency inversion. It organizes code into layers with clear boundaries and dependencies.
Layers:
- Presentation Layer: UI and user interactions
- Domain Layer: Business logic and entities
- Data Layer: Data sources and repositories
Clean Architecture Implementation
// Domain Layer - Entities
class User {
final String id;
final String name;
final String email;
User({
required this.id,
required this.name,
required this.email,
});
}
// Domain Layer - Use Cases
abstract class GetUserUseCase {
Future<User> execute(String id);
}
abstract class GetUsersUseCase {
Future<List<User>> execute();
}
abstract class SaveUserUseCase {
Future<void> execute(User user);
}
class GetUserUseCaseImpl implements GetUserUseCase {
final UserRepository _repository;
GetUserUseCaseImpl(this._repository);
Future<User> execute(String id) async {
return await _repository.getUser(id);
}
}
class GetUsersUseCaseImpl implements GetUsersUseCase {
final UserRepository _repository;
GetUsersUseCaseImpl(this._repository);
Future<List<User>> execute() async {
return await _repository.getUsers();
}
}
class SaveUserUseCaseImpl implements SaveUserUseCase {
final UserRepository _repository;
SaveUserUseCaseImpl(this._repository);
Future<void> execute(User user) async {
await _repository.saveUser(user);
}
}
// Data Layer - Repository Interface
abstract class UserRepository {
Future<User> getUser(String id);
Future<List<User>> getUsers();
Future<void> saveUser(User user);
}
// Data Layer - Data Sources
abstract class UserRemoteDataSource {
Future<User> getUser(String id);
Future<List<User>> getUsers();
Future<void> saveUser(User user);
}
abstract class UserLocalDataSource {
Future<User?> getUser(String id);
Future<List<User>> getUsers();
Future<void> saveUser(User user);
}
// Data Layer - Repository Implementation
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource _remoteDataSource;
final UserLocalDataSource _localDataSource;
UserRepositoryImpl(this._remoteDataSource, this._localDataSource);
Future<User> getUser(String id) async {
try {
// Try local first
final localUser = await _localDataSource.getUser(id);
if (localUser != null) {
return localUser;
}
// Fallback to remote
final remoteUser = await _remoteDataSource.getUser(id);
await _localDataSource.saveUser(remoteUser);
return remoteUser;
} catch (e) {
// Try local as fallback
final localUser = await _localDataSource.getUser(id);
if (localUser != null) {
return localUser;
}
rethrow;
}
}
Future<List<User>> getUsers() async {
try {
final remoteUsers = await _remoteDataSource.getUsers();
for (final user in remoteUsers) {
await _localDataSource.saveUser(user);
}
return remoteUsers;
} catch (e) {
return await _localDataSource.getUsers();
}
}
Future<void> saveUser(User user) async {
await Future.wait([
_remoteDataSource.saveUser(user),
_localDataSource.saveUser(user),
]);
}
}
// Presentation Layer - ViewModel
class UserViewModel extends ChangeNotifier {
final GetUserUseCase _getUserUseCase;
final GetUsersUseCase _getUsersUseCase;
final SaveUserUseCase _saveUserUseCase;
UserViewModel(
this._getUserUseCase,
this._getUsersUseCase,
this._saveUserUseCase,
);
User? _user;
List<User> _users = [];
bool _isLoading = false;
String? _error;
User? get user => _user;
List<User> get users => _users;
bool get isLoading => _isLoading;
String? get error => _error;
Future<void> loadUser(String id) async {
_setLoading(true);
_clearError();
try {
_user = await _getUserUseCase.execute(id);
notifyListeners();
} catch (e) {
_setError(e.toString());
} finally {
_setLoading(false);
}
}
Future<void> loadUsers() async {
_setLoading(true);
_clearError();
try {
_users = await _getUsersUseCase.execute();
notifyListeners();
} catch (e) {
_setError(e.toString());
} finally {
_setLoading(false);
}
}
Future<void> saveUser(User user) async {
_setLoading(true);
_clearError();
try {
await _saveUserUseCase.execute(user);
_user = user;
notifyListeners();
} catch (e) {
_setError(e.toString());
} finally {
_setLoading(false);
}
}
void _setLoading(bool loading) {
_isLoading = loading;
notifyListeners();
}
void _setError(String error) {
_error = error;
notifyListeners();
}
void _clearError() {
_error = null;
notifyListeners();
}
}
// Presentation Layer - View
class UserProfileScreenClean extends StatelessWidget {
final String userId;
const UserProfileScreenClean({Key? key, required this.userId}) : super(key: key);
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) {
// Dependency injection
final remoteDataSource = UserRemoteDataSourceImpl();
final localDataSource = UserLocalDataSourceImpl();
final repository = UserRepositoryImpl(remoteDataSource, localDataSource);
final getUserUseCase = GetUserUseCaseImpl(repository);
final getUsersUseCase = GetUsersUseCaseImpl(repository);
final saveUserUseCase = SaveUserUseCaseImpl(repository);
return UserViewModel(
getUserUseCase,
getUsersUseCase,
saveUserUseCase,
)..loadUser(userId);
},
child: Scaffold(
appBar: AppBar(title: Text('User Profile (Clean)')),
body: Consumer<UserViewModel>(
builder: (context, viewModel, child) {
if (viewModel.isLoading) {
return Center(child: CircularProgressIndicator());
}
if (viewModel.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Error: ${viewModel.error}'),
SizedBox(height: 16),
ElevatedButton(
onPressed: () => viewModel.loadUser(userId),
child: Text('Retry'),
),
],
),
);
}
final user = viewModel.user;
if (user == null) {
return Center(child: Text('No user found'));
}
return Padding(
padding: EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Name: ${user.name}',
style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
SizedBox(height: 8),
Text(
'Email: ${user.email}',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 16),
ElevatedButton(
onPressed: () {
final updatedUser = User(
id: user.id,
name: 'Updated ${user.name}',
email: user.email,
);
viewModel.saveUser(updatedUser);
},
child: Text('Update User'),
),
],
),
);
},
),
),
);
}
}
Repository Pattern
Understanding Repository Pattern
Repository Pattern abstracts the data layer and provides a clean API for data operations. It centralizes data access logic and makes the app more testable.
Repository Implementation
// Repository Interface
abstract class UserRepository {
Future<User> getUser(String id);
Future<List<User>> getUsers();
Future<void> saveUser(User user);
Future<void> deleteUser(String id);
Future<List<User>> searchUsers(String query);
}
// Repository Implementation
class UserRepositoryImpl implements UserRepository {
final UserRemoteDataSource _remoteDataSource;
final UserLocalDataSource _localDataSource;
final NetworkInfo _networkInfo;
UserRepositoryImpl(
this._remoteDataSource,
this._localDataSource,
this._networkInfo,
);
Future<User> getUser(String id) async {
if (await _networkInfo.isConnected) {
try {
final remoteUser = await _remoteDataSource.getUser(id);
await _localDataSource.saveUser(remoteUser);
return remoteUser;
} catch (e) {
// Fallback to local data
final localUser = await _localDataSource.getUser(id);
if (localUser != null) {
return localUser;
}
rethrow;
}
} else {
// Offline mode
final localUser = await _localDataSource.getUser(id);
if (localUser != null) {
return localUser;
}
throw Exception('No internet connection and no local data');
}
}
Future<List<User>> getUsers() async {
if (await _networkInfo.isConnected) {
try {
final remoteUsers = await _remoteDataSource.getUsers();
// Cache all users locally
for (final user in remoteUsers) {
await _localDataSource.saveUser(user);
}
return remoteUsers;
} catch (e) {
return await _localDataSource.getUsers();
}
} else {
return await _localDataSource.getUsers();
}
}
Future<void> saveUser(User user) async {
// Save to local first for immediate feedback
await _localDataSource.saveUser(user);
if (await _networkInfo.isConnected) {
try {
await _remoteDataSource.saveUser(user);
} catch (e) {
// Could implement retry logic or queue for later
print('Failed to save to remote: $e');
}
}
}
Future<void> deleteUser(String id) async {
await _localDataSource.deleteUser(id);
if (await _networkInfo.isConnected) {
try {
await _remoteDataSource.deleteUser(id);
} catch (e) {
print('Failed to delete from remote: $e');
}
}
}
Future<List<User>> searchUsers(String query) async {
// Search locally first for performance
final localResults = await _localDataSource.searchUsers(query);
if (await _networkInfo.isConnected) {
try {
final remoteResults = await _remoteDataSource.searchUsers(query);
// Merge and deduplicate results
final allUsers = {...localResults, ...remoteResults}.toList();
return allUsers;
} catch (e) {
return localResults;
}
} else {
return localResults;
}
}
}
// Network Info
abstract class NetworkInfo {
Future<bool> get isConnected;
}
class NetworkInfoImpl implements NetworkInfo {
final InternetConnectionChecker _connectionChecker;
NetworkInfoImpl(this._connectionChecker);
Future<bool> get isConnected => _connectionChecker.hasConnection;
}
Dependency Injection
Understanding Dependency Injection
Dependency Injection is a design pattern that provides objects with their dependencies rather than creating them internally. It improves testability and reduces coupling.
Dependency Injection with GetIt
// Service Locator
final getIt = GetIt.instance;
void setupDependencies() {
// Core
getIt.registerLazySingleton<NetworkInfo>(() => NetworkInfoImpl(InternetConnectionChecker()));
// Data Sources
getIt.registerLazySingleton<UserRemoteDataSource>(() => UserRemoteDataSourceImpl());
getIt.registerLazySingleton<UserLocalDataSource>(() => UserLocalDataSourceImpl());
// Repository
getIt.registerLazySingleton<UserRepository>(() => UserRepositoryImpl(
getIt<UserRemoteDataSource>(),
getIt<UserLocalDataSource>(),
getIt<NetworkInfo>(),
));
// Use Cases
getIt.registerLazySingleton<GetUserUseCase>(() => GetUserUseCaseImpl(getIt<UserRepository>()));
getIt.registerLazySingleton<GetUsersUseCase>(() => GetUsersUseCaseImpl(getIt<UserRepository>()));
getIt.registerLazySingleton<SaveUserUseCase>(() => SaveUserUseCaseImpl(getIt<UserRepository>()));
// ViewModels
getIt.registerFactory<UserViewModel>(() => UserViewModel(
getIt<GetUserUseCase>(),
getIt<GetUsersUseCase>(),
getIt<SaveUserUseCase>(),
));
}
// App with DI
class AppWithDI extends StatelessWidget {
Widget build(BuildContext context) {
return MaterialApp(
home: UserProfileScreenWithDI(userId: '1'),
);
}
}
// Screen with DI
class UserProfileScreenWithDI extends StatelessWidget {
final String userId;
const UserProfileScreenWithDI({Key? key, required this.userId}) : super(key: key);
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (context) => getIt<UserViewModel>()..loadUser(userId),
child: Scaffold(
appBar: AppBar(title: Text('User Profile (DI)')),
body: Consumer<UserViewModel>(
builder: (context, viewModel, child) {
// Same UI as before
return Container();
},
),
),
);
}
}
Architecture Best Practices
Project Structure
lib/
├── core/
│ ├── error/
│ ├── network/
│ └── utils/
├── data/
│ ├── datasources/
│ ├── models/
│ └── repositories/
├── domain/
│ ├── entities/
│ ├── repositories/
│ └── usecases/
├── presentation/
│ ├── pages/
│ ├── widgets/
│ └── viewmodels/
└── main.dart
State Management Guidelines
// Good: Separate state classes
class UserState {
final User? user;
final bool isLoading;
final String? error;
const UserState({
this.user,
this.isLoading = false,
this.error,
});
UserState copyWith({
User? user,
bool? isLoading,
String? error,
}) {
return UserState(
user: user ?? this.user,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
}
// Good: Immutable state updates
class UserCubit extends Cubit<UserState> {
UserCubit() : super(const UserState());
void loadUser(String id) {
emit(state.copyWith(isLoading: true, error: null));
// ... load user logic
}
}
Error Handling
// Custom exceptions
class ServerException implements Exception {
final String message;
ServerException(this.message);
}
class CacheException implements Exception {
final String message;
CacheException(this.message);
}
class NetworkException implements Exception {
final String message;
NetworkException(this.message);
}
// Error handling in repository
class UserRepositoryImpl implements UserRepository {
Future<User> getUser(String id) async {
try {
if (await _networkInfo.isConnected) {
return await _remoteDataSource.getUser(id);
} else {
final localUser = await _localDataSource.getUser(id);
if (localUser != null) {
return localUser;
}
throw CacheException('No local data available');
}
} on ServerException {
rethrow;
} on CacheException {
rethrow;
} catch (e) {
throw NetworkException('Unexpected error: $e');
}
}
}
Testing Architecture
Unit Testing
// Testing Use Cases
void main() {
group('GetUserUseCase', () {
late GetUserUseCase useCase;
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
useCase = GetUserUseCaseImpl(mockRepository);
});
test('should get user from repository', () async {
// Arrange
final userId = '1';
final expectedUser = User(id: userId, name: 'John', email: 'john@example.com');
when(mockRepository.getUser(userId)).thenAnswer((_) async => expectedUser);
// Act
final result = await useCase.execute(userId);
// Assert
expect(result, expectedUser);
verify(mockRepository.getUser(userId)).called(1);
});
});
}
// Testing ViewModels
void main() {
group('UserViewModel', () {
late UserViewModel viewModel;
late MockGetUserUseCase mockGetUserUseCase;
setUp(() {
mockGetUserUseCase = MockGetUserUseCase();
viewModel = UserViewModel(
mockGetUserUseCase,
MockGetUsersUseCase(),
MockSaveUserUseCase(),
);
});
test('should load user successfully', () async {
// Arrange
final userId = '1';
final expectedUser = User(id: userId, name: 'John', email: 'john@example.com');
when(mockGetUserUseCase.execute(userId)).thenAnswer((_) async => expectedUser);
// Act
await viewModel.loadUser(userId);
// Assert
expect(viewModel.user, expectedUser);
expect(viewModel.isLoading, false);
expect(viewModel.error, null);
});
test('should handle error when loading user fails', () async {
// Arrange
final userId = '1';
final errorMessage = 'Network error';
when(mockGetUserUseCase.execute(userId)).thenThrow(Exception(errorMessage));
// Act
await viewModel.loadUser(userId);
// Assert
expect(viewModel.user, null);
expect(viewModel.isLoading, false);
expect(viewModel.error, errorMessage);
});
});
}
Integration Testing
// Testing Repository
void main() {
group('UserRepository Integration Tests', () {
late UserRepositoryImpl repository;
late MockUserRemoteDataSource mockRemoteDataSource;
late MockUserLocalDataSource mockLocalDataSource;
late MockNetworkInfo mockNetworkInfo;
setUp(() {
mockRemoteDataSource = MockUserRemoteDataSource();
mockLocalDataSource = MockUserLocalDataSource();
mockNetworkInfo = MockNetworkInfo();
repository = UserRepositoryImpl(
mockRemoteDataSource,
mockLocalDataSource,
mockNetworkInfo,
);
});
test('should return remote data when network is connected', () async {
// Arrange
final userId = '1';
final expectedUser = User(id: userId, name: 'John', email: 'john@example.com');
when(mockNetworkInfo.isConnected).thenAnswer((_) async => true);
when(mockRemoteDataSource.getUser(userId)).thenAnswer((_) async => expectedUser);
when(mockLocalDataSource.saveUser(expectedUser)).thenAnswer((_) async {});
// Act
final result = await repository.getUser(userId);
// Assert
expect(result, expectedUser);
verify(mockRemoteDataSource.getUser(userId)).called(1);
verify(mockLocalDataSource.saveUser(expectedUser)).called(1);
});
test('should return local data when network is disconnected', () async {
// Arrange
final userId = '1';
final expectedUser = User(id: userId, name: 'John', email: 'john@example.com');
when(mockNetworkInfo.isConnected).thenAnswer((_) async => false);
when(mockLocalDataSource.getUser(userId)).thenAnswer((_) async => expectedUser);
// Act
final result = await repository.getUser(userId);
// Assert
expect(result, expectedUser);
verify(mockLocalDataSource.getUser(userId)).called(1);
verifyNever(mockRemoteDataSource.getUser(userId));
});
});
}
References
- Flutter Architecture Samples (github.com)
- Clean Architecture in Flutter (cleancoder.com)
- MVVM Pattern (wikipedia.org)
- MVI Pattern (hannesdorfmann.com)
- Repository Pattern (martinfowler.com)
- Dependency Injection (wikipedia.org)