SOLID salvou meu projeto

  • programming
  • design-pattern

Introdução

No início, o maior desafio que era enfrentado por mim era o de dominar as tecnologias, hoje eu vejo que tecnologias são coisas triviais e o que importa é saber a programação em si.

Eu estava trabalhando em um backend para um projeto pessoal, e sinceramente, o código estava um caos, o nível de acoplamento era muito grande e alguns problemas simples acabavam se tornando difíceis de resolver. Minha solução para esse problema foi implementar testes(sim, eu comecei o projeto sem testes devidamente escritos), mas nem mesmo isso resolveu, pois como eu disse, era tudo muito acoplado no sistema que eu estava desenvolvendo.

Meu problema era o seguinte: eu precisava testar diversas funcionalidades que deveriam ser simples mas para isso eu precisava inicializar o projeto e enviar requisições para a API, essas requisições iriam criar dados de teste apenas para que eu os removesse depois. O código estava totalmente estruturado em funções, então uma função de cadastro de usuário se pareceria com isso aqui

async function createUser(userName: string, userEmail: string) {
  await anyORM.user.create(userName, userEmail);
}

(Não se apegue à função chamada para criar o usuário, é apenas um exemplo).

O problema com essa abordagem é que fica impossível testar essa funcionalidade de forma simples: todo teste automatizado que eu implementasse teria que poluir a base de dados com dados de exemplo incluindo dados inválidos, portanto também teria que se preocupar em limpar essa mesma bagunça, além de dar muito trabalho implementar essa bagunça, fica extremamente lento e insustentável.

SOLID

Nesse ponto eu estava estagnado em meu projeto, eu precisava achar uma forma de implementar testes eficientes, mas daquele jeito não era possível. Pesquisei sobre TDD até que encontrei o que faltava em meu código: SOLID.

Caso você não saiba, SOLID é uma sigla para um conjunto de boas práticas no desenvolvimento de um software, e o princípio que fez a maior diferença no meu código foi sem dúvida a Inversão de dependência.

Esse princípio quer dizer que nenhum caso de uso de seu projeto deve depender diretamente de uma implementação(a implementação no exemplo acima seria a anyORM.user.create()), mas sim de uma abstração.

Esse conceito vai ficar claro ao longo do post, na pratica a minha primeira alteração foi transformar o caso de uso em uma classe com apenas uma função:

class CreateUser {
  async do(userName: string, userEmail: string) {
    await anyORM.user.create(userName, userEmail);
  }
}

Até o momento, a alteração não resolve nenhum problema e ainda aumenta o tamanho do código, isso porque estamos apenas no começo.

O próximo passo é o que eu acredito ser o mais importante: aplicar repositories. Repositories são um padrão de arquitetura em que você cria uma classe para lidar com implementações que possam fazer interação com algum banco de dados por exemplo, a ideia é que o repository seja o responsável por chamar a função anyORM.user.create() e o caso de uso fique responsável por fazer as validações e filtragens para executar a funcionalidade.

Na pratica, ficaria da seguinte forma:

class UserRepository {
  async create(userName:string, userEmail:string){
    await anyORM.user.create(userName:string, userEmail:string)
  }
}

E então, o caso de uso de criar usuário receberia UserRepository em seu construtor:

class CreateUser {
  userRepository: UserRepository;
  constructor(userRepository: UserRepository) {
    this.userRepository = userRepository;
  }
  async do(userName: string, userEmail: string) {
    await anyORM.user.create(userName, userEmail);
  }
}

Porém o problema não foi resolvido, todas as chamadas a createUser.do() ainda resultarão em uma inserção direta no banco de dados.

É agora que o princípio da inversão de dependência se torna crucial: atualmente o caso de uso de cadastro de um usuário ainda depende de uma implementação.

O primeiro passo para resolver essa parte é criar uma interface, que nos termite abstrair as ações do repository:

interface UserRepository {
  create: (userName: string, userEmail: string) => Promise<void>;
}

E então o originalmente chamado UserRepository passa a implementar na interface UserRepository e ganha um nome mais específico para sua implementação:

class AnyORMUserRepository implements UserRepository {
  async create(userName:string, userEmail:string){
    await anyORM.user.create(userName:string, userEmail:string)
  }
}

Dessa forma o caso de uso de cadastro de usuário pode receber um UserRepository, qualquer classe que implemente nessa função pode ser passada para o caso de uso:

class CreateUser {
  userRepository: UserRepository;
  constructor(userRepository: UserRepository) {
    this.userRepository = userRepository;
  }
  async do(userName: string, userEmail: string) {
    await anyORM.user.create(userName, userEmail);
  }
}

Exemplo de uso:

const userRepository = new AnyORMUserRepository();
const createUser = new CreateUser(userRepository);
await createUser.do(...args goes here);

Dessa forma nem toda chamada a esse caso de uso necessariamente resultará em uma inserção no banco de dados, e para implementar os testes, não precisamos de um banco de dados, podemos usar algo mais simples como um in-memory repository, que consiste em um repositório que irá simular um banco de dados realizando ações na memória através de variáveis:

class InMemoryUserRepository implements UserRepository {
  users: User[];
  async create(userName: string, userEmail: string) {
    this.users.push({
      name: userName,
      email: userEmail,
    });
  }
}

Com isso, o código de um teste se pareceria com algo assim:

const userRepository = new InMemoryUserRepository();
const createUser = new CreateUser(userRepository);
expect(() => createUser.do("Username", "email@email.com").resolves

Claro que o código de forma específica vai depender da biblioteca de testes que estiver usando, mas o código testa se a simples ciração de um usuário não retorna nenhum erro.

Considerações finais

Depois de tudo isso uma dúvida pode ter surgido: se o repository que acaba sendo reponsável por realizar a inserção dos dados na camada de persistência do projeto, qual papel o caso de uso cumpre além de apenas chamar as funções do repository?

Para responser essa dúvida é preciso antes entender que os dados passados para a inserção na base de dados precisam ser validados: o banco de dados não deveria ter emails inválidos ou nomes inexistentes, e todas essas validações são realizadas na camada do caso de uso, outra questão interessante é que o exemplo que eu mostrei exemplifica um único caso de uso se comunicando com apenas um repository, sendo que na prática um caso de uso pode interagir com vários repositories, e repositories não interagem entre si.

Aprender sobre SOLID melhorou muito meu código back-end, desde então consigo criar projetos muito mais testáveis e ganhar tempo. Eu espero que esse post tenha esclarecido todos os benefícios que obtive ao aprender SOLID. Até uma próxima!

Leia também