Flutter App Architecture and Best Practices
FlutterPulseThis article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!🚀

This article outlines the architectural approach for building a Flutter application using Clean Architecture, SOLID principles, and Riverpod for state management. We take the example of a Crypto Watch-list feature to illustrate the implementation. Additionally, we address key non-functional requirements (NFRs) as best practices to ensure a robust, scalable, and maintainable application.
Part 1: Clean Architecture with Riverpod for the Crypto Watchlist Feature
Overview
The Crypto Watchlist feature allows users to view a list of cryptocurrencies, track their prices, and add/remove coins to their watchlist. The implementation adheres to Clean Architecture and SOLID principles, leveraging Riverpod for state management to ensure separation of concerns, testability, and scalability.
Clean Architecture Layers
Clean Architecture divides the application into three primary layers:
- Presentation Layer: Contains the UI (Flutter widgets) and state management logic (Riverpod providers). It interacts with the domain layer via use cases.
- Domain Layer: The core business logic, including entities and use cases. It is independent of frameworks and external systems.
- Data Layer: Handles data operations (API calls, local storage) and maps external data to domain entities.
1. Domain Layer
The domain layer defines the business logic and entities, independent of Flutter or external systems.
Entities:
class Crypto {
final String id;
final String name;
final double price;
final double change24h;
Crypto({required this.id, required this.name, required this.price, required this.change24h});
}UseCases:
Use cases encapsulate business rules. For the watchlist:
- GetWatchlist: Retrieves the list of tracked cryptocurrencies.
- AddToWatchlist: Adds a cryptocurrency to the watchlist.
- RemoveFromWatchlist: Removes a cryptocurrency from the watchlist.
class GetWatchlist {
final CryptoRepository repository;
GetWatchlist(this.repository);
Future<List<Crypto>> execute() async {
return await repository.getWatchlist();
}
}
class AddToWatchlist {
final CryptoRepository repository;
AddToWatchlist(this.repository);
Future<void> execute(Crypto crypto) async {
await repository.addToWatchlist(crypto);
}
}Repository Interface:
Defines the contract for data operations, keeping the domain layer independent.
abstract class CryptoRepository {
Future<List<Crypto>> getWatchlist();
Future<void> addToWatchlist(Crypto crypto);
Future<void> removeFromWatchlist(String cryptoId);
}SOLID Principles:
- Single Responsibility: Each use case handles one action (e.g., GetWatchlist only fetches data).
- Open/Closed: Use cases are extensible via repository implementations.
- Interface Segregation: The repository interface exposes only relevant methods.
2. Data Layer
The data layer implements the repository interface, handling API calls and local storage.
Models:
Extend entities to include JSON serialization.
class CryptoModel extends Crypto {
CryptoModel({required String id, required String name, required double price, required double change24h})
: super(id: id, name: name, price: price, change24h: change24h);
factory CryptoModel.fromJson(Map<String, dynamic> json) {
return CryptoModel(
id: json['id'],
name: json['name'],
price: json['price'].toDouble(),
change24h: json['change_24h'].toDouble(),
);
}
}Data Sources:
- Remote (API): Fetches live crypto data.
- Local (Hive/SharedPreferences): Caches watchlist data.
class CryptoRemoteDataSource {
Future<List<CryptoModel>> getCryptoPrices() async {
final response = await http.get(Uri.parse('https://api.example.com/coins'));
return (jsonDecode(response.body) as List).map((e) => CryptoModel.fromJson(e)).toList();
}
}
class CryptoLocalDataSource {
Future<List<Crypto>> getWatchlist() async {
// Fetch from Hive/SharedPreferences
}
Future<void> saveWatchlist(List<Crypto> cryptos) async {
// Save to Hive/SharedPreferences
}
}Repository Implementation:
Combines remote and local data sources.
class CryptoRepositoryImpl implements CryptoRepository {
final CryptoRemoteDataSource remoteDataSource;
final CryptoLocalDataSource localDataSource;
CryptoRepositoryImpl(this.remoteDataSource, this.localDataSource);
@override
Future<List<Crypto>> getWatchlist() async {
final cached = await localDataSource.getWatchlist();
if (cached.isNotEmpty) return cached;
final remoteData = await remoteDataSource.getCryptoPrices();
await localDataSource.saveWatchlist(remoteData);
return remoteData;
}
}SOLID Principles:
- Dependency Inversion: The repository depends on abstractions (CryptoRemoteDataSource, CryptoLocalDataSource).
- Liskov Substitution: Data sources can be swapped without affecting the repository.
3. Presentation Layer
The presentation layer uses Riverpod for state management and Flutter widgets for the UI.
Riverpod Providers:
Repository Provider: Provides the repository instance.
final cryptoRepositoryProvider = Provider<CryptoRepository>((ref) {
return CryptoRepositoryImpl(CryptoRemoteDataSource(), CryptoLocalDataSource());
});Use Case Providers: Provide use cases.
final getWatchlistProvider = Provider<GetWatchlist>((ref) {
return GetWatchlist(ref.read(cryptoRepositoryProvider));
});State Notifier for Watchlist:
Manages the watchlist state and handles async operations.
class WatchlistNotifier extends StateNotifier<AsyncValue<List<Crypto>>> {
final GetWatchlist _getWatchlist;
final AddToWatchlist _addToWatchlist;
WatchlistNotifier(this._getWatchlist, this._addToWatchlist) : super(const AsyncValue.loading()) {
loadWatchlist();
}
Future<void> loadWatchlist() async {
state = const AsyncValue.loading();
try {
final cryptos = await _getWatchlist.execute();
state = AsyncValue.data(cryptos);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
Future<void> addCrypto(Crypto crypto) async {
await _addToWatchlist.execute(crypto);
loadWatchlist(); // Refresh the list
}
}
final watchlistProvider = StateNotifierProvider<WatchlistNotifier, AsyncValue<List<Crypto>>>((ref) {
return WatchlistNotifier(
ref.read(getWatchlistProvider),
ref.read(addToWatchlistProvider),
);
});UI Widgets:
Consume the watchlistProvider to display data.
class WatchlistScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final watchlistState = ref.watch(watchlistProvider);
return Scaffold(
appBar: AppBar(title: Text('Crypto Watchlist')),
body: watchlistState.when(
data: (cryptos) => ListView.builder(
itemCount: cryptos.length,
itemBuilder: (context, index) {
final crypto = cryptos[index];
return ListTile(
title: Text(crypto.name),
subtitle: Text('\$${crypto.price} (${crypto.change24h}%)'),
);
},
),
loading: () => Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Error: $error')),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(watchlistProvider.notifier).addCrypto(Crypto(id: 'btc', name: 'Bitcoin', price: 60000, change24h: 2.5));
},
child: Icon(Icons.add),
),
);
}
}SOLID Principles:
- Single Responsibility: Widgets handle UI, WatchlistNotifier manages state.
- Open/Closed: Providers are modular and extensible.
Part 2: Non-Functional Requirements as Best Practices
1. Data Retention or Reset Based on Page Context
Practice:
Determine whether a page's state (e.g., watchlist data) should persist or reset when the user navigates away. For example:
- Persist watchlist data to allow quick re-access.
- Reset search results to avoid stale data.
Implementation:
Use Riverpod's autoDispose modifier for providers that should reset when the widget is disposed:
final searchProvider = StateProvider.autoDispose<String>((ref) => '');
For persistent state, use non-autoDispose providers or local storage:
final watchlistProvider = StateNotifierProvider<WatchlistNotifier, AsyncValue<List<Crypto>>>((ref) => ...);
Benefit:
Aligns state lifecycle with feature requirements, improving UX and memory usage.
2. Data Retention for 10 Minutes
Practice:
Cache frequently accessed data for 10 minutes, resetting afterward to ensure freshness.
Implementation:
Use a timer in the repository to invalidate cache:
class CryptoRepositoryImpl implements CryptoRepository {
DateTime? _lastFetched;
List<Crypto>? _cachedData;
@override
Future<List<Crypto>> getWatchlist() async {
if (_lastFetched != null &&
DateTime.now().difference(_lastFetched!).inMinutes < 10 &&
_cachedData != null) {
return _cachedData!;
}
final data = await remoteDataSource.getCryptoPrices();
_cachedData = data;
_lastFetched = DateTime.now();
return data;
}
}Benefit:
Reduces API calls while ensuring data isn't stale.
3. No API Calls from UI
Practice:
UI widgets should never call APIs directly. APIs are accessed via the data layer through use cases.
Implementation:
Widgets interact with StateNotifier or providers, which trigger use cases:
ref.read(watchlistProvider.notifier).loadWatchlist();
Use cases call repository methods, keeping UI decoupled.
Benefit:
Enforces Clean Architecture, improving testability and maintainability.
4. UI Watches Data Changes and Redraws
Practice:
The UI should reactively update when underlying data changes.
Implementation:
Use Riverpod's ConsumerWidget or ref.watch to observe providers:
final watchlist = ref.watch(watchlistProvider);
Riverpod ensures widgets rebuild only when the observed state changes.
Benefit:
Simplifies state management and ensures a responsive UI.
5. Unified Data Loading and Error Handling
Practice:
Standardize handling for loading states and errors across API calls.
Implementation:
Use AsyncValue in Riverpod to manage loading, data, and error states:
class WatchlistNotifier extends StateNotifier<AsyncValue<List<Crypto>>> {
WatchlistNotifier() : super(const AsyncValue.loading());
Future<void> loadWatchlist() async {
state = const AsyncValue.loading();
try {
final data = await repository.getWatchlist();
state = AsyncValue.data(data);
} catch (e, stack) {
state = AsyncValue.error(e, stack);
}
}
}Create a reusable widget for loading/error states:
class AsyncHandler<T> extends StatelessWidget {
final AsyncValue<T> value;
final Widget Function(T) builder;
AsyncHandler({required this.value, required this.builder});
@override
Widget build(BuildContext context) {
return value.when(
data: builder,
loading: () => Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
);
}
}Benefit:
Reduces code duplication and ensures consistent UX.
6. Cancel Data Requests on Page Exit
Practice:
Cancel ongoing API requests if the user navigates away before data is loaded.
Implementation:
Use CancelToken from the dio package or a custom cancellation mechanism:
class CryptoRemoteDataSource {
final Dio _dio = Dio();
Future<List<CryptoModel>> getCryptoPrices({CancelToken? cancelToken}) async {
final response = await _dio.get('https://api.example.com/coins', cancelToken: cancelToken);
return (response.data as List).map((e) => CryptoModel.fromJson(e)).toList();
}
}In the provider, cancel the token when the widget is disposed:
final watchlistProvider = StateNotifierProvider<WatchlistNotifier, AsyncValue<List<Crypto>>>((ref) {
final cancelToken = CancelToken();
ref.onDispose(() => cancelToken.cancel());
return WatchlistNotifier(cancelToken: cancelToken);
});Benefit:
Prevents unnecessary network usage and stale data updates.
7. Show Previously Fetched Data Offline
Practice:
Display cached data when the network is unavailable.
Implementation:
Check connectivity and fallback to local storage:
class CryptoRepositoryImpl implements CryptoRepository {
@override
Future<List<Crypto>> getWatchlist() async {
try {
final data = await remoteDataSource.getCryptoPrices();
await localDataSource.saveWatchlist(data);
return data;
} catch (e) {
final cached = await localDataSource.getWatchlist();
if (cached.isNotEmpty) return cached;
rethrow;
}
}
}Benefit:
Enhances offline UX and resilience.
8. Auto-Retry on Network Restoration
Practice:
Automatically retry failed API calls when the network is restored.
Implementation:
Use a connectivity listener (e.g., connectivity_plus) in the provider:
class WatchlistNotifier extends StateNotifier<AsyncValue<List<Crypto>>> {
WatchlistNotifier(this.repository) : super(const AsyncValue.loading()) {
Connectivity().onConnectivityChanged.listen((result) {
if (result != ConnectivityResult.none) {
loadWatchlist();
}
});
}
}Benefit:
Improves reliability without user intervention.
9. Minimize UI Rebuilds for List Data
Practice:
Optimize list updates to avoid unnecessary rebuilds when data changes frequently.
Implementation:
Use ListView.builder with keys to minimize widget rebuilds:
ListView.builder(
itemCount: cryptos.length,
itemBuilder: (context, index) {
final crypto = cryptos[index];
return CryptoTile(key: ValueKey(crypto.id), crypto: crypto);
},
);
Use immutable data structures or Riverpod's keepAlive to prevent unnecessary updates.
Benefit:
Improves performance for dynamic lists.
10. Flexible State Declaration
Practice:
Allow state to be declared and accessed anywhere in the app.
Implementation:
Riverpod's provider scope enables global or feature-specific state:
final globalCounterProvider = StateProvider<int>((ref) => 0);
Use ProviderScope to override providers for specific features:
runApp(ProviderScope(
overrides: [
cryptoRepositoryProvider.overrideWithValue(MockCryptoRepository()),
],
child: MyApp(),
));
Benefit:
Simplifies state management and supports modular architecture.
Conclusion
This article provides a comprehensive guide for building a Flutter application using Clean Architecture, SOLID principles, and Riverpod, with the Crypto Watch-list feature as a practical example. By adhering to the outlined structure and best practices, developers can create a scalable, maintainable, and performant app. The non-functional requirements ensure a robust user experience, addressing concerns like offline support, performance optimization, and state management flexibility.
This architecture is designed to evolve with the application, supporting new features while maintaining code quality and consistency. Developers should regularly review and refine these practices to align with project needs and emerging Flutter ecosystem advancements.