Flutter Clean Architecture - Conflict with domain entity and data model

1,354

Just to share with you my understanding on why do we need these 2 layers of data presentation, and convert model to entity and vise versa.

Imagine you have several backends, each having it's own data structure, but in your application it is represented by the single data class. For example, you fetch e-currency data from different sources - and on the top of that you have your own source of data. So you have 2+ DTO, and those map to your model which is later used in UI presentation.

Share:
1,354
Vivek
Author by

Vivek

From the God's Own Country, Kerala, India. Interested in blogging, traveling and coding!

Updated on December 28, 2022

Comments

  • Vivek
    Vivek over 1 year

    I am a beginner in flutter and have been learning flutter for 6 months or so. I am currently working on expense manager app. I found the clean architecture pattern really interesting although it requires a lot more(!) coding than needed otherwise. I could say this authentically since I almost made a functional app and now trying to start from scratch in clean architecture. I am not entirely following the guidelines in https://pub.dev/packages/flutter_clean_architecture but rather try to follow the clean concept as such - Domain -> Data -> UI

    Here is what I have done so far:

    Domain

    1. Created entities within Domain. Let me try to isolate the issue with one of the entities - user transaction
    abstract class UserTransactionEntity {
      final int? transactionID;
      final String merchant;
      final amount;
      final String category;
      final String paymentType;
      final String paymentAccount;
      final String transactionDate;
      final String? transactionNotes;
      final String transactionType;
    
      ///User Transaction Entity Class
    
      UserTransactionEntity(
          {required this.transactionType,
          required this.merchant,
          this.amount,
          required this.category,
          required this.paymentType,
          required this.paymentAccount,
          required this.transactionDate,
          this.transactionNotes,
          this.transactionID});
    }
    
    
    1. Created a domain repository (interface?)
    ///UserTransaction Interface
    abstract class UserTransactionRepository {
      Future<List<UserTransactionEntity>> getTransactionList();
      postTransaction(UserTransactionEntity transaction);
    }
    
    
    1. Created separate usecases in Domain

    Base usecase

    abstract class UseCase<Type, Params> {
      Future<Type> call(Params params);
    }
    
    class NoParams extends Equatable {
      @override
      List<Object?> get props => [];
    }
    
    

    Usecase to fetch everything at once

    class GetAllTransactions
        extends UseCase<List<UserTransactionEntity>, NoParams> {
      final UserTransactionRepository repository;
    
      GetAllTransactions(this.repository);
    
      @override
      Future<List<UserTransactionEntity>> call(NoParams params) async {
        print('calling GetAll Transactions UseCase');
        return await repository.getTransactionList();
      }
    }
    

    Second use case to post transaction

    
    class PostTransaction extends UseCase<dynamic, UserTransactionEntity> {
      final UserTransactionRepository _userTransactionRepository;
      PostTransaction(this._userTransactionRepository);
    
      @override
      call(UserTransactionEntity transaction) async {
        return await _userTransactionRepository.postTransaction(transaction);
      }
    }
    
    

    Data Layer

    1. Created data models extending domain entities. I am using SQFLite so models are created with this in mind. I have kept the entities clean without any methods in it since it can vary depending on outer layers.
    class UserTransactionModel extends UserTransactionEntity {
      UserTransactionModel(
          {int? transactionID,
          required String transactionType,
          required String merchant,
          required num amount,
          required String category,
          required String paymentType,
          required String paymentAccount,
          required String transactionDate,
          String? transactionNotes})
          : super(
                merchant: merchant,
                transactionType: transactionType,
                amount: amount,
                category: category,
                paymentType: paymentType,
                paymentAccount: paymentAccount,
                transactionDate: transactionDate,
                transactionNotes: transactionNotes,
                transactionID: transactionID);
    
      factory UserTransactionModel.fromMap(Map<String, dynamic> map) {
        return UserTransactionModel(
          merchant: map[kMerchant],
          transactionType: map[kTransactionType],
          amount: map[kTransactionAmount],
          category: map[kCategoryName],
          paymentType: map[kPaymentType],
          paymentAccount: map[kPaymentAccount],
          transactionDate: map[kTransactionDate],
          transactionNotes: map[kTransactionNotes],
          transactionID: map[kTransactionID],
        );
      }
    
      Map<String, dynamic> toMap() {
        _validation();
        return {
          kMerchant: merchant,
          kTransactionAmount: amount,
          kCategoryName: category,
          kPaymentType: paymentType,
          kPaymentAccount: paymentAccount,
          kTransactionDate: transactionDate,
          kTransactionNotes: transactionNotes,
          kTransactionType: transactionType
        };
      }
    
      @override
      String toString() {
        return "User Transaction {$transactionType Amount: $amount Merchant:$merchant TransactionDate: $transactionDate}";
      }
    
      void _validation() {
        if (merchant == '') {
          throw NullMerchantNameException(message: 'Merchant should have a name!');
        }
      }
    }
    
    1. Created DB Provider for SQFlite and methods to post and fetch transactions
      getTransactionList() async {
        final _db = await _dbProvider.database;
        final result = await _db!.query(kTransactionTable);
        return result;
      } 
    
    

    Post Transaction method is using data model created from domain entity.

      Future<int> postTransaction(UserTransactionModel userTransaction) async {
        var _resultRow;
        final _db = await _dbProvider.database;
    
        try {
          _resultRow = _db!.insert(
            kTransactionTable,
            userTransaction.toMap(),
          );
          return _resultRow;
        } catch (exception) {
          //TODO
          throw UnimplementedError();
        }
      }
    
    1. Created Local Data Source.
    abstract class LocalDataSource {
      postTransaction(UserTransactionModel transaction);
      updateTransaction();
      deleteTransaction(int transactionID);
      getIncomeTransactionList();
      getExpenseTransactionList();
      getTransactionByDate(String date);
      Future<List<TransactionCategoryEntity>> getCategoryList();
      Future<List<UserTransactionEntity>> getTransactionList();
    }
    

    This is where my problem begins. Following method works fine in the 4th step when implemented in repository. Note that I am fetching entity from database.

        Future<List<UserTransactionEntity>> getTransactionList();
    

    Following method throws error when try to implement in the data repository.

        postTransaction(UserTransactionModel transaction);
    

    If I try to post this model, repository in 4th step complains that its conflicts with domain repository

    'UserTransactionRepositoryImpl.postTransaction' ('dynamic Function(UserTransactionModel)') isn't a valid override of 'UserTransactionRepository.postTransaction' ('dynamic Function(UserTransactionEntity)').

    If I try to update it in the Local Data Source to domain entity, then it will ultimately conflict with the method in 2nd step to post data model. If I change everything to entity, it works fine but then the data model becomes useless and I have no way to created methods to remap data fetched from DB- e.g toMAP(), FromMap() etc.

    I am not sure what I am missing here. I am completely newbie in software programming but have some experience with VBA.

    Let's move on to next step.

    1. Created a data repository implementing repositories from doamin layer.
    class UserTransactionRepositoryImpl implements UserTransactionRepository {
      final LocalDataSource _dataSource;
      UserTransactionRepositoryImpl(this._dataSource);
    
      @override
      Future<List<UserTransactionEntity>> getTransactionList() async {
        try {
          final List<UserTransactionEntity> transactionList =
              await _dataSource.getTransactionList();
          return transactionList;
        } on Exception {
          throw Exception;
        }
      }
    
      @override
      postTransaction(UserTransactionEntity transaction) async {
        await _dataSource.postTransaction(transaction);
      }
    }
    
    

    Presentation layer Finally, I have created the Presentation layer as well and isolated the UI from data layer with the help of Provider. I have set aside the UI part to the final stage unlike my previous experiments.

    Questions:

    So to conclude such a long writeup, here my questions are;

    1. When we create data models extending entity, what should be the one called in UI - data model or domain entity? I can make it work with both but then data model becomes useless especially when we post data using entity and retrieve same thing. Also, this requires the entities to have proper methods to map data for SQLite. If I change to Firebase, the domain logic to format data needs change, which breaks the core principle - closed for edit and most importantly data layer affects domain layer.

    2. Is data model used only when the data is fetched externally and needed extra work to make it look like an entity data? Or in other words, if I am just posting a transaction and read in within the app, would entity suffice?

    3. If both should be used, how it should be done?

    4. Finally, help me understand what went wrong.