Efficient CRUD Operations in Flutter: A Guide to Implementing HTTP Requests with Clean Architecture and Dio
Tags: flutter, cleancode, mobile, dart
Published: 2023-05-17 17:56:52

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:

1. 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 networking process and allows developers to focus on app logic.
2. 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.
3. 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.
4. 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:

![result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4m52ldg9aba4nz30ya1k.gif)

### 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**

```dart
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.

![folder structure](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/sqt7x6bchz8e15y4rvn9.png)

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](https://reqres.in/) API in the example, you can check it out to see all the methods it provides. Now, go to the `core/internet_services/` create a dart file and name it `paths.dart`, this will contain the baseurl and endpoint.

```dart
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.

```dart
/// 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 ensured 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 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.

### 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.

```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 `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.
    
```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;
      }
    }
```
    

```dart
    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](https://javiercbk.github.io/json_to_dart/)
    

### 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.

```dart
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.

```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 `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.

```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 `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`.

```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. 

```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 `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.

```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 `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](https://github.com/nikkieke/dioclient_tutorial) for the completion of the other methods, as we will be taking only GET all users in this section.

```dart
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 provider
- Then using the `ChangeNotifierProvider`, we can access the `UserListProvider` 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`.

```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 the `ref` property.
- Using `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](https://github.com/nikkieke/dioclient_tutorial) link.

After implementing the other methods, this is our result:

![result](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/49bls7yi1wqebofcvkoc.gif)
## 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](https://pub.dev/documentation/dio/latest/) 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.