Clean Architecture: Flutter App
Photo by Ksenia Chernaya from Pexels

Clean Architecture: Flutter App

By employing clean architecture, you can design applications with very low coupling and independent of technical implementation details. That way, the application becomes easy to maintain and flexible to change. Clean architecture allows us to create architectural boundaries between dependencies which allows components to be intrinsically testable

In this project, we'll use the process of creating a CRM application to discuss a Robert C Martin philosophy called Clean Architecture. 

Customer Relationship Management (CRM) is a process companies use to manage customer interactions.

We'll be using Flutter/Dart to create the app.

Use Cases of the Application

A use case, the essence of the application, is a description of how users will perform tasks or interact with in your application.

We'll implement the following use cases:

Customers
  • Get all customers
  • Create customer
  • Update customer information
  • Make customer inactive
  • Make customer active
  • Delete customer
Leads (Potential Customers )
  • Get all leads
  • Create lead
  • Update lead information
  • Convert lead to customer
Tasks
  • Get all activities for customer
  • Create customer task

What is Clean Architecture?

"Clean Architecture" was coined by Robert C Martin and is a software design philosophy that organizes code in such a way that business logic is kept separate from technical implementation (databases, APIs, frameworks). This makes application functionality easy to maintain, change and test.

Uncle Bob's Clean Architecture

A more contextual front-end application illustration of clean architecture would be the following image which illustrates the flow of control and data.

Control and Data flow

Before we start let's add a few project dependencies. The pubspec.yaml file (located at the root of the project) specifies dependencies that the project requires, such as particular packages (and their versions). Let's install a few Flutter/Dart dependencies:

  • Equatable for object comparison
  • Dio for HTTP calls
  • Mockito for mocking dependencies in our tests
  • dartz to help with functional programming in Dart
  • uuid to generate unique ids
1//pubspec.yaml 2name: crm 3description: Mobile CRM 4publish_to: "none" 5version: 1.0.0+1 6environment: 7 sdk: ">=2.18.2 <3.0.0" 8dependencies: 9 flutter: 10 sdk: flutter 11 cupertino_icons: ^1.0.2 12 equatable: ^2.0.5 13 dio: ^4.0.6 14 uuid: ^3.0.6 15 dartz: ^0.10.1 16dev_dependencies: 17 flutter_test: 18 sdk: flutter 19 flutter_lints: ^2.0.0 20 mockito: ^5.3.2 21 build_runner: ^2.3.0 22flutter: 23 uses-material-design: true

How do we organize our code?

I found it useful to divide the project into the following files and folders

├── core
│  └── error
│    └── failures.dart
├── data
│  ├── data_sources
│  │  ├── implementations
│  │  │  └── api
│  │  │    └── task_datasource_impl.dart
│  │  └── interfaces
│  │    ├── customer_datasource.dart
│  │    └── task_datasource.dart
│  └── entities
│    ├── customer_entity.dart
│    └── task_entity.dart
├── domain
│  ├── model
│  │  ├── customer.dart
│  │  └── task.dart
│  ├── repositories
│  │  ├── implementations
│  │  │  ├── customer_repository_impl.dart
│  │  │  └── task_repository_impl.dart
│  │  └── interfaces
│  │    ├── customer_repository.dart
│  │    └── task_repository.dart
│  └── use_cases
│    ├── customer
│    │  ├── create_customer.dart
│    │  ├── delete_customer.dart
│    │  ├── get_all_customers.dart
│    │  ├── get_customer.dart
│    │  ├── make_customer_active.dart
│    │  ├── make_customer_inactive.dart
│    │  └── update_customer_details.dart
│    ├── lead
│    │  ├── convert_lead_to_customer.dart
│    │  ├── create_lead.dart
│    │  ├── delete_lead.dart
│    │  ├── get_all_leads.dart
│    │  ├── get_lead.dart
│    │  └── update_lead_details.dart
│    └── task
│      ├── create_task.dart
│      ├── delete_task.dart
│      ├── mark_task_as_completed.dart
│      └── update_task.dart
├── main.dart
└── presentation
  ├── components
  │  ├── delete_button.dart
  │  ├── edit_button.dart
  │  ├── list_item.dart
  │  ├── list.dart
  │  └── toolbar.dart
  ├── view_models
  │  ├── customer
  │  │  ├── detail.dart
  │  │  ├── edit.dart
  │  │  ├── list.dart
  │  │  └── new.dart
  │  └── task
  │    ├── detail.dart
  │    ├── edit.dart
  │    ├── list.dart
  │    └── new.dart
  └── views
    ├── customer
    │  ├── detail.dart
    │  ├── edit.dart
    │  ├── list.dart
    │  └── new.dart
    └── task
      ├── detail.dart
      ├── edit.dart
      ├── list.dart
      └── new.dart

Models

