Efficient CRUD Operations in Flutter: A Guide to Implementing HTTP Requests with Clean Architecture and Dio
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
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!
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 behaviour 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 - 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 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 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";
Set up DioClient
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 thepaths
fileStated 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 requestStated 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
: Thedata
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
: ThequeryParameters
parameter allows you to include query parameters in the URL of the request.options
: Theoptions
parameter is an instance of theOptions
class that allows you to specify additional configuration options for the request. It includes properties likeheaders
andfollowRedirects
cancelToken
: ThecancelToken
parameter is used to cancel the request if needed.onSendProgress
: TheonSendProgress
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
: TheonReceiveProgress
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.
Create DioException Class for ErrorHandling
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.
Create Model Class
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
Wrap 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()));
}
Create User Repository
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);
}
Create User Repository Implementation class
Now, in the infrastructure/repository
folder, create a file for 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.
Create Provider class
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();
});
Create UseCase class
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.
Create Provider class
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.
Create View Model
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:
We have a method, which we named
init
, that loads the list of users using the use case providerThen using the
ChangeNotifierProvider
, we can access theUserListProvider
class and use it to feed the UI.
Create the User_List UI and add the provider
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:
UserList class is a
ConsumerStateful
widget, this is required by Riverpod to ensure the seamless passing of theref
property.Using
ref.watch
we can have access to thegetUsersProvider
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:
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 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.
Subscribe to my newsletter
Read articles from Nikki Eke directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by