DEV Community

Rubem Vasconcelos
Rubem Vasconcelos

Posted on • Edited on

Arquitetura Limpa: Aplicando com Flutter

Antes de iniciar a leitura desse texto, é recomendável ter noções de alguns conceitos básicos como Arquitetura Limpa e SOLID, pois eles facilitam o entendimento do que será apresentado.

Este texto tem dois propósitos: I. Mostrar uma divisão arquitetural de uma aplicação Flutter usando Arquitetura Limpa; II. Guiar a implementação de novas features nesta arquitetura proposta.

O código analisado é baseado na abordagem de Arquitetura Limpa proposta por Rodrigo Manguinho em seu curso de Flutter. Sua abordagem segue alinhada com a proposta original de Robert Martin.

Divisão Arquitetural

O primeiro passo é analisar como é feita a divisão.

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

E a partir daí, podemos fazer associações com a teoria da Arquitetura Limpa para uma compreensão mais fácil da divisão de responsabilidades.

A seguir, vamos ver em detalhes o propósito de cada estrutura de arquivos.

  • Android: Contém os arquivos necessários para buildar a aplicação em sistemas android.
  • iOS: Contém os arquivos necessários para buildar a aplicação em sistemas iOS.
  • Lib: Contém todos os arquivos necessários para a aplicação.
    • Data: A pasta data representa a camada de dados da Arquitetura Limpa, sendo dependente da camada de domínio. Contém as implementações das regras de negócio que são declaradas no domain.
    • Domain: Representa a camada de domínio da Arquitetura Limpa, a camada mais interna da aplicação, não apresentando dependência com nenhuma outra camada, onde contém as regras de negócio.
    • Infra: Essa pasta contém as implementações referentes ao protocolo HTTP e ao cache, também é único local onde terá acesso a dependências externas relacionadas para esses dois itens citados.
    • Main: Corresponde a camada principal da aplicação, ponto que ocorre a integração das interfaces desenvolvidas na camada de UI, com as regras de negócio criadas nas pastas que representam as camadas mais internas da Arquitetura Limpa. Tudo isso se dá devido ao uso de padrões de projetos como Factory Method, Composite e Builder.
    • Presentation: Nessa camada é onde os dados são preparados para serem consumidos na UI, tratando as lógicas por trás das telas.
    • UI: Contém os componentes e interfaces visuais que são vistas no sistema, é aqui que propriamente são criadas as telas.
    • Validation: Onde contém as implementações das validações utilizadas nos campos (ex: quantidade mínima de carácteres, campo obrigatório, email válido, dentre outros).
  • Requirements: Contém os requisitos do sistema documentados, essa pasta pode ou não ter todas as subpastas a seguir, depende muito de como o time trabalha.
    • Bdd_specs: Contém os arquivos escritos em linguagem Gherkin para descrever o comportamento esperado do sistema.
    • Checklist: Contém a descrição do comportamento das páginas, com o intuito de facilitar durante os testes unitários, para saber o que validar e o que esperar.
    • Usecases: Contém o comportamento esperado dos casos de uso do sistema, onde descreve as variações das regras de negócio para facilitar os testes unitários e implementação.
  • Test: Contém todos os testes unitários da aplicação, cada pasta interna representa a camada que os testes pertencem, e a pasta de mocks contém os mocks utilizados nos testes.

Guia de Implementação

Após compreender a razão da divisão e quais responsabilidades estão contidas em cada pasta da estrutura, será descrita uma sequência lógica recomendada para um melhor desempenho de implementação utilizando esta arquitetura.

Para finalidade de simplificar a explicação, não será descrito em detalhes os testes unitários. No entanto, é fortemente recomendado começar pelos testes unitários antes do desenvolvimento (TDD) de cada passo utilizando os requirements para embasar os cenários.

A demonstração a seguir é da criação do fluxo de Login para entrar em uma aplicação.

Primeiro passo: Criar as regras de negócio na camada de domínio

Dentro de lib/domain/usecases, criar o authentication.dart. Esse arquivo vai ser uma classe abstrata que vai descrever a regra de negócio da autenticação.

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

Como vemos, é uma classe abstrata que tem um método auth() que recebe os parâmetros AuthenticationParams que são declarados abaixo (email e password), e espera retornar um AccountEntity de maneira assíncrona através do Future.

O AccountEntity é uma classe criada em lib/domain/entities que representa o token que é retornado após a autenticação para persistir a sessão.

class AccountEntity {
  final String token;

  AccountEntity({required this.token});
}
Enter fullscreen mode Exit fullscreen mode

Segundo passo: Implementar as regras na camada de dados

Nessa camada, criamos o caso de uso para implementar a regra criada anteriormente na camada de domínio, porém dentro de lib/data/usecases.

O arquivo costuma ficar como o exemplo abaixo.

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

Como podemos observar, a classe RemoteAuthentication implementa a classe abstrata Authentication, recebendo o cliente HTTP e a url para a requisição. No método auth() ele recebe os parâmetros, e chama a factory RemoteAuthenticationParams.fromDomain(params) criada abaixo com o propósito de converter o que vem no formato padrão para o formato json para ser enviado na requisição HTTP dentro do body. Após isso, é feita a requisição e armazenado o valor retornado em httpResponse, e essa httpResponse é retornada no método dentro de um model com o intuito de converter o resultado para o formato padrão de se trabalhar (entity) através de RemoteAccountModel.fromJson(hpptResponse).toEntity().

Essa factory e model são criadas nessa camada com o propósito de não poluir a camada de domínio, pois o que acontece na camada de dados não deve influenciar o que acontece na de domínio.

