Clean Architecture, front-end e o caos

Clean Architecture, front-end e o caos

Quando falamos sobre padrões de arquitetura no front-end, existem diferentes opiniões sobre quais padrões aplicar, quando aplicar e se padrões já reconhecidos no mundo de back-end, deveriam fazer parte do contexto do front. Um dos padrões que costuma ser discutido é o famoso Clean Architecture. Muitos defendem que ao desenvolver no front-end, já se define uma tecnologia e/ou arquitetura específica para trabalhar (como React, Angular, VueJS etc) e portanto já não faz sentido se preocupar com novos padrões, incluindo a arquitetura limpa. E neste artigo, vou trazer um pouco das minhas reflexões e experiências vividas sobre a adoção de clean code e clean architecture no front-end.

Os defensores dessa posição ainda colocam que não faz sentido aplicar a regra de dependência de aplicações limpas aqui, que diz que as dependências devem seguir em direção à camada de domínio. Outros porém já defendem que as aplicações front-end devem guardar camadas de domínio e possuir os seus próprios usecases.

Mas afinal: aplicações front-end devem ser modeladas com padrões de arquitetura limpa ou serem livres disso? Qual a melhor forma de modelarmos a nossa aplicação front-end? O Clean Architecture pode nos ajudar quando o que temos a nossa frente é uma aplicação front-end?

Você já deve ter visto um código problemático como este

Você provavelmente já teve a infelicidade de se deparar com um componente enorme e cheio de responsabilidades, assim como neste exemplo:

import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import axios from 'axios'
import Styles from './login-styles.scss'
import { Footer, Input, LoginHeader, FormStatus, SubmitButton } from '@/presentation/components'
import Context from '@/presentation/contexts/form/form-context'
import { HttpStatusCode } from '@/data/protocols/http'

const Login = () => {
  const [state, setState] = useState({
    isLoading: false,
    isFormInvalid: true,
    email: '',
    password: ''
  })
  const url = process.env.BASE_URL

  const isValidMail = (email) => {
    const emailRegex = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
    return emailRegex.test(email)
  }

  const isValidPassword = (passwordg) => {
    const passwordRegex = /^(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[*.!@$%^&(){}[]:;<>,.?\/~_+-=|\]).{8,32}$/
    return passwordRegex.test(password)
  }

  const auth = async (params) => {
    const httpResponse = await axios.post(url, params)
    switch (httpResponse.status) {
      case HttpStatusCode.ok: return httpResponse.data
      case HttpStatusCode.unauthorized: throw new Error('Unauthorized')
      default: throw new Error('Unknow')
    }
  }

  const saveAccessToken = (key, value) => {
    localStorage.setItem(key, value)
  }

  useEffect(() => {
    const emailError = isValidMail('kelvin@mail.com')
    const passwordError = isValidPassword('som&P@ssw#')
    setState({
      ...state,
      isFormInvalid: !!emailError || !!passwordError
    })
  }, [state.email, state.password])

  const handleSubmit = async (event) => {
    event.preventDefault()
    try {
      if (state.isLoading || state.isFormInvalid) {
        return
      }
      setState({ ...state, isLoading: true })
      const account = await auth({
        email: state.email,
        password: state.password
      })
      await saveAccessToken(`accessToken_${state.email}`, account.accessToken)
    } catch (error) {
      setState({
        ...state,
        isLoading: false
      })
    }
  }

  return (
    <div className={Styles.login}>
      <LoginHeader />

      <Context.Provider value={{ state, setState }}>
        <form className={Styles.form} onSubmit={ handleSubmit }>
          <h2>Login</h2>

          <Input type="email" name="email" placeholder="Type your email here"/>
          <Input type="password" name="password" placeholder="Type your password here"/>

          <SubmitButton text="Sign in" />
          <Link to="/signup" className={Styles.link}>Sign up</Link>

          <FormStatus />
        </form>
      </Context.Provider>

      <Footer />
    </div>
  )
}

export default Login

Qual é o problema com esse código? Temos nele o componente Login, que tem as responsabilidades de renderizar o formulário para colher as informações de login, fazer a checagem de validade das informações, chamar o serviço de autenticação e salvar o access token em local storage. Uau! Que componente poderoso! Pena que tão mal construído. Aqui provavelmente estamos ferindo algumas boas práticas de construção de software bem feito.

