Search code examples
flutterarchitectureclean-architecture

Flutter Clean Architecture - Conflict with domain entity and data model


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.


Solution

  • Hopefully I was able to figure this out. I am adding the questions and answers together for ease.

    Questions:

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

    1. [Question] 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.

    [Answer]: It should be the entity. A data model's duty is to convert an external data to app's entity model. Also, it handles the additional duty of de-converting entity to outer layer format. 2. [Question]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?

    [Answer]: Data Model is created to parse and convert external data to entity and back. So, classes dealing with this data uses data models.

    Following is an article written recently on clean architecture implementation.

    Flutter clean architecture with Riverpod