DEV Community

Wali Queiroz
Wali Queiroz

Posted on

Princípios SOLID em GoLang - Dependency Inversion Principle (DIP)

Olá, pessoas!

Estou de volta para concluir nossa série de artigos sobre SOLID, apresentando o princípio que tem o impacto mais significativo nos testes unitários em Go: O Princípio da Inversão de Dependência. Para ver os artigos onde detalho os outros, acesse:

  1. Princípios SOLID em GoLang - Single Responsability Principle (SRP)
  2. Princípios SOLID em GoLang - Open/Closed Principle (OCP)
  3. Princípios SOLID em GoLang - Liskov Substitution Principle (LSP)
  4. Princípios SOLID em GoLang - Interface Segregation Principle (ISP)

GoLang - Dependency Inversion Principle

Como desenvolvedor, você já se deparou com softwares altamente acoplados, onde as regras de negócio se misturam com detalhes de apresentação e recursos de bibliotecas externas? Quando é necessário substituir ou alterar uma dessas dependências, muitas partes da aplicação precisam ser modificadas. E aí está uma coisa que todo dev odeia.

Aqui entra o DIP, uma luz no fim do túnel para esse problema. Segundo Robert C. Martin, seu formulador:

“Os módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações. Abstrações não devem depender de detalhes. Detalhes devem depender de abstrações.”

Mas o que são módulos de alto e baixo nível?

Os módulos de alto nível, nesse contexto, são os componentes mais próximos das regras de negócio da aplicação, enquanto os de baixo nível são as ferramentas que usamos para executar essas regras, eles escondem detalhes técnicos sobre diferentes integrações de infraestrutura. Por exemplo, poderia ser uma struct que contém a lógica para recuperar dados do banco de dados, enviar uma mensagem SQS, buscar um valor do Redis ou enviar uma solicitação HTTP para uma API externa.

O DIP sugere que as regras de negócio não devem interagir diretamente com os recursos utilizados para sua execução. A comunicação deve ocorrer através de interfaces, definidas pelo negócio, e as ferramentas externas ou detalhes de implementação são encapsulados em classes que implementam essas interfaces. Isso pode aumentar um pouco a base de código, mas vale a pena, pois torna o sistema mais flexível e fácil de modificar.

Vamos aos exemplos práticos com Go.

No trecho de código acima definimos um componente de alto nível, TaskService. Esta estrutura tem um método ChangeStatus, responsável por mudar o estado da tarefa, que espera o ID e um novo estado. Observem que nosso componente de alto nível depende diretamente do componente de baixo nível sql.DB. Essa dependência direta aumenta o acoplamento entre componentes, tornando o código mais difícil de manter e evoluir. A substituição do banco de dados ou a introdução de um repositório diferente exige modificações no TaskService, o que viola o princípio da responsabilidade única. Também limita a capacidade de reutilizar o TaskService em diferentes contextos, onde uma implementação diferente de persistência pode ser necessária. Além disso, sem definir uma conexão real com o banco de dados, não podemos inicializar nossa estrutura de caso de uso. Tal anti-padrão impacta diretamente nossos testes unitários em Go. Vejamos:

Em contraste com algumas linguagens, como PHP ou Java, não podemos simplesmente criar mocks de "qualquer coisa" em Go. A criação de mocks aqui depende do uso de interfaces, para as quais podemos definir uma implementação simulada, mas não podemos fazer o mesmo para structs. Portanto, não podemos criar um mock de sql.DB, pois é uma struct. Nesse caso, precisamos criar um mock em um nível mais baixo, instanciando uma conexão falsa, o que podemos conseguir usando o pacote SQLMock.

No entanto, mesmo essa abordagem não é nem confiável nem eficiente para testes. Qualquer mudança dentro do banco de dados requer que adaptemos também os testes unitários. Além dos problemas com testes, temos um dilema ainda maior: o que acontecerá se decidirmos mudar o armazenamento para algo diferente, como MongoDB? Nesse cenário, se continuarmos usando essa implementação de TaskService, isso levará a inúmeras refatorações.

Como aplicar a inversão de dependências