Então, se quisermos em algum outro momento validar as informações de email e senha vamos precisar reescrever o código? Se precisarmos fazer uma nova chamada de autenticação, precisaremos inserir novo código de autenticação em outro componente? Se quisermos salvar o access token em outras situações não teremos como reaproveitar o que já escrevemos?

Num primeiro olhar, a solução pode ser a seguinte: criar um componente para cada responsabilidade (validação, autenticação e persistência de token de acesso) e deixar o componente Login para manusear esses novos componentes e renderizar o formulário. É o que a sensatez inicialmente nos leva a pensar. No entanto, ainda não temos tudo solucionado, apenas demos uma melhor pequena organizada nas coisas.

Conforme podemos ver no código, tivemos a necessidade de utilizar duas dependências externas: o Axios e o Local Storage. Com o crescimento da aplicação, precisaremos fazer mais chamadas http. O que faremos numa situação na qual quisermos trocar a ferramenta para chamadas http? Vamos ter o retrabalho de reescrever todo o código? Não estaremos construindo um código melhor se utilizarmos o princípio de dependência do Clean Architecture, que diz para não deixarmos a nossa aplicação dependendo de tecnologias externas, como banco de dados, dispositivos, UIs etc? Sem contar conceitos de SOLID sobre responsabilidade única e isolamento de funcionalidades.

Como vamor dividir os componentes e responsabilidades de maneira inteligente?

A primeira coisa que podemos fazer para corrigir a aplicação com o componente acima é utilizar os príncipios de Clean Architeture e Hexagonal Architecture para separar os componentes, conforme o que se segue:

  • O componente Login será colocado na camada presentation
  • As validações serão colocadas num componente próprio e deixadas na camada validation
  • Para as requisições http e o storage, vamos criar uma classe para cada um. Na prática, vamos utilizar o design pattern Adapter aqui. Todas essas classes estarão na camada infra
  • Na camada main faremos a composição de tudo

Na presentation layer, deixaremos o componente Login, tal como desta forma:

