In the dynamic world of Flutter app development, the ability to perform efficient CRUD 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.
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!
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.
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.
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 - Get all Users
POST - Create New User
PUT - Update User data
DELETE - Delete User data
We will use:
the REQ | RES API in this example because it provides us with the methods we need.
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:
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
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.
As stated earlier we are using the REQ | RES API in this example, you can check it out to see all the methods it provides. Now, go to the core/internet_services/
folder and create a dart file and name it paths.dart
, this will contain the baseurl and endpoint.
String baseUrl = "https://reqres.in/api";
String users = "/users";
Next, in your core/internet_services/
folder, you create a dart file and name it dio_client.dart
. 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.
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 BaseOptions
in Dio:
Stated the baseUrl
, which we had initially added to the project in the paths
file
Stated the connectTimeout
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
Stated the receiveTimeout
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.
Stated the responseType
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.
You can check out the BaseOptions
, for more options based on your project requirements.
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:
data
: The data
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.
queryParameters
: The queryParameters
parameter allows you to include query parameters in the URL of the request.
options
: The options
parameter is an instance of the Options
class that allows you to specify additional configuration options for the request. It includes properties like headers
and followRedirects
cancelToken
: The cancelToken
parameter is used to cancel the request if needed.
onSendProgress
: The onSendProgress
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.
onReceiveProgress
: The onReceiveProgress
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.
In the core/internet_services/
folder, create a file for the DioException class and name it dio_exception.dart
. This class will enhance error handling, provide meaningful error messages, and tailor exception handling to suit your application's requirements.
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 DioErrorType
, we have created a very robust error-handling class, which you can customize even further to suit your use case.
Next, we will create a model class in the domain/model
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 user.dart
and new_user.dart
respectively.
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
MyApp
with ProviderScope
As we will be using Riverpod for state management, dependency injection, and much more, we need to wrap MyApp
in the main.dart
with ProviderScope
widget because this is necessary for the Widgets in the app to read providers.
void main() {
runApp(const ProviderScope(child: MyApp()));
}
In the domain/repository
folder, create the repository abstract class and name the file user_repository.dart
, this will contain all the different methods to be implemented for GET, POST, PUT, and, DELETE.
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);
}
Now, in the infrastructure/repository
folder, create a file for the user repository implementation class, and name it user_repository_implementation.dart
. In this class, we will implement all the methods in the user repository we just created.
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.
Still in the infrastructure/repository
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 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();
});
In the domain/usecase
folder, create a file for user usecase and name it user_usecase.dart
. 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.
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.
In the same domain/use case
folder, create another file for the provider class and name it provider.dart
. It will be for the use case implementation class, as we did for the user repository implementation class.
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.
Finally, we are ready to plug all this into the UI. In the presentation/view_model
folder, create a file for the user_list provider class and name it user_list_provider.dart
. 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 GitHub for the completion of the other methods, as we will be taking only GET all users in this section.
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:
init
, that loads the list of users using the use case providerChangeNotifierProvider
, we can access the UserListProvider
class and use it to feed the UI.
In this section, we will see our API response displayed in the UI. 💃🏽 In the presentation/screens
folder, create a ConsumerStatefulWidget
class for the user list UI, and name the file 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:
ConsumerStateful
widget, this is required by Riverpod to ensure the seamless passing of the ref
property.ref.watch
we can have access to the getUsersProvider
which we used to get user list
To view the complete folder structure and access the remaining code for the other methods, please refer to this GitHub link.
After implementing the other methods, this is our result:
Congratulations, you have come to the end of this tutorial. You should have learned
You can study the Dio 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.
Also published here.