“Os módulos de alto nível não devem depender de módulos de baixo nível. Ambos devem depender de abstrações. Abstrações não devem depender de detalhes. Detalhes devem depender de abstrações.”

Vamos revisitar a diretriz original do Princípio da Inversão de Dependência e focar nas frases em negrito. Elas nos fornecem algumas orientações para o processo de refatoração. Precisamos definir uma abstração (uma interface) da qual nossos componentes, TaskService (alto nível) e sql.DB (baixo nível), dependerão. Esta abstração não deve estar vinculada a nenhum detalhe técnico, somente à entidades que compõem o cerne da regra de negócio. Vamos dar uma olhada no código a seguir:

Na nova estrutura de código, adicionamos a interface TaskRepository como um componente que depende da estrutura Task. Task não reflete diretamente o esquema do banco de dados; em vez disso, usamos a estrutura TaskModel para essa finalidade. Também temos uma função mapTaskModelToTask que facilita o mapeamento para a estrutura Task real.

Nesta configuração, o componente de alto nível depende de uma abstração, pois contém um campo do tipo TaskRepository. Para fazer o componente de baixo nível depender da mesma abstração, aplicamos o pattern Adapter, fazendo com que sql.DB sirva como parte dos detalhes em taskSQLRepository, que atua como a implementação concreta de TaskRepository. Podemos definir quantas implementações para TaskRepository forem necessárias, como taskFileRepository ou taskMongoDBRepository.

Agora nossas estruturas dependem de interfaces e, se precisarmos alterar nossas dependências, podemos definir diferentes implementações e injetá-las. Essa técnica está alinhada com o padrão de Injeção de Dependência (DI), uma prática comum em vários frameworks. A diferença entre injeção de dependência e inversão de dependência é sutil, mas crucial. O DIP é um princípio de design que orienta o desacoplamento de módulos de alto nível dos de baixo nível, enquanto a DI é uma técnica de implementação usada para realizar esse desacoplamento, injetando dependências em vez de criá-las internamente.

Agora, vamos examinar como essa refatoração afeta nossos testes unitários:

Após essa mudança, fica fácil criar mocks. Essa forma de fazer as coisas torna o processo de teste mais simples e elegante. Agora, podemos facilmente usar diferentes versões do TaskRepository para testar diferentes situações e controlar com precisão os resultados dos testes. Mas você pode estar se perguntando: "Eu tenho que fazer isso manualmente?" A resposta é não. No exemplo, criei mocks manualmente apenas para facilitar o entendimento, mas na prática, você pode usar bibliotecas como Mockery e GoMock para gerar automaticamente o código desses mocks com base nas interfaces, o que acelera bastante o desenvolvimento.

Considerações finais

Aumento de Código e Manutenção

Ao aplicar o Princípio da Inversão de Dependência (DIP), é natural que o código aumente em termos de quantidade. A necessidade de criar interfaces e classes adicionais para cumprir o princípio pode parecer um overhead inicialmente. No entanto, essa abordagem traz inúmeros benefícios a longo prazo. O código torna-se mais modular e cada componente se torna independente dos detalhes de implementação dos outros. Isso facilita a manutenção e a evolução do sistema, pois mudanças em uma parte do código não requerem alterações em outras partes, minimizando o risco de introduzir novos bugs.

Facilidade de Teste

Outro benefício crucial de seguir o DIP é a facilidade de testar o código. Quando dependências são abstraídas através de interfaces, é simples substituir implementações reais por mocks ou stubs nos testes unitários. Isso permite que cada parte do sistema seja testada de maneira isolada, garantindo uma cobertura de testes mais eficaz e uma detecção precoce de falhas.

Relevância em Arquiteturas em Camadas

Em arquiteturas modernas, como a Arquitetura Limpa e a Arquitetura Hexagonal, o DIP não é apenas uma recomendação, mas uma necessidade para garantir que o sistema seja robusto, flexível e preparado para mudanças futuras.

E assim concluímos nossa série sobre SOLID em Golang! Espero que tenha sido útil para vocês.

Até a próxima!

Referências:

Top comments (0)