Domain models represent real-world objects that are related to the problem or domain space. This is a good place to start.

Customer Model
1//lib/domain/models/customer.dart 2import 'package:equatable/equatable.dart'; 3 4enum CustomerType { 5 lead, 6 customer, 7} 8 9class Customer extends Equatable { 10 final String id; 11 final String name; 12 final String email; 13 final CustomerType customerType; 14 final bool isActive; 15 16 const Customer({ 17 required this.id, 18 required this.name, 19 required this.email, 20 this.isActive = true, 21 this.customerType = CustomerType.customer, 22 }); 23 24 25 List<Object> get props { 26 return [id, name, email, isActive, customerType]; 27 } 28} 29
Task Model
1//lib/domain/models/task.dart 2import 'package:crm/domain/models/customer.dart'; 3 4enum Status { notStarted, inProgress, completed } 5 6enum Priority { low, normal, high } 7 8class CRMTask { 9 final String id; 10 final Customer customer; 11 final Priority priority; 12 final String subject; 13 final Status status; 14 final DateTime dueDate; 15 16 const CRMTask({ 17 required this.id, 18 required this.customer, 19 this.priority = Priority.high, 20 this.status = Status.notStarted, 21 required this.subject, 22 required this.dueDate, 23 }); 24} 25

Error and Exception Handling

Typically exceptions and errors are caught and handled by using "try-catch" blocks wrapping a piece of code that might throw. We allow errors to bubble up to a point where they can be centrally handled (near the UI).

Languages like Java, allow you used to use the keyword "throws" to mark a function that might have exception side effects. The Dart language does not allow you to mark functions as potentially throwing so you have to remember which functions might throw to handle them accordingly.

There is nothing wrong with this. We would like to however take a different approach, instead of throwing exceptions, we'd like to catch side-effect exceptions and channel the failure to the function's return value.

This is a Functional Programming (FP) approach to creating pure functions (functions without side effects). The dartz package gives us the ability to write Dart in a more FP way. The package has a type called Either which is used to represent a value that can have two possible types. We'll use this type as our deterministic return type to either return a Failure or the intended return value.

Let's define the Failure type and an example of one of many custom-defined failures.

1//lib/core/error/failures.dart 2import 'package:equatable/equatable.dart'; 3 4abstract class Failure extends Equatable { 5 6 List<Object?> get props => []; 7} 8 9class ServerFailure extends Failure {} 10

To see this in action, let's write our customer repository interface/contract.

1//lib/domain/repository/interfaces/customer_repository.dart 2import 'package:crm/core/error/failures.dart'; 3import 'package:crm/domain/model/customer.dart'; 4 5import "package:dartz/dartz.dart"; 6 7abstract class CustomerRepository { 8 Future<Either<Failure, List<Customer>>> getAllCustomers(CustomerType customerType); 9 Future<Either<Failure, Customer>> getCustomer(String id); 10 Future<Either<Failure, Unit>> createCustomer(Customer data); 11 Future<Either<Failure, Unit>> deleteCustomer(String id); 12 Future<Either<Failure, Unit>> updateCustomer( 13 String id, { 14 String? name, 15 String? email, 16 CustomerType? customerType, 17 bool? isActive, 18 }); 19} 20 21

TDD

Use Case: Get All Customers

Before we create the "Get all customers" use case implementation, let's define an interface/contract for it.

1//lib/domain/use_cases/customer/get_all_customers.dart 2import 'package:crm/core/error/failures.dart'; 3import 'package:crm/domain/model/customer.dart'; 4import 'package:dartz/dartz.dart'; 5 6abstract class GetAllCustomers { 7 Future<Either<Failure, List<Customer>>> execute(); 8}

Using TDD, we'll create the implementation of this interface. We'll use the same file to hold the implementation.

The whole process of TDD can be broken down into these steps:

  • Write test code, but it doesn’t compile (of course).
  • Write production code to make test compile.
  • Write test code that compiles but fails an assertion.
  • Write production code to pass assertion.

Let's write the first test.

1//test/domain/use_cases/customer/get_all_customers_test.dart 2import 'package:flutter_test/flutter_test.dart'; 3import 'package:mockito/annotations.dart'; 4 5import 'package:crm/domain/repositories/interfaces/customer_repository.dart'; 6import 'package:crm/domain/use_cases/customer/get_all_customers.dart'; 7 8import 'get_all_customers_test.mocks.dart'; 9 10@GenerateMocks([CustomerRepository]) 11void main() { 12 late CustomerRepository mockCustomerRepository; 13 late GetAllCustomers usecase; 14 15 setUp(() { 16 mockCustomerRepository = MockCustomerRepository(); 17 usecase = GetAllCustomersImpl(mockCustomerRepository); 18 }); 19 20 test("should get all customers from the customer repository", () async { 21 22 }); 23}
Test Result:
1Failed to load "get_all_customers_test.dart": Compilation failed

