Before starting to read this text, it is recommended to have notions of some basic concepts such as Clean Architecture and SOLID, as they make it easier to understand what will be presented.
This text has two purposes: I. Show an architectural division of a Flutter application using Clean Architecture; II. Guide the implementation of new features in this proposed architecture.
The analyzed code is based on the Clean Architecture approach proposed by Rodrigo Manguinho in his Flutter course. His approach is aligned with the original proposal by Robert Martin.
Architectural Division
The first step is to analyze how the division is done.
android/
ios/
lib/
data/
cache/
http/
models/
usecases/
domain/
entities/
helpers/
usecases/
infra/
cache/
http/
main/
builders/
composites/
decorators/
factories/
cache/
http/
pages/
usecases/
main.dart
presentation/
mixins/
presenters/
protocols/
ui/
assets/
components/
helpers/
mixins/
pages/
validation/
protocols/
validators/
requirements/
bdd_specs/
checklists/
usecases/
test/
data/
domain/
infra/
main/
mocks/
presentation/
ui/
validation/
And from there, we can make associations with the Clean Architecture theory for an easier understanding of division of responsibilities.
Next, let's take a closer look at the purpose of each file structure.
- Android: Contains the files needed to build the application on android systems.
- iOS: Contains the files needed to build the application on iOS systems.
-
Lib: Contains all files needed for the application.
- Data: The data folder represents the data layer of the Clean Architecture, being dependent on the domain layer. Contains the implementations of business rules that are declared in domain.
- Domain: Represents the domain layer of the Clean Architecture, the innermost layer of the application, not having any dependency on any other layer, where it contains the business rules.
- Infra: This folder contains the implementations referring to the HTTP protocol and the cache, it is also the only place where you will have access to external dependencies related to these two items mentioned.
- Main: It corresponds to the main layer of the application, where the interfaces developed in the UI layer are integrated with the business rules created in the folders that represent the innermost layers of the Clean Architecture. All this is due to the use of design patterns such as Factory Method, Composite and Builder.
- Presentation: This layer is where the data is prepared to be consumed in the UI, handling the logic behind the screens.
- UI: Contains the components and visual interfaces that are seen in the system, this is where the screens are created.
- Validation: Where it contains the implementations of the validations used in the fields (ex: minimum amount of characters, required field, valid email, among others).
-
Requirements: Contains documented system requirements, this folder may or may not have all of the following subfolders, it depends a lot on how the team works.
- Bdd_specs: Contains files written in Gherkin language to describe the expected behavior of the system.
- Checklist: It contains the description of the behavior of the pages, in order to facilitate during the unit tests, to know what to validate and what to expect.
- Usecases: It contains the expected behavior of the use cases of the system, where it describes the variations of the business rules to facilitate the unit tests and implementation.
- Test: It contains all the unit tests of the application, each internal folder represents the layer that the tests belong to, and the mocks folder contains the mocks used in the tests.
Implementation Guide
After understanding the reason for the division and what responsibilities are contained in each folder of the structure, a recommended logical sequence for a better implementation performance using this architecture will be described.
In order to simplify the explanation, unit tests will not be described in detail. However, it is strongly recommended to start with unit tests before development (TDD) of each step using the requirements to support the scenarios.
The following demonstration is the creation of the Login flow to log into an application.
First step: Create business rules in the domain layer
Inside lib/domain/usecases, create authentication.dart
. This file will be an abstract class that will describe the authentication business rule.
import '../entities/entities.dart';
abstract class Authentication {
Future<AccountEntity> auth(AuthenticationParams params);
}
class AuthenticationParams {
final String email;
final String password;
AuthenticationParams({required this.email, required this.password});
}
As we can see, it is an abstract class that has an auth()
method that receives the AuthenticationParams parameters that are declared below (email and password), and expects to return an AccountEntity
asynchronously through the Future.
AccountEntity is a class created in lib/domain/entities which represents the token that is returned after authentication to persist the session.
class AccountEntity {
final String token;
AccountEntity({required this.token});
}
Second step: Implement the rules in the data layer
In this layer, we create the use case to implement the rule created previously in the domain layer, but inside lib/data/usecases.
The file usually looks like the example below.
import '../../../domain/entities/entities.dart';
import '../../../domain/helpers/helpers.dart';
import '../../../domain/usecases/usecases.dart';
import '../../http/http.dart';
import '../../models/models.dart';
class RemoteAuthentication implements Authentication {
final HttpClient httpClient;
final String url;
RemoteAuthentication({required this.httpClient, required this.url});
Future<AccountEntity> auth(AuthenticationParams params) async {
final body = RemoteAuthenticationParams.fromDomain(params).toJson();
try {
final hpptResponse =
await httpClient.request(url: url, method: 'post', body: body);
return RemoteAccountModel.fromJson(hpptResponse).toEntity();
} on HttpError catch (error) {
throw error == HttpError.unauthorized
? DomainError.invalidCredentials
: DomainError.unexpected;
}
}
}
class RemoteAuthenticationParams {
final String email;
final String password;
RemoteAuthenticationParams({required this.email, required this.password});
factory RemoteAuthenticationParams.fromDomain(AuthenticationParams params) =>
RemoteAuthenticationParams(
email: params.email, password: params.password);
Map toJson() => {'email': email, 'password': password};
}
As we can see, the RemoteAuthentication class implements the Authentication abstract class, receiving the HTTP client and the url for the request. In the auth()
method it receives the parameters, and calls the RemoteAuthenticationParams.fromDomain(params)
factory created below with the purpose of converting what comes in the standard format to the json format to be sent in the HTTP request inside the body. After that, the request is made and the value returned in httpResponse is stored, and this httpResponse is returned in the method inside a model in order to convert the result to the standard format to work (entity) through RemoteAccountModel.fromJson( hpptResponse).toEntity()
.
This factory and model are created in this layer with the purpose of not polluting the domain layer, because what happens in the data layer should not influence what happens in the domain layer.
For the sake of curiosity, the implementation of RemoteAccountModel is below. It takes an accessToken in json format and converts it to an Entity.
import '../../domain/entities/entities.dart';
import '../http/http.dart';
class RemoteAccountModel {
final String accessToken;
RemoteAccountModel(this.accessToken);
factory RemoteAccountModel.fromJson(Map json) {
if (!json.containsKey('accessToken')) {
throw HttpError.invalidData;
}
return RemoteAccountModel(json['accessToken']);
}
AccountEntity toEntity() => AccountEntity(token: accessToken);
}
Third step: Implement the screens in the UI layer
To simplify the understanding, only code snippets referring to the authentication method call will be presented. The Login screen contains more actions and details that go beyond authentication. Consider the screen prototype below for easier visualization.
In lib/ui/pages/ you will need at least two files: I. login_page.dart
, which will be the Login page; II. login_presenter.dart
which will contain the abstract class with the methods and streams that are used on the page, and the implementation of this abstract class takes place in the presentation layer.
The login_presenter.dart
file is similar to the example below.
import 'package:flutter/material.dart';
abstract class LoginPresenter implements Listenable {
void validateEmail(String email);
void validatePassword(String password);
Future<void> auth();
}
And the code below is for the Login button.
class LoginButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final presenter = Provider.of<LoginPresenter>(context);
return StreamBuilder<bool>(
stream: presenter.isFormValidStream,
builder: (context, snapshot) {
return ElevatedButton(
onPressed: snapshot.data == true ? presenter.auth : null,
child: Text(R.translations.enterButtonText.toUpperCase()),
);
},
);
}
}
The auth()
method when clicked on onPressed
calls the presenter to do the authentication. In this case, you are not passing parameters in auth()
because the parameters are taken from the presenter when interacting with the screen (the validateEmail()
and validatePassword()
declared above in the page presenter are used). We will see more details in the next step.
Fourth step: Implement the UI's abstract presenter class in the presentation layer
To make it easier to work with Streams, it is recommended to use the GetX library (or any other one of your choice) to make it less verbose. The choice for GetX is due to its great support and being constantly updated.
In lib/presentation/presenters, getx_login_presenter.dart
is created. It is a GetxLoginPresenter class that extends GetxController and implements LoginPresenter. Although the example below has Validation and SaveCurrentAccount, we will focus only on Authentication.
import 'dart:async';
import 'package:get/get.dart';
import '../../ui/pages/login/login_presenter.dart';
import '../../domain/helpers/domain_error.dart';
import '../../domain/usecases/usecases.dart';
import '../protocols/protocols.dart';
class GetxLoginPresenter extends GetxController
implements LoginPresenter {
final Validation validation;
final Authentication authentication;
final SaveCurrentAccount saveCurrentAccount;
String? _email;
String? _password;
GetxLoginPresenter({
required this.validation,
required this.authentication,
required this.saveCurrentAccount,
});
void validateEmail(String email) {
_email = email;
// Validation code here
}
void validatePassword(String password) {
_password = password;
// Validation code here
}
Future<void> auth() async {
try {
final account = await authentication.auth(AuthenticationParams(
email: _email!,
password: _password!,
));
await saveCurrentAccount.save(account);
} on DomainError catch (error) {
// Handle errors here
}
}
}
In the validateEmail(String email)
and validatePassword(String password)
methods, the user's email and password are captured when typing in the Inputs of the Login screen. In auth()
, this is where there is a call to the previously implemented authentication method, which receives the email and password captured by the previous validates.
The call authentication.auth(AuthenticationParams(email: _email!, password: _password!))
returns a token (as explained earlier), is assigned to a variable called account and then cached via saveCurrentAccount.save( account)
(this point was not explained in this text, but it is through it that the user session persists on the device).
Fifth step: Connect all layers for requests to work
After everything is implemented, now just connect all the parts. For this, the design pattern Factory Method is used.
Inside lib/main/factories/usecases we create the factory of the use case being implemented. In the case of this example, it is related to authentication.
The authentication_factory.dart is created, which returns the RemoteAuthentication that receives as a parameter the factory of the Http Client and the factory that creates the URL. The URL of the API you want to request is passed as a parameter along with the factory that creates the URL. In the example it is the URL that ends with /login.
import '../../../domain/usecases/usecases.dart';
import '../../../data/usecases/usecases.dart';
import '../factories.dart';
Authentication makeRemoteAuthentication() {
return RemoteAuthentication(
httpClient: makeHttpAdapter(),
url: makeApiUrl('login'),
);
}
After that, in lib/main/factories/pages, the folder for the Login factories is created. For this explanation, we will focus on login_page_factory.dart and login_presenter_factory.dart.
First, the login_presenter_factory.dart is made, which is a Widget that returns the GetxLoginPresenter. This presenter was created previously, and inside it is injecting the authentication factories (which was created just above) and the validation and saving token in the cache (which were not covered in this text, but follow the same premises of _ makeRemoteAuthentication_) .
import '../../factories.dart';
import '../../../../presentation/presenters/presenters.dart';
import '../../../../ui/pages/pages.dart';
LoginPresenter makeGetxLoginPresenter() {
return GetxLoginPresenter(
authentication: makeRemoteAuthentication(),
validation: makeLoginValidation(),
saveCurrentAccount: makeLocalSaveCurrentAccount(),
);
}
Then, following the same line of thought, the factory of the Login page is made. Like the factory of the presenter, it's a Widget, but in this case it returns the LoginPage with the factory of the presenter created earlier being injected as a parameter.
import 'package:flutter/material.dart';
import '../../../../ui/pages/pages.dart';
import '../../factories.dart';
Widget makeLoginPage() {
return LoginPage(makeGetxLoginPresenter());
}
Sixth step: Apply the screen created in the application
Finally, it is necessary to call the Login factory in the application, so that it can be accessed by the user.
In the main.dart file located in lib/main, add the factory page created into the page array (getPages). In the route, is passed the name of the rout - in this case it is /login - and the page, which in this case is the pointer to the factory makeLoginPage
. This logic is used with all other pages. The code looks like it is below.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:get/get.dart';
import '../ui/components/components.dart';
import 'factories/factories.dart';
void main() {
runApp(App());
}
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.light);
final routeObserver = Get.put<RouteObserver>(RouteObserver<PageRoute>());
return GetMaterialApp(
title: 'Flutter Clean App',
debugShowCheckedModeBanner: false,
theme: makeAppTheme(),
navigatorObservers: [routeObserver],
initialRoute: '/',
getPages: [
GetPage(
name: '/login', page: makeLoginPage, transition: Transition.fadeIn),
],
);
}
}
Conclusion
Although it is a little complex to understand at first and seems a bit redundant, abstractions are necessary. Various design patterns are applied to ensure code quality and independence, facilitating evolution and maintenance.
Following the development process and understanding why you are doing it in such a way makes code production easier. After a while it ends up being done naturally, as it has a linear development process: I. Use case in the domain layer; II. Use case in the data layer; III. UI creation; IV. Creation of logics to call the request in the presentation layer; V. Creation of factories to integrate all layers into the main layer; VI. And then the call of the main factory in the application routes so that it is available to the user.
Despite having many abstracted parts, it is recommended to read the code of the hidden parts for a better understanding. In this repository from Rodrigo Manguinho's course you can access these abstracted codes.
References
- Rodrigo Manguinho https://github.com/rmanguinho/clean-flutter-app
- MARTIN, Robert C. Clean Architecture: A Craftsman’s Guide to Software Structure and Design. 1st. ed. USA: Prentice Hall Press, 2017. ISBN 0134494164.
Top comments (0)