avatarTeresa Wu

Summary

The provided content outlines a structured approach to building a clean Flutter module using the principles of clean architecture, which emphasizes separation of concerns through distinct layers for presentation, domain, and data.

Abstract

The article discusses the development of a Flutter module with a focus on clean architecture, which is crucial for creating scalable and maintainable applications. It begins by acknowledging the evolution of cross-platform tools and positions Flutter as a leading solution for native-like mobile apps. The author emphasizes the importance of understanding Flutter's widget system, including stateless and stateful widgets, and the role of Dart in handling complex logic. The clean architecture approach is broken down into three layers: Presentation, Domain, and Data. The Presentation layer handles user interaction, the Domain layer encapsulates business logic, and the Data layer manages data operations. The article delves into the use of libraries like Chopper for HTTP requests and the concept of repositories, mappers, and use cases to ensure a clean separation of concerns. It also touches on design patterns such as MVP, MVVM, and BLoC for the Presentation layer, advocating for the use of BLoC as a potential replacement for UseCases in certain scenarios. The article concludes by highlighting the benefits of adhering to the Dependency Inversion Principle and suggests organizing code into feature modules to isolate changes and improve maintainability.

Opinions

  • The author suggests that Flutter is the closest tool available for achieving a native look and feel in cross-platform applications, implying a level of satisfaction with Flutter's capabilities.
  • There is a preference expressed for structuring modules around features rather than grouping all data, domain, and presentation code together, to localize the impact of changes.
  • The use of the Chopper library for HTTP requests is recommended due to its similarity to Retrofit in Android, indicating a familiar and possibly preferred approach for Android developers.
  • The author acknowledges the debate around the necessity of UseCases when dealing with a single repository but ultimately supports their use for organization and abstraction, especially when multiple repositories are involved.
  • The article conveys a positive view of BLoC as a pattern that can serve both as a ViewModel and a potential replacement for UseCases, showcasing the author's inclination towards this pattern for state management in Flutter applications.

Build a clean Flutter Module

Photo by Lance Anderson on Unsplash

Flutter is the cross-platform tool used to build natively compiled mobile apps for iOS and Android. The platform is highly focused on UI and can be tailored for various screen sizes. Before we dive in, let’s take a look at the evolution of cross-platform tools.

I am sure you have probably heard this famous phrase before: “I don’t like Flutter because I have tried tool X and Y in the past, and none of them matched the expectations as native” (and my face is like this 🤔 🤨 😐 😑 😶). It is true that in the past, developers have tried various approaches to arrive to a stage where we have multiple options to select from, and Flutter is probably the closest tool for a native look & feel. So, if you haven’t given it a try, don’t dismiss it too quickly.

Let’s view some basic components of Flutter, followed by examples of building them with clean code, which helps us to scale, extend and to adapt to changes.

To use Flutter, you must know Widget, it is like View for Android but more. If a view is like a piece of lego then Widget is a more complete model. There are two types:

  • Stateless Widgets have an immutable state and doesn’t change through its lifetime
  • Stateful Widgets have a mutable state and keep track of changes. UI will be redrawn from scratch to adapt to them, and because Flutter renders UI fast, you won’t see any difference with the naked eye.

Saying so, Flutter isn’t just an UI tool, Dart is a language to use for complex logic, especially if you are following the clean architecture path. One of the most famous and widely used pattern is probably the Presentation-Domain-Data layering. It enforces separation of concerns and concentration on current piece of work only.

  • Presentation layer is where magic occurs, it is the most important part of the app because it interacts with users.
  • Domain layer contains business logic and it is the brain of the app. When you are working at this layer, you can completely ignore UI and treat data as something you’d be given like a charm, assuming you have it when you need.
  • Data layer obviously interacts with data. Whether it came from a remote server or a database, this layer is responsible to receive data, to parse, to store and to send it forward.

Okay, enough jibber jabber jibber jabbering, let’s dive in…

Module Overview

Module structure is debatable, some might prefer to divide code into Data-Domain-Presentation modules, and each module contains 100+ features, it is better to form a feature module with it is own eco system, so if an API service changes, it affects only within the module, rather than re-compiling the entire code base.

Data Layer