Lets discuss mocks before we fix the test.

A note on Mocks

Because the "get all customers" use case has a customer repository dependency, we need to mock the customer repository based on the customer repository interface. We add the generate attribute to our test to instruct Dart to generate mocks and place it next to the test file.

1@GenerateMocks([CustomerRepository])

To use Mockito's generated mock classes, add a build_runner dependency in your package's pubspec.yaml file, under dev_dependencies; something like build_runner: ^1.11.0. We then generate the mocks by running the following command in the project folder

1$> flutter pub run build_runner build

Once the mock is generated you can import it

1import 'get_all_customers_test.mocks.dart';

The setup of the test is now complete but the test fails because the class "GetAllCustomersImpl" does not exist.

Let's write some production code to make the test pass

1//lib/domain/use-cases/customer/get_all_customers.dart 2import 'package:crm/core/error/failures.dart'; 3import 'package:crm/domain/model/customer.dart'; 4import 'package:crm/domain/repositories/interfaces/customer_repository.dart'; 5import 'package:dartz/dartz.dart'; 6 7abstract class GetAllCustomers { 8 Future<Either<Failure, List<Customer>>> execute(); 9} 10 11class GetAllCustomersImpl implements GetAllCustomers { 12 final CustomerRepository customerRepository; 13 14 GetAllCustomersImpl(this.customerRepository); 15 16 17 Future<Either<Failure, List<Customer>>> execute() async { 18 return const Right([]); 19 } 20}
Test Result
1All tests passed!

Let's write a test that compiles but fails assertion,

1 test("should get all customers from the customer repository", () async { 2 Either<Failure, List<Customer>> repoResult = const Right<Failure, List<Customer>>([ 3 Customer( 4 id: "123", 5 email: "john@company.com", 6 name: "John", 7 ), 8 Customer( 9 id: "124", 10 email: "jane@company.com", 11 name: "Jane", 12 ) 13 ]); 14 15 when(mockCustomerRepository.getAllCustomers()).thenAnswer((_) async => repoResult); 16 17 final result = await usecase.execute(); 18 19 expect(result, equals(repoResult)); 20 });
Test Result:
1 Expected: Right<Failure, List<Customer>>:<Right([Customer(123, John, john@company.com, true, CustomerType.customer), Customer(124, Jane, jane@company.com, true, CustomerType.customer)])> 2 Actual: Right<Failure, List<Customer>>:<Right([])>

We can make it pass by simply hard coding a result

1//lib/domain/use_cases/customer/get_all_customers.dart 2import 'package:crm/core/error/failures.dart'; 3import 'package:crm/domain/model/customer.dart'; 4import 'package:crm/domain/repositories/interfaces/customer_repository.dart'; 5import 'package:dartz/dartz.dart'; 6 7abstract class GetAllCustomers { 8 Future<Either<Failure, List<Customer>>> execute(); 9} 10 11class GetAllCustomersImpl implements GetAllCustomers { 12 final CustomerRepository customerRepository; 13 14 GetAllCustomersImpl(this.customerRepository); 15 16 17 Future<Either<Failure, List<Customer>>> execute() async { 18 var result = await customerRepository.getAllCustomers(); 19 return const Right<Failure, List<Customer>>([ 20 Customer( 21 id: "123", 22 email: "john@company.com", 23 name: "John", 24 ), 25 Customer( 26 id: "124", 27 email: "jane@company.com", 28 name: "Jane", 29 ) 30 ]); 31 } 32} 33

This is kind of silly, but it shows that our test is not good enough. In our test we need to verify that the repository was called, so we add a verify line to our test

1... 2 expect(result, equals(repoResult)); 3 verify(mockCustomerRepository.getAllCustomers()); 4...
Test Result:
1 No matching calls (actually, no calls at all).

Let's fix it by writing better production code

1//lib/domain/use_cases/customer/get_all_customers.dart 2import 'package:crm/core/error/failures.dart'; 3import 'package:crm/domain/model/customer.dart'; 4import 'package:crm/domain/repositories/interfaces/customer_repository.dart'; 5import 'package:dartz/dartz.dart'; 6 7abstract class GetAllCustomers { 8 Future<Either<Failure, List<Customer>>> execute(); 9} 10 11class GetAllCustomersImpl implements GetAllCustomers { 12 final CustomerRepository customerRepository; 13 14 GetAllCustomersImpl(this.customerRepository); 15 16 17 Future<Either<Failure, List<Customer>>> execute() async { 18 var result = await customerRepository.getAllCustomers(); 19 return result; 20 } 21}

And this completes the test and production code for "get all customers" use case.

Conclusion

Using the clean architecture approach/philosophy we can easily develop small decoupled parts of our application in a clean and testable way. Please check the github repo for the other production code and tests