Back to Posts

Flutter Architecture Patterns

7 min read

This guide covers different architectural approaches and best practices for organizing Flutter applications.

1. Clean Architecture

1.1. Domain Layer

// Domain/entities/user.dart
class User {
  final String id;
  final String name;
  final String email;

  User({
    required this.id,
    required this.name,
    required this.email,
  });
}

// Domain/repositories/user_repository.dart
abstract class UserRepository {
  Future<User> getUser(String id);
  Future<List<User>> getUsers();
  Future<void> saveUser(User user);
}

// Domain/usecases/get_user.dart
class GetUser {
  final UserRepository repository;

  GetUser(this.repository);

  Future<User> call(String id) async {
    return await repository.getUser(id);
  }
}

1.2. Data Layer

// Data/repositories/user_repository_impl.dart
class UserRepositoryImpl implements UserRepository {
  final UserDataSource dataSource;

  UserRepositoryImpl(this.dataSource);

  @override
  Future<User> getUser(String id) async {
    return await dataSource.getUser(id);
  }

  @override
  Future<List<User>> getUsers() async {
    return await dataSource.getUsers();
  }

  @override
  Future<void> saveUser(User user) async {
    await dataSource.saveUser(user);
  }
}

// Data/datasources/user_data_source.dart
abstract class UserDataSource {
  Future<User> getUser(String id);
  Future<List<User>> getUsers();
  Future<void> saveUser(User user);
}

2. MVVM Pattern

2.1. ViewModel

class UserViewModel extends ChangeNotifier {
  final UserRepository repository;
  User? _user;
  bool _isLoading = false;

  User? get user => _user;
  bool get isLoading => _isLoading;

  UserViewModel(this.repository);

  Future<void> loadUser(String id) async {
    _isLoading = true;
    notifyListeners();

    try {
      _user = await repository.getUser(id);
    } catch (e) {
      // Handle error
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

2.2. View

class UserView extends StatelessWidget {
  final String userId;

  const UserView({Key? key, required this.userId}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => UserViewModel(context.read<UserRepository>()),
      child: Consumer<UserViewModel>(
        builder: (context, viewModel, child) {
          if (viewModel.isLoading) {
            return const CircularProgressIndicator();
          }

          final user = viewModel.user;
          if (user == null) {
            return const Text('User not found');
          }

          return Column(
            children: [
              Text('Name: ${user.name}'),
              Text('Email: ${user.email}'),
            ],
          );
        },
      ),
    );
  }
}

3. BLoC Pattern

3.1. BLoC Implementation

// Events
abstract class UserEvent {}

class LoadUser extends UserEvent {
  final String id;
  LoadUser(this.id);
}

// States
abstract class UserState {}

class UserInitial extends UserState {}

class UserLoading extends UserState {}

class UserLoaded extends UserState {
  final User user;
  UserLoaded(this.user);
}

class UserError extends UserState {
  final String message;
  UserError(this.message);
}

// BLoC
class UserBloc extends Bloc<UserEvent, UserState> {
  final UserRepository repository;

  UserBloc(this.repository) : super(UserInitial()) {
    on<LoadUser>(_onLoadUser);
  }

  Future<void> _onLoadUser(LoadUser event, Emitter<UserState> emit) async {
    emit(UserLoading());
    try {
      final user = await repository.getUser(event.id);
      emit(UserLoaded(user));
    } catch (e) {
      emit(UserError(e.toString()));
    }
  }
}

3.2. BLoC Usage

class UserScreen extends StatelessWidget {
  final String userId;

  const UserScreen({Key? key, required this.userId}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => UserBloc(context.read<UserRepository>())
        ..add(LoadUser(userId)),
      child: BlocBuilder<UserBloc, UserState>(
        builder: (context, state) {
          if (state is UserLoading) {
            return const CircularProgressIndicator();
          }

          if (state is UserError) {
            return Text('Error: ${state.message}');
          }

          if (state is UserLoaded) {
            final user = state.user;
            return Column(
              children: [
                Text('Name: ${user.name}'),
                Text('Email: ${user.email}'),
              ],
            );
          }

          return const SizedBox();
        },
      ),
    );
  }
}

4. Repository Pattern

4.1. Repository Implementation

class UserRepositoryImpl implements UserRepository {
  final UserLocalDataSource localDataSource;
  final UserRemoteDataSource remoteDataSource;
  final NetworkInfo networkInfo;

  UserRepositoryImpl({
    required this.localDataSource,
    required this.remoteDataSource,
    required this.networkInfo,
  });

  @override
  Future<User> getUser(String id) async {
    if (await networkInfo.isConnected) {
      try {
        final remoteUser = await remoteDataSource.getUser(id);
        await localDataSource.cacheUser(remoteUser);
        return remoteUser;
      } catch (e) {
        return await localDataSource.getUser(id);
      }
    } else {
      return await localDataSource.getUser(id);
    }
  }
}

4.2. Data Sources

abstract class UserLocalDataSource {
  Future<User> getUser(String id);
  Future<void> cacheUser(User user);
}

abstract class UserRemoteDataSource {
  Future<User> getUser(String id);
}

class NetworkInfo {
  Future<bool> get isConnected async {
    // Implementation
    return true;
  }
}

5. Dependency Injection

5.1. Service Locator

class ServiceLocator {
  static final ServiceLocator _instance = ServiceLocator._internal();
  factory ServiceLocator() => _instance;
  ServiceLocator._internal();

  final Map<Type, Object> _services = {};

  void register<T>(T service) {
    _services[T] = service;
  }

  T get<T>() {
    if (!_services.containsKey(T)) {
      throw Exception('Service $T not found');
    }
    return _services[T] as T;
  }
}

5.2. Usage

void setupDependencies() {
  final locator = ServiceLocator();

  // Register services
  locator.register<UserRepository>(UserRepositoryImpl(
    localDataSource: UserLocalDataSourceImpl(),
    remoteDataSource: UserRemoteDataSourceImpl(),
    networkInfo: NetworkInfo(),
  ));

  // Register BLoCs
  locator.register<UserBloc>(UserBloc(locator.get<UserRepository>()));
}

6. Best Practices

  1. Clean Architecture

    • Separate concerns
    • Define clear boundaries
    • Follow dependency rule
  2. State Management

    • Choose appropriate pattern
    • Keep state minimal
    • Handle errors properly
  3. Dependency Injection

    • Use service locator
    • Register dependencies
    • Follow SOLID principles
  4. Testing

    • Write unit tests
    • Mock dependencies
    • Test edge cases
  5. Code Organization

    • Follow consistent structure
    • Use meaningful names
    • Document complex logic

Conclusion

Remember these key points:

  1. Choose appropriate architecture
  2. Follow clean code principles
  3. Implement proper state management
  4. Use dependency injection
  5. Write testable code

By following these practices, you can:

  • Build maintainable apps
  • Scale your codebase
  • Improve testability
  • Enhance collaboration

Keep improving your Flutter architecture!