Go + Injeção de Dependência
Árvore de Dependências, Inversão de Controle e Raiz de Composição — Artigo 7
Postman collection, código do artigo, diff com o artigo 6.
Sumário: Desenvolvimento de APIs em Go
Próximo Artigo: Go + Testes Unitários — Libs, Nomenclaturas, Padrão AAA, Wrappers e Organização.
Artigo Anterior: Go + GORM— O melhor ORM de Go
Introdução
O princípio da injeção de dependência (DI) é um dos princípios de SOLID e é um conceito fundamental em design de software e programação orientada a objetos. Ele se baseia na ideia de que as dependências de um objeto devem ser fornecidas externamente, em vez de serem criadas ou gerenciadas internamente pelo próprio objeto.
Em vez de um objeto criar diretamente as instâncias das classes que depende, a injeção de dependência permite que essas dependências sejam injetadas por meio de parâmetros de construtor, métodos de configuração ou até mesmo por meio de containers de injeção de dependência.
Para entender os benefícios da utilização de DI, vamos primeiro entender o que acontece quando um código não segue esse princípio.
Ignorando DI
O exemplo a seguir demonstra um código que não segue o princípio da injeção de dependência. Basicamente, definimos em um arquivo uma struct chamada ProductRepository e em outro arquivo definimos a struct ProductService, a qual depende da struct ProductRepository. No construtor de ProductService, chamamos o construtor de ProductRepository para suprir essa dependência. Embora essa abordagem tenha sucesso em fornecer a dependência necessária, ela apresenta alguns efeitos negativos.
Quando um objeto constrói outro, ele assume a responsabilidade pelo ciclo de vida desse objeto e, nesse caso, passa a ter conhecimento específico da implementação utilizada, através do construtor de ProductRepository. O fato de ProductService conhecer a implementação de ProductRepository resulta em um nível mais elevado de acoplamento entre esses objetos, e esse acoplamento tende a aumentar para cada objeto que constrói suas dependências ignorando DI.
O diagrama a seguir demonstra o relacionamento dentre ProductService e ProductRepository, seguindo essa implementação.
DI Sem Interfaces
Este próximo exemplo demonstra um código que segue DI, porém não utiliza o conceito de interfaces. A definição da struct ProductRepository segue sendo feita em um arquivo separado, e em outro arquivo definimos a struct ProductService, que depende da struct ProductRepository. A única mudança em relação ao exemplo anterior no arquivo ProductService está no construtor, que passa a receber o objeto ProductRepository.
Note que nessa implementação temos um terceiro arquivo interagindo com a criação dos objetos ProductService e ProductRepository, que é o arquivo main. Nessa abordagem, a responsabilidade de criar e controlar o ciclo de vida dos objetos é atribuída a esse arquivo, dessa maneira ProductService não possui mais nenhuma responsabilidade relacionada ao ciclo de vida de ProductRepository.
O diagrama a seguir demonstra o relacionamento dentre ProductService e ProductRepository e o arquivo main na implementação atual.
Apesar de termos melhorado o código em relação à construção dos objetos, a implementação neste exemplo ainda acopla a implementação de ProductService a ProductRepository. Para aprimorar esse aspecto do código, será adicionado o conceito de interfaces.
DI Com Interfaces
Nessa última implementação, realizamos uma grande mudança no arquivo do repositório. Agora definimos uma interface chamada ProductRepository (com ‘P’ maiúsculo) e renomeamos a struct para productRepository (com ‘p’ minúsculo). Essa mudança implica que a struct, ou seja, a implementação, não será mais acessível para quem chama de fora do pacote do repositório. Os únicos pontos acessíveis do pacote, seguindo esse design, serão a interface ProductRepository e o construtor New(), que agora retorna a interface.
Outra mudança foi realizada no arquivo de ProductService, que agora ao invés de depender da struct (agora inacessível) passa a depender da interface.
Ao trocar uma dependência de struct por uma dependência de interface temos alguns ganhos:
- Em software, as interfaces tendem a sofrer menos alterações do que as structs. Portanto, o código que depende de interfaces tende a ser menos impactado por mudanças.
- Interfaces, quando utilizadas no paradigma de orientação a objetos, costumam representar conceitos ou abstrações em vez de uma implementação técnica específica. Portanto, o código que depende de interfaces estará dependendo de conceitos de alto nível, em vez de detalhes específicos.
- As interfaces são contratos que podem ser satisfeitos por mais de uma implementação, ou seja, são polimórficas. Isso significa que é possível ter uma struct chamada productMySQLRepository que implementa a interface ProductRepository, assim como é possível ter outra implementação chamada productElasticSearchRepository que também satisfaz a mesma interface. Com o conceito de interfaces, podemos alternar entre essas implementações sem causar quebras no código.
- Assim como podemos ter várias implementações “reais” para a interface ProductRepository, também é possível ter implementações de mock dessa mesma interface. Com o uso de mocks, podemos simular o comportamento dos métodos de ProductRepository e facilitar a escrita de testes unitários.
O diagrama a seguir demonstra o relacionamento entre struct ProductService, struct ProductRepository, interface ProductRepository e o arquivo main na implementação atual.
Seguir DI com interfaces promove maior flexibilidade e modularidade no design do software, reduzindo o acoplamento entre os objetos e facilitando os processos de teste, reutilização, evolução e manutenção do sistema. Além disso, existem três conceitos relacionados à DI que valem a pena mencionar aqui: Árvore de Dependências, Inversão de Controle (IoC) e Raiz de Composição.
Árvore de Dependências
Os microsserviços no mundo real tendem a ser complexos. Ao tentarmos dominar essa complexidade, seguimos conceitos que nos ajudam a modularizar o código, resultando na criação de diversas funções, classes, interfaces, pacotes e camadas. Assim como em qualquer aspecto da vida, cada escolha traz consigo um conjunto de trade-offs, e desenvolver dessa forma gera uma maior complexidade no gerenciamento desses pequenos fragmentos de código interconectados.
Em desenvolvimento de software, sempre que um código A faz referência a outro código B, dizemos que A depende de B. Essas dependências são comuns e podem aparecer em grande quantidade no mundo do desenvolvimento de software. No contexto de microsserviços, podemos afirmar que:
- Microsserviços definem entre dezenas e centenas de objetos com diferentes responsabilidades.
- Cada objeto gera pelo menos uma dependência. Se um objeto não gera dependência, significa que ele não está sendo utilizado (e se não está sendo utilizado, não deveria existir, hehe).
- A estrutura formada pelos objetos e suas dependências é complexa e se assemelha a uma árvore, na qual os objetos são os nós e as dependências são as arestas
- Dentro da árvore de dependência podem existir códigos que são utilizados em mais de um lugar.
O gerenciamento dessas dependências é algo que tende a se tornar cada vez mais complexo à medida que o projeto evolui. Portanto, é de extrema importância tomar certos cuidados no desenvolvimento para evitar problemas comuns de dependências como referências cíclicas e etc. DI nos ajuda a manter nossas dependências sob controle, por meio da inversão de controle (IoC) que ocorre na raiz da composição.
Inversão de Controle (IoC) e Raiz de Composição
Quando se ignora DI, cada objeto é responsável por criar suas próprias dependências. Isso resulta em uma árvore de dependências “invisível”, pois está dispersa em vários arquivos que criam objetos em toda a aplicação. Por outro lado, ao utilizar DI, os objetos passam a solicitar suas dependências à main, que se torna responsável por construir os objetos e conectar as dependências.
Esse comportamento, no qual os objetos passam a solicitar suas dependências à main, é denominado de Inversão de Controle (IoC) e é uma característica importante da aplicação de DI. Nesse caso, a main se torna a raiz de composição responsável pelo gerenciamento de dependências. Isso traz uma série de benefícios, tais como:
- Centraliza a construção da API em um único arquivo, aumentando a coesão da aplicação, pois apenas a raiz de composição é responsável pelo processo de construir e conectar os objetos.
- Facilita o processo de inicialização da API, pois os procedimentos se tornam sequenciais e claros na raiz de composição.
- Facilita a adoção de padrões de projeto, pois possibilita que certos padrões possam ser construídos de maneiras diferentes em tempo de execução durante a inicialização da API.
Quando é aplicado DI da maneira correta, a raiz de composição passa a ter o poder de construir a aplicação de muitas formas diferentes, basicamente o processo vira uma grande construção de muitos plugins.
Realizando uma pequena ligação com o episódio do project-layout. É possível que um projeto possua uma raiz de composição cmd/jsonapi/main.go e outra raiz de composição em cmd/protobufapi/main.go. Onde essas raízes alterem somente qual implementação de ProductHttp será instanciada e utilizada, alterando entre uma API que trabalha com JSON e outra com protubuf.
Conclusões e Próximas Etapas
Nesse artigo foi abordado o Princípio da Injeção de Dependência e alguns conceitos correlatos importantes. DI nos dias de hoje é um padrão que é seguido majoritariamente no mundo de desenvolvimento de software empresarial, pois trás muitos benefícios.
Apesar de termos abordado bastante coisa relacionada a DI, não falamos dos containers de injeção de dependência, que acabam auxiliando ainda mais nosso trabalho quando trabalhamos com projetos ainda mais complexos, vamos dedicar um episódio no futuro para esse tópico.
No próximo episódio vamos abordar o tema de testes unitários, serão levantados aspectos importantes da escrita de testes em Go e discussões sobre alguns padrões.