In this example, I used Chopper lib for HTTP request, it is very close to Retrofit in Android. Dart has its own async task system called Future, which has three states: incomplete, complete with value and complete with error.

@Get(path: “/account.json”)
Future<Response<AccountDTO>> getAccountDTO();

How the flow works is like this: first someone clicks a button and within the same thread, the handler sends out a HTTP request, in this moment, an empty Future object is created, after a while waiting in patient, some data arrives in the callback, the Future is now complete with values. It can either be passed forward or be consumed within the same function using keyword then().

DTO model is unique in Data layer, which maps JSON structure, and because of that, the DTO model isn’t designed for one screen or one UI/UX flow, hence we need to create a mapper to transfer DTO to domain model.

class AccountMapper implements Mapper<AccountDTO, Account> {
   Account map(AccountDTO input) {
      return Account(
             accountNumber: input.accountNumber,
             name: input.name,
             balance: input.balance,
             sortCode: input.sortCode);
   }
}

Repository is the class that glues everything together in Data layer. It hides data logic of how it is formed but provides clean API so others can retrieve data easily.

AccountRepositoryImpl(this._accountApi, this._accountMapper);
Future<Result<AccountModel>> getAccount() async {
   Response<AccountDTO> accountDTO = await _accountApi.getAccountDTO();
   if (accountDTO.isSuccessful) {
    return _accountMapper.map(Result.success(accountDTO.body));
   } else {
    return Result.failure(ResultException(accountDTO.base.toString()));
   }
 }
}

Domain Layer

This is an overview of a module’s content, if case you might wonder repository has appeared twice in data and domain, the abstract repository in domain will be used in presentation, which hides the solid implementation inside data, in order to prevent directing accessing data from outer layer.

abstract class AccountRepository {
   Future<Result<AccountModel>> getAccount();
}

Unlike DTO, domain model isn’t just a plain data holder but contains actions and behaviour, i.e. null checks.

class AccountModel {
   final Iterable<Account> account;
   AccountModel({this.account});
}

UseCase is debatable especially when it contains just one repository, it becomes the duplicated middleman, nevertheless it tidies up your ViewModel or Presenter if there are more than one repositories involved. It works like an organiser inside a drawer. But then depending on your choice of design pattern in the presentation layer, you might replace UseCase with other things like BLoC

class GetAccountUseCaseImpl implements GetAccountUseCase {
  final AccountRepository _accountRepository;
  GetAccountUseCaseImpl(this._accountRepository);
  @override
  Future<Result<AccountModel>> execute() {
    return _accountRepository.getAccount();
  }
}

Presentation Layer

There are plenty patterns to choose from in this layer: MVP, MVVM or BLoC. The reason I said before that BLoC can be the new UseCase, is because they perform similar tasks, although BLoC sometimes lean towards more on the ViewModel side, because it has both business and platform logic. For the example above, you can replace it with BLoC:

class AccountBloc implements Bloc {
  final AccountRepository _accountRepository;
  final StreamController<Result<AccountModel>> _accountController = StreamController();
  Stream<Result<AccountModel>> get accounts => _accountController.stream;
void _loadAccount() async {
      final Result<AccountModel> model = await _accountRepository.getAccount();
      _timelineController.sink.add(model);
  }
}

It doesn’t matter which pattern you choose to use, when it comes to build Widget, you only need to describe how the Widget should be build, assuming you’d have the data when needed. A sample ListView Widget for looks like below:

class _AccountState extends State<AccountListView> {
  final _accountUseCase = AccountProvider().getAccountUseCase;
  
  void initState() {
     super.initState();
     _accountUseCase.execute().then((result) {
     setState(() {
        AccountModel account = (result as Success).value;
     }
   
  @override Widget build(BuildContext context) {...}
}
ListView of Account Transactions

At the end…

  • Used Dependency Inversion Principle to isolate Domain Layer
  • Outer layers can depend on inner layers, but not vice versa
  • Depends on the size of your project, you can split layers between classes, packages or modules, and it is advisable to keep presentation-domain-data layer inside each module, not a god module that contains only one layer of 100 features to share within one project.
Flutter
Apps
iOS
Android
Android App Development
Recommended from ReadMedium