In the dynamic world of , the ability to perform efficient operations is a game-changer. Seamlessly integrating HTTP requests with the power of Clean Architecture and the Dio library can elevate your Flutter applications to new heights of performance and productivity. Flutter app development CRUD In this in-depth guide, we unveil the strategies and techniques for implementing smooth and optimized HTTP requests using Clean Architecture and Dio. By following the principles of Clean Architecture, you'll establish a solid foundation that enhances code maintainability, scalability, and modularity. Combined with the versatility of Dio, a battle-tested HTTP client library, this will equip you with a powerful toolset to conquer complex networking challenges. Get ready to elevate your Flutter development with optimized HTTP requests. Let's dive in! Table of contents Understanding Clean Architecture Benefits of Dio for Flutter App Networking CRUD API Implementation with Dio Add Required Dependencies Implement the Project Structure State your API endpoints Set up DioClient Create DioException Class for ErrorHandling Create Model Class Wrap MyApp with ProviderScope Create User Repository Create User Repository Implementation class Create Provider class Create UseCase class Create Provider class Create View Model Create the User_List UI and add the provider Conclusion Understanding Clean Architecture Clean Architecture is a software design pattern. It emphasizes the separation of concerns and the independence of the components that make up a software system. It is a helpful pattern for building scalable and maintainable apps because it provides a clear, structured architecture that promotes separation of concerns, testability, and flexibility. Clean Architecture advocates for layered architecture, and there are clear boundaries between each layer. The outermost layer is the presentation layer, which handles the user interface and user interaction with the application. The domain layer handles the core business logic of the application. It is independent of any specific implementation directly concerning the database or user interface. Finally, the innermost layer is the infrastructure or data layer, which handles the business logic for storing and retrieving data in the application. Benefits of Dio for Flutter App Networking Dio is a powerful HTTP client library that simplifies the process of making HTTP requests and handling responses in Dart-based applications. Here are some of its many benefits: Simplified HTTP request handling: Dio provides an easy-to-use API, which abstracts away the complexities of making network requests in Flutter. It simplifies the process and allows developers to focus on app logic. Customization: Dio allows developers to customize various aspects of network requests, such as headers, timeouts, and response formats. This flexibility allows for greater control over the networking behavior of the app. Efficient Caching: Dio supports various caching strategies that can help reduce network traffic and improve app performance. It is helpful for apps that rely heavily on network requests, as it can reduce the number of requests and improve app responsiveness. Error Handling: Dio provides a robust error-handling mechanism that can help detect and handle network errors gracefully and consistently. It ensures that the app behaves correctly in the face of network errors and provides a better user experience. Overall, Dio is a powerful and flexible tool for networking in Flutter apps that can help developers build more robust, scalable, and efficient apps with less effort and better results. CRUD API Implementation with Dio We will now go ahead to create a simple Flutter app. In which we will implement the CRUD APIs. At the end of this tutorial, we should be able to: - Get all Users GET - Create New User POST - Update User data PUT - Delete User data DELETE We will use: the API in this example because it provides us with the methods we need. REQ | RES Dio for the app networking, Clean architecture and Feature-first approach for managing the project structure, and finally, Riverpod for state management. This is the expected result: Add Required Dependencies Create a Flutter app, then go to your Pubspec.yaml file and add the following dependencies for Dio and Riverpod. You can find these dependencies on Pub.dev dependencies: dio: ^5.1.1 flutter_riverpod: ^2.3.6 Implement the Project Structure Following the Clean Architecture and Feature-first approach, we will create the folders we need and name them accordingly. Your project structure should look like this. Here, we can see that we have implemented clean architecture. It comprises of structuring the project into domain, infrastructure, and presentation. Also, following the feature-first approach, in which each feature contains its domain, infrastructure, and presentation folders, the CRUD feature we are implementing has all these folders. State your API endpoints As stated earlier we are using the API in this example, you can check it out to see all the methods it provides. Now, go to the folder and create a dart file and name it , this will contain the baseurl and endpoint. REQ | RES core/internet_services/ paths.dart String baseUrl = "https://reqres.in/api"; String users = "/users"; Set up DioClient Next, in your folder, you create a dart file and name it . To send a request to the server, we must first set up the DioClient. Setting up a DioClient provides a convenient and efficient way to manage network requests in your application. It offers customization options, simplifies request management, and much more. core/internet_services/ dio_client.dart Here we will create a DioClient singleton class to contain all the Dio methods we need and the helper functions. /// Create a singleton class to contain all Dio methods and helper functions class DioClient { DioClient._(); static final instance = DioClient._(); final Dio _dio = Dio( BaseOptions( baseUrl: baseUrl, connectTimeout: const Duration(seconds: 60), receiveTimeout: const Duration(seconds: 60), responseType: ResponseType.json ) ); ///Get Method Future<Map<String, dynamic>> get( String path, { Map<String, dynamic>? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onReceiveProgress }) async{ try{ final Response response = await _dio.get( path, queryParameters: queryParameters, options: options, cancelToken: cancelToken, onReceiveProgress: onReceiveProgress, ); if(response.statusCode == 200){ return response.data; } throw "something went wrong"; } catch(e){ rethrow; } } ///Post Method Future<Map<String, dynamic>> post( String path, { data, Map<String, dynamic>? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress }) async{ try{ final Response response = await _dio.post( path, data: data, queryParameters: queryParameters, options: options, cancelToken: cancelToken, onSendProgress: onSendProgress, onReceiveProgress: onReceiveProgress, ); if(response.statusCode == 200 || response.statusCode == 201){ return response.data; } throw "something went wrong"; } catch (e){ rethrow; } } ///Put Method Future<Map<String, dynamic>> put( String path, { data, Map<String, dynamic>? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress }) async{ try{ final Response response = await _dio.put( path, data: data, queryParameters: queryParameters, options: options, cancelToken: cancelToken, onSendProgress: onSendProgress, onReceiveProgress: onReceiveProgress, ); if(response.statusCode == 200){ return response.data; } throw "something went wrong"; } catch (e){ rethrow; } } ///Delete Method Future<dynamic> delete( String path, { data, Map<String, dynamic>? queryParameters, Options? options, CancelToken? cancelToken, ProgressCallback? onSendProgress, ProgressCallback? onReceiveProgress }) async{ try{ final Response response = await _dio.delete( path, data: data, queryParameters: queryParameters, options: options, cancelToken: cancelToken, ); if(response.statusCode == 204){ return response.data; } throw "something went wrong"; } catch (e){ rethrow; } } } From the code snippet above, we can see that we did the following: Created a singleton class For DioClient which will ensure that only one instance of the class can exist throughout the application and provides a global point of access to that instance. In the in Dio: BaseOptions Stated the , which we had initially added to the project in the file baseUrl paths Stated the which just refers to the maximum amount of time Dio will wait to establish a connection with the server before it is considered a failed request connectTimeout Stated the which specifies the maximum amount of time Dio will wait to receive a response from the server after the connection has been established before it is considered a failed request. receiveTimeout Stated the which allows you to easily work with the response in the desired format, whether it's JSON, a stream, or raw text, based on your specific requirements. responseType You can check out the , for more options based on your project requirements. BaseOptions Next, we created the various methods for GET, POST, PUT, and DELETE and added several parameters for customization and fine-tuning of the network request. Here's an explanation of each parameter: : The parameter represents the payload or body of the request. As you can see it was not added to the GET method because it does not need a body. data data : The parameter allows you to include query parameters in the URL of the request. queryParameters queryParameters : The parameter is an instance of the class that allows you to specify additional configuration options for the request. It includes properties like and options options Options headers followRedirects : The parameter is used to cancel the request if needed. cancelToken cancelToken : The parameter is a callback function that is called periodically during the sending phase of the request. It allows you to track the progress of the request being sent, which can be useful for displaying progress indicators or implementing upload progress tracking. onSendProgress onSendProgress : The parameter is a callback function that is called periodically during the receiving phase of the response. It enables you to track the progress of the response being received. onReceiveProgress onReceiveProgress Create DioException Class for ErrorHandling In the folder, create a file for the DioException class and name it . This class will enhance error handling, provide meaningful error messages, and tailor exception handling to suit your application's requirements. core/internet_services/ dio_exception.dart class DioException implements Exception{ late String errorMessage; DioException.fromDioError(DioError dioError){ switch(dioError.type){ case DioErrorType.cancel: errorMessage = "Request to the server was cancelled."; break; case DioErrorType.connectionTimeout: errorMessage = "Connection timed out."; break; case DioErrorType.receiveTimeout: errorMessage = "Receiving timeout occurred."; break; case DioErrorType.sendTimeout: errorMessage = "Request send timeout."; break; case DioErrorType.badResponse: errorMessage = _handleStatusCode(dioError.response?.statusCode); break; case DioErrorType.unknown: if (dioError.message!.contains('SocketException')) { errorMessage = 'No Internet.'; break; } errorMessage = 'Unexpected error occurred.'; break; default: errorMessage = 'Something went wrong'; break; } } String _handleStatusCode(int? statusCode) { switch (statusCode) { case 400: return 'User already exist '; case 401: return 'Authentication failed.'; case 403: return 'The authenticated user is not allowed to access the specified API endpoint.'; case 404: return 'The requested resource does not exist.'; case 500: return 'Internal server error.'; default: return 'Oops something went wrong!'; } } @override String toString()=> errorMessage; } Here, we can see that by using , we have created a very robust error-handling class, which you can customize even further to suit your use case. DioErrorType Create Model Class Next, we will create a model class in the folder, for the data obtained from the server to parse it to a dart readable format and for easy JSON Serialization/Deserialization. We will create a User Model for getting a list of users and a New User Model for creating, updating, and deleting a new user. You can name the files and respectively. domain/model user.dart new_user.dart class User { int? id; String? email; String? firstName; String? lastName; String? avatar; User({this.id, this.email, this.firstName, this.lastName, this.avatar}); User.fromJson(Map<String, dynamic> json) { id = json['id']; email = json['email']; firstName = json['first_name']; lastName = json['last_name']; avatar = json['avatar']; } Map<String, dynamic> toJson() { final Map<String, dynamic> data = <String, dynamic>{}; data['id'] = id; data['email'] = email; data['first_name'] = firstName; data['last_name'] = lastName; data['avatar'] = avatar; return data; } } class NewUser { String? name; String? job; String? id; String? createdAt; String? updatedAt; NewUser({this.name, this.job, this.id, this.createdAt, this.updatedAt}); NewUser.fromJson(Map<String, dynamic> json) { name = json['name']; job = json['job']; id = json['id']; createdAt = json['createdAt']; updatedAt = json['updatedAt']; } Map<String, dynamic> toJson() { final Map<String, dynamic> data = <String, dynamic>{}; data['name'] = name; data['job'] = job; data['id'] = id; data['createdAt'] = createdAt; data['updatedAt'] = updatedAt; return data; } } You can generate this easily, by pasting your API response from the REQ | RES API in this JSON to dart converter Wrap with MyApp ProviderScope As we will be using Riverpod for state management, dependency injection, and much more, we need to wrap in the with widget because this is necessary for the Widgets in the app to read providers. MyApp main.dart ProviderScope void main() { runApp(const ProviderScope(child: MyApp())); } Create User Repository In the folder, create the repository abstract class and name the file , this will contain all the different methods to be implemented for GET, POST, PUT, and, DELETE. domain/repository user_repository.dart abstract class UserRepository{ Future<List<User>>getUserList(); Future<NewUser>addNewUser(String name, String job); Future<NewUser>updateUser(String id, String name, String job); Future<void>deleteUser(String id); } Create User Repository Implementation class Now, in the folder, create a file for the user repository implementation class, and name it . In this class, we will implement all the methods in the user repository we just created. infrastructure/repository user_repository_implementation.dart class UserRepositoryImpl implements UserRepository{ @override Future<NewUser> addNewUser(String name, String job) async { try{ final response = await DioClient.instance.post( users, data: { 'name': name, 'job': job, }, ); return NewUser.fromJson(response); }on DioError catch(e){ var error = DioException.fromDioError(e); throw error.errorMessage; } } @override Future<void> deleteUser(String id) async{ try{ await DioClient.instance.delete('$users/$id'); }on DioError catch(e){ var error = DioException.fromDioError(e); throw error.errorMessage; } } @override Future<List<User>> getUserList() async { try { final response = await DioClient.instance.get(users); final userList = (response["data"] as List).map((e) => User.fromJson(e)).toList(); return userList; }on DioError catch(e){ var error = DioException.fromDioError(e); throw error.errorMessage; } } @override Future<NewUser> updateUser(String id, String name, String job)async { try{ final response = await DioClient.instance.put( '$users/$id', data: { 'id': id, 'name': name, 'job': job, }, ); return NewUser.fromJson(response); }on DioError catch(e){ var error = DioException.fromDioError(e); throw error.errorMessage; } } } From the code snippet above, we can see that. We implemented the methods in the abstract class user repository using the GET, POST, UPDATE, and DELETE methods previously defined in the dio client class. Using the DioException class, we can get better-defined error messages. Create Provider class Still in the folder, we will create a provider class using Riverpod for this user repository implementation class. It provides a global point of access for the class. You can name this file . infrastructure/repository provider.dart final userListProvider = Provider<UserRepository>((ref){ return UserRepositoryImpl(); }); final newUserProvider = Provider<UserRepository>((ref){ return UserRepositoryImpl(); }); final updateUserProvider = Provider<UserRepository>((ref){ return UserRepositoryImpl(); }); final deleteUserProvider = Provider<UserRepository>((ref){ return UserRepositoryImpl(); }); Create UseCase class In the folder, create a file for user usecase and name it . The usecase class abstracts the details of external dependencies, such as data sources or APIs. They provide a clean interface for interacting with these dependencies, allowing the use case to remain agnostic of the specific implementation details. domain/usecase user_usecase.dart abstract class UserUseCase{ Future<List<User>> getAllUsers(); Future<NewUser>createNewUser(String name, String job); Future<NewUser> updateUserInfo(String id, String name, String job); Future<void> deleteUserInfo(String id); } class UserUseCaseImpl extends UserUseCase{ final UserRepository userRepository; UserUseCaseImpl(this.userRepository); @override Future<List<User>> getAllUsers() async{ return await userRepository.getUserList(); } @override Future<NewUser> createNewUser(String name, String job)async { return await userRepository.addNewUser(name, job); } @override Future<NewUser> updateUserInfo(String id, String name, String job) async{ return await userRepository.updateUser(id, name, job); } @override Future<void> deleteUserInfo(String id)async { return await userRepository.deleteUser(id); } } On studying the code snippet above, we can see that we did the following: We created an abstract class for the user usecase containing all the different methods we will implement. We also named it differently from those in the user repository to prevent any issues. In the user usecase implementation class, we can see that, for the different methods, we returned the functions from the user repository, we have successfully abstracted our code using clean architecture. Now it is easier to add, modify, or remove functionality without affecting the rest of the codebase. Create Provider class In the same folder, create another file for the provider class and name it . It will be for the use case implementation class, as we did for the user repository implementation class. domain/use case provider.dart final usersListProvider = Provider<UserUseCase>((ref){ return UserUseCaseImpl(ref.read(userListProvider)); }); final createUserProvider = Provider<UserUseCase>((ref){ return UserUseCaseImpl(ref.read(newUserProvider)); }); final updateUserDataProvider = Provider<UserUseCase>((ref){ return UserUseCaseImpl(ref.read(updateUserProvider)); }); final deleteUserDataProvider = Provider<UserUseCase>((ref){ return UserUseCaseImpl(ref.read(deleteUserProvider)); }); Here, we can see that with this provider, we can have access to the user usecase implementation class which will in turn allow us access the user repository implementation providers that we created earlier. Create View Model Finally, we are ready to plug all this into the UI. In the folder, create a file for the user_list provider class and name it . This provider will feed data to the UI. Now in the user list screen, we will get the list of all the users from the server. For brevity, you can check for the completion of the other methods, as we will be taking only GET all users in this section. presentation/view_model user_list_provider.dart GitHub class UserListProvider extends ChangeNotifier{ final ChangeNotifierProviderRef ref; List<User>list = []; bool haveData = false; UserListProvider({required this.ref}); Future<void>init()async{ list = await ref.watch(usersListProvider).getAllUsers(); haveData = true; notifyListeners(); } } final getUsersProvider = ChangeNotifierProvider<UserListProvider>((ref) => UserListProvider(ref: ref)); From the code snippet, we can see that: We have a method, which we named , that loads the list of users using the use case provider init Then using the , we can access the class and use it to feed the UI. ChangeNotifierProvider UserListProvider Create the User_List UI and add the provider In this section, we will see our API response displayed in the UI. 💃🏽 In the folder, create a class for the user list UI, and name the file . presentation/screens ConsumerStatefulWidget user_list.dart class UserList extends ConsumerStatefulWidget { const UserList({Key? key}) : super(key: key); @override ConsumerState<UserList> createState() => _UserListState(); } class _UserListState extends ConsumerState<UserList> { late UserListProvider provider; @override Widget build(BuildContext context) { provider = ref.watch(getUsersProvider); provider.init(); return Scaffold( appBar: AppBar(title: const Text("Get User list"),), body: provider.haveData? Padding( padding: const EdgeInsets.symmetric(vertical: 20,horizontal: 20), child: SingleChildScrollView( child: Column( children: [ ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: provider.list.length, itemBuilder: (context, index){ return ListTile( leading: ClipRRect( borderRadius: BorderRadius.circular(50), child: Image.network("${provider.list[index].avatar}") ), title: Text('${provider.list[index].firstName}'), subtitle: Text('${provider.list[index].lastName}'), ); }) ], ), ), ): const Center(child: CircularProgressIndicator()) ); } } Here, we can see that: UserList class is a widget, this is required by Riverpod to ensure the seamless passing of the property. ConsumerStateful ref Using we can have access to the which we used to get user list ref.watch getUsersProvider To view the complete folder structure and access the remaining code for the other methods, please refer to this link. GitHub After implementing the other methods, this is our result: Conclusion Congratulations, you have come to the end of this tutorial. You should have learned How to structure your files using clean architecture and feature first approach How to use Dio for app networking How to implement a CRUD API How to use Riverpod for state management and dependency injection You can study the docs to explore the many things you could achieve using Dio. If you liked this tutorial and found it helpful, drop a reaction or a comment and follow me for more related articles. Dio Also published here.