Efficient CRUD Operations in Flutter: A Guide to Implementing HTTP Requests with Clean Architecture and Dio

Efficient CRUD Operations in Flutter: A Guide to Implementing HTTP Requests with Clean Architecture and Dio

·

14 min read

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 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 behaviour 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:

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

folder_structure

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

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

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

After implementing the other methods, this is our result:

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