import React, { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import Styles from './login-styles.scss'
import Context from '@/presentation/contexts/form/form-context'
import { Footer, Input, LoginHeader, FormStatus, SubmitButton } from '@/presentation/components'

const Login = ({ validation, authentication, saveAccessToken }) => {
  const [state, setState] = useState({
    isLoading: false,
    isFormInvalid: true,
    email: '',
    password: ''
  })

  useEffect(() => {
    const { email, password } = state
    const formData = { email, password }
    const emailError = validation.validate({ fieldName: 'email', input: formData})
    const passwordError = validation.validate(fieldName: 'password', input: formData)
    setState({
      ...state,
      isFormInvalid: !!emailError || !!passwordError
    })
  }, [state.email, state.password])

  const handleSubmit = async (event) => {
    event.preventDefault()
    try {
      if (state.isLoading || state.isFormInvalid) {
        return
      }
      setState({ ...state, isLoading: true })
      const account = await authentication.auth({
        email: state.email,
        password: state.password
      })
      await saveAccessToken.save(account.accessToken)
    } catch (error) {
      setState({
        ...state,
        isLoading: false
      })
    }
  }

  return (
    <div className={Styles.login}>

      <LoginHeader />

      <Context.Provider value={{ state, setState }}>
        <form data-testid="form" className={Styles.form} onSubmit={ handleSubmit }>
          <h2>Login</h2>

          <Input type="email" name="email" placeholder="Type your email here"/>
          <Input type="password" name="password" placeholder="Type your password here"/>

          <SubmitButton text="Sign in" />
          <Link data-testid="signup-link" to="/signup" className={Styles.link}>Sign up</Link>

          <FormStatus />
        </form>
      </Context.Provider>

      <Footer />
    </div>
  )
}

export default Login


Para elaborar as requisições http, ao invés de criarmos uma requisição em cada componente ou termos um método para cada endpoint que quisermos chamar, podemos ter um client para elaborar qualquer requisição post que desejarmos, e o melhor de tudo é que o client request em si pode ser qualquer um que desejarmos utilizar, como pode ser visto no exemplo abaixo:

import axios from 'axios'

export class AxiosHttpClient {
  async post ({ url, body }) {
    let httpResponse
    try {
      httpResponse = await axios.post(url, body)
    } catch (error) {
      httpResponse = error.response
    }
    return {
      statusCode: httpResponse.status,
      body: httpResponse.data
    }
  }
}

A autenticação pode muito bem ser utilizada por uma classe, presente na camada data, com responsabilidade de realizar autenticação remota dentro da nossa aplicação, conforme exemplo abaixo:

import { HttpStatusCode } from '@/data/protocols/http'

export class RemoteAuthentication {
  constructor ({ url, httpClient }) {
    this.url = url
    this.httpClient = httpClient
  }

  async auth (params) {
    const httpResponse = await this.httpClient.post({
      url: this.url,
      body: params
    })

    if (httpResponse.body !== HttpStatusCode.ok) {
      return new Error(`Something went wrong: ${httpResponse?.error}`)
    }

    return httpResponse.body
  }
}

A classe acima recebe o client para requisições http como injeção de dependência e, para realizar a autenticação, faz uma requisição post chamando a rota devida (também passada como parâmetro no construtor). Até aqui, você já notou que o nosso código de autenticação em nada está dependente do framework, nem de qualquer lib específica, além de ser mais intuitivo e facilmente testável.

O mesmo conceito que usamos acima podemos também aplicar no nosso código para salvar informações no local storage. Primeiro, criamos na camada de infraestrutura da nossa aplicação a classe que chamará a dependência que escolhemos para a nossa aplicação utilizar:

export class LocalStorageAdapter {
  async set ({ key, value }) {
    localStorage.setItem(key, value)
  }
}

Agora, criamos a classe que terá a responsabilidade de salvar os tokens de acesso e passamos como injeção de dependência o componente responsável por fazer a interface com o data storage que escolhemos.

export class LocalSaveAccessToken {
  constructor (setStorage) {
        this.setStorage = setStorage
  }

  async save (accessToken) {
    await this.setStorage.set({ 
        key: 'accessToken', 
        value: accessToken
    })
  }
}

Quando chegamos à nossa camada de validação, passar cada validação por injeção de dependência pode deixar a composição muito grande. Também já vimos que escrever a validação dentro do próprio componente não pode nem ser considerada como opção. Então, como faremos? Podemos aqui utilizar o design pattern Composite para criar uma estrutura de validações, que pode servir não apenas para validar e-mail ou senha, mas qualquer outro tipo de validação que desejarmos, tais como campo obrigatório, tamanho mínimo, tipos literais e assim segue.

export class ValidationComposite {
  constructor (validators) {
    this.validators = validators
  }

  static build (validators) {
    return new ValidationComposite(validators)
  }

  validate ({ fieldName, input }) {
    const validators = this.validators.filter(v => v.field === fieldName)
    for (const validator of validators) {
      const error = validator.validate(input)
      if (error) {
        return error.message
      }
    }
  }
}

Nessa classe, passamos no método build uma lista com todas as validações que desejamos utilizar dentro de um componente. Depois, basta apenas chamarmos o método de validação e indicar qual tipo de validação queremos aplicar e qual o valor a ser validado.

Por fim, no diretório main, que pode se comunicar com todas as outras camadas, podemos criar todos os componentes, atribuir suas respectivas injeções de dependência e tudo mais. Podemos, por exemplo, ter uma factory para a página de login e atribuir suas dependências lá.

import React from 'react'
import { makeLoginValidation } from './login-validation-factory'
import { makeRemoteAuthentication } from '@/main/factories/authentication/remote-authentication-factory'
import { makeLocalSaveAccessToken } from '@/main/factories/save-access-token/local-save-access-token-factory'
import { Login } from '@/presentation/pages'

export const makeLogin = () => {
  return (
    <Login
      authentication={makeRemoteAuthentication()}
      validation={makeLoginValidation()}
      saveAccessToken={makeLocalSaveAccessToken()}
    />
  )
}

Observe que para cada dependência, temos uma factory específica, responsável por criar a instância da classe e atribuir suas dependências.

import { makeApiUrl } from '@/main/factories/http/api-url-factory'
import { makeAxiosHttpClient } from '@/main/factories/http/axios-http-client-factory'
import { RemoteAuthentication } from '@/data/authentication/remote-authentication'

export const makeRemoteAuthentication = () => {
  return new RemoteAuthentication(makeApiUrl('/login'), makeAxiosHttpClient())
}

import { ValidationComposite } from '@/validation/validators'
import { ValidationBuilder as Builder } from '@/validation/validators/builder/validation-builder'

export const makeLoginValidation = () => {
  return ValidationComposite.build([
    ...Builder.field('email').required().email().build(),
    ...Builder.field('password').required().min(5).build()
  ])
}

import { LocalSaveAccessToken } from '@/data/save-access-token/local-save-access-token'
import { makeLocalStorageAdapter } from '@/main/factories/cache/local-storage-factory-adapter'

export const makeLocalSaveAccessToken = () => {
  return new LocalSaveAccessToken(makeLocalStorageAdapter())
}

Pronto! Temos uma aplicação front-end bem construída, com componentes desacoplados, com dependência de frameworks reduzida e aproveitando o melhor dos conceitos da Clean Architeture.

Como esses conceitos me foram um auxílio poderoso na Vórtx

No início deste ano, a squad em que eu atuava recebeu um desafio muito interessante: elaborar uma página de exibição de títulos das carteiras de fundos de investimento que pudesse ser exibida em outro web site, isso porque o cliente não queria que a sua carteira fosse exibida no site da Vórtx, mas em seu próprio site. Além disso, existe uma lei que proíbe que esse tipo de informação seja exibido em dois lugares diferentes.

Ai surgem os problemas: não teríamos acesso ao front do site do cliente para programar isso lá; também fazer a nossa api enviar o próprio front-end junto com as infomações colhidas nos usecases não parecia ser um boa idéia, pois isso iria virar uma coisa semelhante a um "iframe monolítico", o que não ficaria legal.

Sabíamos que podiamos criar uma nova página disponibilizada pela aplicação que renderiza site da Vórtx, acessível através de uma url específica de criássemos.

Não era realmente uma demanda singela. O módulo precisava exibir títulos das carteiras de fundos de investimento, o que implicava em exibir também as informações dos fundos a qual essas carteiras estavam associadas. Ainda havia o problema dos títulos poderem ser de ativos ou passivos e existirem diferentes tipos de ativos (renda fixa, opções, swap, etc) e diferentes tipos de passivos (taxa de administração, performance, auditoria, etc). As carteiras também são atualizadas diariamente, o que implica em diferentes posições da carteira, conforme sua data.

Para lidar com os problemas de domínio e back-end, a nossa ferramenta HerbsJS foi a nossa poderosa arma. Mas como lidar com toda essa complexidade no front-end?

A primeira estratégia foi reaproveitar componentes que já tínhamos no site, para que criar um novo date picker, por exemplo, se já o tínhamos como componente separado e reaproveitável? Depois, separamos cadas componente por responsabilidade única, ou seja, um componente seriam responsável por listas os diferentes tipos de fundos, outro seria responsável por listar as informações do fundo, outro por listar os títulos das carteiras e outro por listar os documentos do fundo. Dessa forma, a responsabilidades ficavam melhor segmentadas.

Feito isso, o próximo passo foi colocar em cada componente a injeção de dependência que ele precisava e implementar as chamadas para o backend em serviços diferentes que fossem responsáveis só por isso. Dessa forma, as chamadas seriam reaproveitáveis e não precisariam ser implementadas no próprio component react. There you go. Agora era só trabalhar o estilo e o desafio estava superado.

You better use it

Portanto, os conceitos do Clean Architeture, Clean Code e SOLID não só podem, como devem ser utilizados na modelagem de aplicações front-end.

É verdade que aplicações front-end tedem a ser mais mutáveis e nenhuma delas deve guardar as regras de negócio do domínio. Porém, devemos deixar os componentes da nossa aplicação muito mais independentes do que costumamos ver em muitas aplicações, o que torna o princípio de inversão de dependência totalmente indispensável.

Além disso, a melhor modelagem de aplicações front-end com estes princípios de construção de software tornará os componentes da nossa aplicação front-end melhor reaproveitáveis e com um custo de manutenibilidade baixo e assim fará com que o desenvolvimento de um nova feature não seja algo penoso e demorado. Isso sem falar de possíveis correções, que serão bem menos problemáticas.

Diariamente lidamos com o desafio de evoluir a aplicação para a fazer solucionar problemas reais e ser rentável. É triste saber que existem tantas aplicações que que só poderão ser evoluídas se forem substituídas por outras. Para evitar isso, se preocupar com deixar sua aplicação mais independente e reutilizável, deve estar entre uma das principais preocupações quando o assunto é modelagem de aplicações front-end.

Referências