Para fim de curiosidade, a implementação do RemoteAccountModel está abaixo. Ele recebe um accessToken no formato de json e o converte para um 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

Terceiro passo: Implementar as telas na camada de UI

Para simplificar o entendimento, será apresentado apenas trechos de códigos referentes a chamada do método de autenticação. A tela de Login contém mais ações e detalhes que vão além da autenticação. Levar em consideração o protótipo da tela abaixo para facilitar a visualização.

Login screen

Em lib/ui/pages/ será necessário ao menos dois arquivos: I. login_page.dart, que será a página de Login; II. login_presenter.dart que conterá a classe abstrata com os métodos e streams que são utilizados na página, e a implementação dessa classe abstrata ocorre na camada de presentation.

O arquivo login_presenter.dart é similar ao exemplo abaixo.

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

E o código abaixo é do botão de Login.

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

Onde o auth() clicado no onPressed chama o presenter para fazer a autenticação. Nesse caso não está passando parâmetros no auth() por os parâmetros serem pegos no presenter durante a interação com a tela (são usados os validateEmail() e validatePassword() declarados acima no presenter da página). Veremos mais detalhes no próximo passo.

Quarto passo: Implementar a classe abstrata do presenter da UI na camada de presentation

Para facilitar o trabalho com Streams, é recomendado o uso da biblioteca GetX (ou alguma outra da sua preferência) para deixar menos verboso. A escolha pela GetX é devido ao seu grande suporte e estar em constante atualização.

Em lib/presentation/presenters é criado o getx_login_presenter.dart. É uma classe GetxLoginPresenter que extende o GetxController e implementa o LoginPresenter. Apesar do exemplo abaixo ter Validation e SaveCurrentAccount, focaremos no 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

Nos métodos validateEmail(String email) e validatePassword(String password) são capturados o email e senha do usuário ao digitar nos Inputs da tela de Login. Em auth(), é onde há a chamada ao método de autenticação implementados anteriormente, que recebem o email e senha capturados pelos validates anteriores.

A chamada authentication.auth(AuthenticationParams(email: _email!, password: _password!)) retorna um token (como explicado anteriormente), é atribuído a uma variável chamada account e em seguida salva em cache através do saveCurrentAccount.save(account) (não foi explicado sobre esse ponto nesse texto, porém é através dele que há a persistência da sessão do usuário no aparelho).

Quinto passo: Conectar todas as camadas para que as requisições funcionem

Após tudo implementado, agora basta conectar todas as partes. Para isso, é utilizado o padrão de projeto Factory Method.

Dentro de lib/main/factories/usecases, criamos a factory do caso de uso que está sendo implementado. No caso desse exemplo, é o relacionado a autenticação.

É criado o authentication_factory.dart, que retorna o RemoteAuthentication que recebe como parâmetro a factory do Http Client e a factory que cria a URL. É passado como parâmetro a URL da API que deseja requisitar junto da factory que cria a URL. No exemplo é a URL que finaliza com /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

Após isso, em lib/main/factories/pages, é criada a pasta para as factories do Login. Para essa explicação, focaremos no login_page_factory.dart e login_presenter_factory.dart.

Primeiro, é feito a login_presenter_factory.dart, que é um Widget que retorna o GetxLoginPresenter. Esse presenter foi criado anteriormente, e dentro dele é injetando as factories de autenticação (que foi criado logo a cima) e as de validação e salvar token no cache (que não foram abordadas nesse texto, porém seguem as mesmas premissas da factory de autenticação).

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

Em seguida, seguindo a mesma linha de pensamento, é feita a factory da página de Login. Como a factory do presenter, é um Widget, mas nesse caso retorna a LoginPage com a factory do presenter criado anteriormente sendo injetado como parâmetro.

import 'package:flutter/material.dart';
import '../../../../ui/pages/pages.dart';
import '../../factories.dart';

Widget makeLoginPage() {
  return LoginPage(makeGetxLoginPresenter());
}
Enter fullscreen mode Exit fullscreen mode

Sexto passo: Aplicar a tela criada na aplicação

Por fim, é necessário chamar a factory do Login na aplicação para que ela consiga ser acessada pelo usuário.

No arquivo main.dart que fica localizado em lib/main, adicionar dentro do array de páginas (getPages) a factory página criada. É passado no name a rota, no caso é a /login, e a página, que no caso é o ponteiro para a factory makeLoginPage. Essa lógica é utilizada com todas as outras páginas. O código fica como está abaixo.

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

Conclusão

Apesar de ser um pouco complexo de se entender no começo e parecer um pouco redundante, as abstrações são necessárias. São aplicados diversos padrões de projetos para garantir a qualidade e independência do código, facilitando a evolução e manutenção.

Seguir o processo de desenvolvimento e entender o porquê está fazendo de tal maneira facilita a produção do código. Após um tempo acaba sendo feito de maneira natural, pois tem um processo linear de desenvolvimento: I. Caso de uso na camada de domínio; II. Caso de uso na camada de dados; III. Criação da UI; IV. Criação das lógicas para chamada da requisição na camada de presentation; V. Criação das factories para integrar todas as camadas na camada principal; VI. E a chamada da factory principal nas rotas da aplicação para que seja disponível para o usuário.

Apesar de ter muitas partes abstraídas, é recomendável a leitura do código das partes ocultas para uma maior compreensão. Nesse repositório do curso do Rodrigo Manguinho você consegue ter acesso a esses códigos abstraídos.

Referências

Top comments (0)