DEV Community

Rubem Vasconcelos
Rubem Vasconcelos

Posted on

Clean Architecture: Applying with Flutter

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/
Enter fullscreen mode Exit fullscreen mode

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});
}
Enter fullscreen mode Exit fullscreen mode

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});
}
Enter fullscreen mode Exit fullscreen mode

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};
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Login screen

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();
}
Enter fullscreen mode Exit fullscreen mode

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()),
        );
      },
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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'),
  );
}
Enter fullscreen mode Exit fullscreen mode

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(),
  );
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)