O conceito de injeção de dependência é, em sua essência, uma noção fundamentalmente simples. No entanto, é comumente apresentado de uma maneira ao lado dos conceitos mais teóricos de Inversão de Controle, Inversão de Dependência, Princípios SÓLIDOS e assim por diante. Para tornar o mais fácil possível para você começar a usar a injeção de dependência e começar a colher seus benefícios, este artigo permanecerá muito no lado prático da história, apresentando exemplos que mostram precisamente os benefícios de seu uso, principalmente divorciado da teoria associada. Iremos gastar muito pouco tempo discutindo os conceitos acadêmicos que cercam a injeção de dependência aqui, pois a maior parte dessa explicação será reservada para o segundo artigo desta série. Na verdade, livros inteiros podem ser e foram escritos de forma a fornecer um tratamento mais aprofundado e rigoroso dos conceitos.
Aqui, começaremos com uma explicação simples, passaremos para mais alguns exemplos do mundo real e, em seguida, discutiremos algumas informações básicas. Outro artigo (a seguir a este) discutirá como a injeção de dependência se encaixa no ecossistema geral de aplicação de padrões arquitetônicos de práticas recomendadas.
Uma explicação simples
“Injeção de dependência”é um termo excessivamente complexo para um conceito extremamente simples. Neste ponto, algumas questões sábias e razoáveis seriam”como você define‘ dependência ’?”,”O que significa para uma dependência ser‘ injetada ’?”,”Você pode injetar dependências de maneiras diferentes?”e”por que isso é útil?”Você pode não acreditar que um termo como “injeção de dependência” possa ser explicado em dois trechos de código e algumas palavras, mas, infelizmente, pode.
A maneira mais simples de explicar o conceito é mostrar a você.
Isso, por exemplo, não é injeção de dependência:
import {Engine} from'./Engine'; class Car { motor privado: Motor; construtor público () { this.engine=novo motor (); } public startEngine (): void { this.engine.fireCylinders (); }
}
Mas esta é injeção de dependência:
import {Engine} from'./Engine'; class Car { motor privado: Motor; construtor público (motor: Motor) { this.motor=motor; } public startEngine (): void { this.engine.fireCylinders (); }
}
Feito. É isso aí. Legal. Fim.
O que mudou? Em vez de permitir que a classe Car
instancie Engine
(como fez no primeiro exemplo), no segundo exemplo, Car
tinha uma instância de Engine
passado-ou injetado -de algum nível superior de controle para seu construtor. É isso aí. Basicamente, tudo isso é injeção de dependência-o ato de injetar (passar) uma dependência em outra classe ou função. Qualquer outra coisa envolvendo a noção de injeção de dependência é simplesmente uma variação desse conceito fundamental e simples. Colocado de maneira trivial, injeção de dependência é uma técnica pela qual um objeto recebe outros objetos dos quais depende, chamados de dependências, em vez de criá-los por conta própria.
Em geral, para definir o que é uma “dependência”, se alguma classe A
usa a funcionalidade de uma classe B
, então B
é uma dependência de A
, ou, em outras palavras, A
tem uma dependência de B
. Claro, isso não se limita a classes e também se aplica a funções. Nesse caso, a classe Car
depende da classe Engine
ou Engine
é uma dependência de Car
. Dependências são simplesmente variáveis, assim como a maioria das coisas na programação.
A injeção de dependência é amplamente usada para dar suporte a muitos casos de uso, mas talvez o uso mais evidente seja permitir testes mais fáceis. No primeiro exemplo, não podemos simular facilmente o engine
porque a classe Car
o instancia. O verdadeiro motor está sempre sendo usado. Mas, no último caso, temos controle sobre o Engine
que é usado, o que significa que, em um teste, podemos criar uma subclasse de Engine
e substituir seus métodos.
Por exemplo, se quisermos ver o que Car.startEngine ()
faz se engine.fireCylinders ()
gerar um erro, poderíamos simplesmente criar um Classe FakeEngine
, faça com que ela estenda a classe Engine
e, em seguida, substitua fireCylinders
para fazê-la lançar um erro. No teste, podemos injetar esse objeto FakeEngine
no construtor de Car
. Como o FakeEngine
é um Engine
por implicação de herança, o sistema de tipo TypeScript está satisfeito.
Quero deixar muito, muito claro que o que você vê acima é a noção central de injeção de dependência. Um Car
, por si só, não é inteligente o suficiente para saber de que motor precisa. Somente os engenheiros que constroem o carro entendem os requisitos de seus motores e rodas. Portanto, faz sentido que as pessoas que constroem o carro forneçam o motor específico necessário, em vez de permitir que o próprio Car
escolha o motor que deseja usar.
Eu uso a palavra”construir”especificamente porque você constrói o carro chamando o construtor, que é o local onde as dependências são injetadas. Se o carro também criou seus próprios pneus, além do motor, como sabemos se os pneus usados são seguros para girar na rotação máxima que o motor pode produzir? Por todas essas razões e mais, deve fazer sentido, talvez intuitivamente, que Car
não tenha nada a ver com a decisão de qual Motor
e quais Wheels
ele usa. Eles devem ser fornecidos a partir de algum nível superior de controle.
No último exemplo que descreve a injeção de dependência em ação, se você imaginar Engine
como uma classe abstrata em vez de concreta, isso deve fazer ainda mais sentido-o carro sabe que precisa de um motor e ele sabe que o motor precisa ter algumas funcionalidades básicas, mas como esse motor é gerenciado e qual a implementação específica dele é reservado para ser decidido e fornecido pelo pedaço de código que cria (constrói) o carro.
Um exemplo do mundo real
Veremos mais alguns exemplos práticos que, com sorte, ajudam a explicar, novamente de forma intuitiva, por que a injeção de dependência é útil. Felizmente, ao não insistir na teoria e, em vez disso, ir direto para os conceitos aplicáveis, você pode ver mais plenamente os benefícios que a injeção de dependência oferece e as dificuldades da vida sem ela. Voltaremos a um tratamento um pouco mais”acadêmico”do tópico mais tarde.
Começaremos construindo nosso aplicativo normalmente, de uma maneira altamente acoplada, sem utilizar injeção de dependência ou abstrações, para que possamos ver as desvantagens dessa abordagem e a dificuldade que ela adiciona aos testes. Ao longo do caminho, iremos refatorar gradualmente até retificar todos os problemas.
Para começar, suponha que você tenha recebido a tarefa de criar duas classes-um provedor de e-mail e uma classe para uma camada de acesso a dados que precisa ser usada por algum UserService
. Começaremos com o acesso aos dados, mas ambos são facilmente definidos:
//UserRepository.ts import {dbDriver} de'pg-driver'; export class UserRepository { public async addUser (usuário: Usuário): Promessa { //... dbDriver.save (...) } public async findUserById (id: string): Promise { //... dbDriver.query (...) } public async existsByEmail (email: string): Promise { //... dbDriver.save (...) }
}
Observação: o nome”Repositório”aqui vem do”Padrão de Repositório”, um método de desacoplar seu banco de dados de sua lógica de negócios. Você pode aprender mais sobre o Padrão de Repositório , mas para os fins deste artigo, você pode simplesmente considerá-lo como uma classe que encapsula seu banco de dados de modo que, para a lógica de negócios, seu sistema de armazenamento de dados seja tratado apenas como uma coleção na memória. Explicar completamente o Padrão do Repositório está fora do escopo deste artigo.
É assim que normalmente esperamos que as coisas funcionem, e dbDriver
está codificado no arquivo.
Em seu UserService
, você importaria a classe, instanciaria-a e começaria a usá-la:
importar {UserRepository} de'./UserRepository.ts'; class UserService { userRepository somente leitura privado: UserRepository; construtor público () { //Não é injeção de dependência. this.userRepository=new UserRepository (); } public async registerUser (dto: IRegisterUserDto): Promise { //Objeto de usuário e validação const user=User.fromDto (dto); if (await this.userRepository.existsByEmail (dto.email)) return Promise.reject (new DuplicateEmailError ()); //Persistência do banco de dados aguarde this.userRepository.addUser (usuário); //Envie um email de boas-vindas //... } public async findUserById (id: string): Promise { //Não há necessidade de esperar aqui, a promessa será desembrulhada pelo chamador. retornar this.userRepository.findUserById (id); }
}
Mais uma vez, tudo permanece normal.
Um breve aparte: Um DTO é um objeto de transferência de dados-é um objeto que atua como um saco de propriedades para definir uma forma de dados padronizada conforme se move entre dois sistemas externos ou duas camadas de um aplicativo. Você pode aprender mais sobre DTOs no artigo de Martin Fowler sobre o tópico, aqui . Nesse caso, IRegisterUserDto
define um contrato para qual deve ser a forma dos dados conforme eles saem do cliente. Eu só tenho duas propriedades- id
e email
. Você pode achar que é peculiar que o DTO que esperamos do cliente para criar um novo usuário contenha o ID do usuário, embora ainda não tenhamos criado um usuário. O ID é um UUID e permito que o cliente o gere por vários motivos, que estão fora do escopo deste artigo. Além disso, a função findUserById
deve mapear o objeto User
para um DTO de resposta, mas eu negligenciei isso por questões de brevidade. Finalmente, no mundo real, eu não teria um modelo de domínio User
contendo um método fromDto
. Isso não é bom para a pureza do domínio. Mais uma vez, seu objetivo é a brevidade aqui.
Em seguida, você deseja controlar o envio de e-mails. Mais uma vez, como de costume, você pode simplesmente criar uma classe de provedor de e-mail e importá-la para o seu UserService
.
//SendGridEmailProvider.ts import {sendMail} de'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail (para: string): Promise { //... aguarda sendMail (...); }
}
Em UserService
:
importar {UserRepository} de'./UserRepository.ts';
import {SendGridEmailProvider} de'./SendGridEmailProvider.ts'; class UserService { userRepository somente leitura privado: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; construtor público () { //Ainda não estou fazendo injeção de dependência. this.userRepository=new UserRepository (); this.sendGridEmailProvider=novo SendGridEmailProvider (); } public async registerUser (dto: IRegisterUserDto): Promise { //Objeto de usuário e validação const user=User.fromDto (dto); if (await this.userRepository.existsByEmail (dto.email)) return Promise.reject (new DuplicateEmailError ()); //Persistência do banco de dados aguarde this.userRepository.addUser (usuário); //Enviar email de boas-vindas aguarde this.sendGridEmailProvider.sendWelcomeEmail (user.email); } public async findUserById (id: string): Promise { retornar this.userRepository.findUserById (id); }
}
Agora temos uma classe trabalhadora completa e em um mundo onde não nos importamos com a testabilidade ou a escrita de código limpo por qualquer tipo de definição, e em um mundo onde o débito técnico é inexistente e um programa incômodo os gerentes não estabelecem prazos, isso é perfeitamente normal. Infelizmente, esse não é um mundo em que temos o benefício de viver.
O que acontecerá quando decidirmos que precisamos migrar do SendGrid para emails e usar o MailChimp? Da mesma forma, o que acontece quando queremos testar a unidade de nossos métodos-vamos usar o banco de dados real nos testes? Pior, vamos realmente enviar e-mails reais para endereços de e-mail potencialmente reais e pagar por isso também?
No ecossistema JavaScript tradicional, os métodos de classes de teste de unidade sob essa configuração são repletos de complexidade e excesso de engenharia. As pessoas trazem bibliotecas inteiras simplesmente para fornecer funcionalidade de stub, que adiciona todos os tipos de camadas de indireção e, pior ainda, podem acoplar diretamente os testes à implementação do sistema em teste, quando, na realidade, os testes nunca deveriam saber como o sistema real funciona (isso é conhecido como teste de caixa preta). Trabalharemos para mitigar esses problemas enquanto discutimos qual é a real responsabilidade de UserService
e aplicamos novas técnicas de injeção de dependência.
Considere, por um momento, o que um UserService
faz. O objetivo da existência de UserService
é executar casos de uso específicos envolvendo usuários-registrando-os, lendo-os, atualizando-os, etc. É uma prática recomendada para classes e funções ter apenas uma responsabilidade (SRP-o Princípio de Responsabilidade Única), e a responsabilidade de UserService
é lidar com operações relacionadas ao usuário. Por que, então, o UserService
é responsável por controlar o tempo de vida de UserRepository
e SendGridEmailProvider
neste exemplo?
Imagine se tivéssemos alguma outra classe usada por UserService
que abriu uma conexão de longa duração. O UserService
deve ser responsável por descartar essa conexão também? Claro que não. Todas essas dependências têm uma vida útil associada a elas-podem ser singletons, podem ser transitórias e com escopo para uma Solicitação HTTP específica, etc. O controle dessas vidas úteis está bem fora do alcance do UserService
. Então, para resolver esses problemas, vamos injetar todas as dependências, assim como vimos antes.
import {UserRepository} de'./UserRepository.ts';
import {SendGridEmailProvider} de'./SendGridEmailProvider.ts'; class UserService { userRepository somente leitura privado: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; construtor público ( userRepository: UserRepository, sendGridEmailProvider: SendGridEmailProvider ) { //Yay! Dependências são injetadas. this.userRepository=userRepository; this.sendGridEmailProvider=sendGridEmailProvider; } public async registerUser (dto: IRegisterUserDto): Promise { //Objeto de usuário e validação const user=User.fromDto (dto); if (await this.userRepository.existsByEmail (dto.email)) return Promise.reject (new DuplicateEmailError ()); //Persistência do banco de dados aguarde this.userRepository.addUser (usuário); //Enviar email de boas-vindas aguarde this.sendGridEmailProvider.sendWelcomeEmail (user.email); } public async findUserById (id: string): Promise { retornar this.userRepository.findUserById (id); }
}
Ótimo! Agora, o UserService
recebe objetos pré-instanciados e qualquer parte do código que chame e crie um novo UserService
é a parte do código encarregada de controlar o tempo de vida das dependências. Invertemos o controle de UserService
para um nível superior. Se eu quisesse apenas mostrar como podemos injetar dependências por meio do construtor para explicar o inquilino básico da injeção de dependência, poderia parar por aqui. Ainda existem alguns problemas de uma perspectiva de design, no entanto, que, quando corrigidos, servirão para tornar o uso da injeção de dependência ainda mais poderoso.
Em primeiro lugar, por que UserService
sabe que estamos usando o SendGrid para e-mails? Em segundo lugar, ambas as dependências estão em classes concretas-o UserRepository
concreto e o SendGridEmailProvider
concreto. Esta relação é muito rígida-estamos presos tendo que passar algum objeto que é um UserRepository
e é um SendGridEmailProvider
.
Isso não é ótimo porque queremos que o UserService
seja completamente independente da implementação de suas dependências. Tendo o UserService
cego dessa maneira, podemos trocar as implementações sem afetar o serviço de forma alguma-isso significa que, se decidirmos migrar do SendGrid e usar o MailChimp, podemos fazer isso. Também significa que, se quisermos falsificar o provedor de e-mail para testes, também podemos fazer isso.
O que seria útil é se pudéssemos definir alguma interface pública e forçar que as dependências de entrada obedecessem a essa interface, embora ainda tivéssemos UserService
independente dos detalhes de implementação. Dito de outra forma, precisamos forçar UserService
a depender apenas de uma abstração de suas dependências, e não de suas dependências concretas reais. Podemos fazer isso por meio de interfaces.
Comece definindo uma interface para o UserRepository
e implemente-o:
//UserRepository.ts import {dbDriver} de'pg-driver'; interface de exportação IUserRepository { addUser (usuário: Usuário): Promessa ; findUserById (id: string): Promise ; existeByEmail (email: string): Promise ;
} export class UserRepository implementa IUserRepository { public async addUser (usuário: Usuário): Promessa { //... dbDriver.save (...) } public async findUserById (id: string): Promise { //... dbDriver.query (...) } public async existsByEmail (email: string): Promise { //... dbDriver.save (...) }
}
E defina um para o provedor de e-mail, também implementando-o:
//IEmailProvider.ts
interface de exportação IEmailProvider { sendWelcomeEmail (para: string): Promise ;
} //SendGridEmailProvider.ts
import {sendMail} de'sendgrid';
importar {IEmailProvider} de'./IEmailProvider'; export class SendGridEmailProvider implementa IEmailProvider { public async sendWelcomeEmail (para: string): Promise { //... aguarda sendMail (...); }
}
Observação: Este é o Padrão de Adaptador da Gang of Four Design Padrões.
Agora, nosso UserService
pode depender das interfaces em vez das implementações concretas das dependências:
import {IUserRepository} de'./UserRepository.ts';
importar {IEmailProvider} de'./SendGridEmailProvider.ts'; class UserService { userRepository somente leitura privado: IUserRepository; emailProvider privado somente leitura: IEmailProvider; construtor público ( userRepository: IUserRepository, emailProvider: IEmailProvider ) { //Duplique yay! Injetando dependências e codificação em interfaces. this.userRepository=userRepository; this.emailProvider=emailProvider; } public async registerUser (dto: IRegisterUserDto): Promise { //Objeto de usuário e validação const user=User.fromDto (dto); if (await this.userRepository.existsByEmail (dto.email)) return Promise.reject (new DuplicateEmailError ()); //Persistência do banco de dados aguarde this.userRepository.addUser (usuário); //Enviar email de boas-vindas aguarde this.emailProvider.sendWelcomeEmail (user.email); } public async findUserById (id: string): Promise { retornar this.userRepository.findUserById (id); }
}
Se as interfaces são novas para você, isso pode parecer muito, muito complexo. Na verdade, o conceito de construção de software fracamente acoplado também pode ser novo para você. Pense em receptáculos de parede. Você pode conectar qualquer dispositivo a qualquer receptáculo, desde que o plugue se encaixe na tomada. Isso é acoplamento fraco em ação. Sua torradeira não está conectada à parede, porque se estivesse, e você decidir atualizá-la, está sem sorte. Em vez disso, as tomadas são usadas e a tomada define a interface. Da mesma forma, quando você conecta um dispositivo eletrônico em sua tomada de parede, você não está preocupado com o potencial de tensão, o consumo máximo de corrente, a frequência CA, etc., você apenas se preocupa se o plugue se encaixa na tomada. Você pode pedir a um eletricista que troque todos os fios atrás dessa tomada e não terá problemas para conectar sua torradeira, desde que a tomada não mude. Além disso, sua fonte de eletricidade pode ser trocada para vir da cidade ou de seus próprios painéis solares e, mais uma vez, você não se importa, contanto que ainda possa ligar a essa tomada.
A interface é a saída, fornecendo funcionalidade”plug-and-play”. Neste exemplo, a fiação na parede e a fonte de eletricidade são semelhantes às dependências e sua torradeira é semelhante ao UserService
(tem uma dependência da eletricidade)-a fonte de eletricidade pode mudar e o a torradeira ainda funciona bem e não precisa ser tocada, pois a tomada, atuando como interface, define o meio padrão para que ambas se comuniquem. Na verdade, você poderia dizer que a tomada atua como uma”abstração”da fiação da parede, dos disjuntores, da fonte elétrica etc.
É um princípio comum e bem considerado de design de software, pelas razões acima, codificar contra interfaces (abstrações) e não implementações, que é o que fizemos aqui. Ao fazer isso, temos a liberdade de trocar as implementações como quisermos, pois essas implementações estão escondidas atrás da interface (assim como a fiação da parede está escondida atrás da tomada) e, portanto, a lógica de negócios que usa a dependência nunca precisa mude, desde que a interface nunca mude. Lembre-se de que o UserService
só precisa saber qual funcionalidade é oferecida por suas dependências, não como essa funcionalidade é suportada nos bastidores. É por isso que o uso de interfaces funciona.
Essas duas mudanças simples na utilização de interfaces e na injeção de dependências fazem toda a diferença no mundo quando se trata de criar softwares fracamente acoplados e resolver todos os problemas que encontramos acima.
Se decidirmos amanhã que queremos contar com o Mailchimp para emails, simplesmente criamos uma nova classe Mailchimp que honra a interface IEmailProvider
e a injetamos em vez do SendGrid. A classe UserService
real nunca precisa mudar, embora tenhamos acabado de fazer uma mudança enorme em nosso sistema, mudando para um novo provedor de e-mail. A beleza desses padrões é que UserService
permanece felizmente inconsciente de como as dependências que usa funcionam nos bastidores. A interface serve como o limite arquitetônico entre os dois componentes, mantendo-os adequadamente desacoplados.
Além disso, quando se trata de testes, podemos criar falsificações que obedecem às interfaces e injetá-las em seu lugar. Aqui, você pode ver um repositório falso e um provedor de e-mail falso.
//Ambas as falsificações:
class FakeUserRepository implementa IUserRepository { usuários privados somente leitura: Usuário []=[]; public async addUser (usuário: Usuário): Promessa { this.users.push (usuário); } public async findUserById (id: string): Promise { const userOrNone=this.users.find (u=> u.id===id); return userOrNone ? Promise.resolve (userOrNone) : Promise.reject (new NotFoundError ()); } public async existsByEmail (email: string): Promise { return Boolean (this.users.find (u=> u.email===email)); } public getPersistedUserCount=()=> this.users.length;
} class FakeEmailProvider implementa IEmailProvider { EmailRecipients somente leitura privado: string []=[]; public async sendWelcomeEmail (para: string): Promise { this.emailRecipients.push (para); } public wasEmailSentToRecipient=(destinatário: string)=> Booleano (this.emailRecipients.find (r=> r===destinatário));
}
Observe que as duas falsificações implementam as mesmas interfaces que UserService
espera que suas dependências honrem. Agora, podemos passar essas falsificações para o UserService
em vez das classes reais e o UserService
não será o mais sábio; vai usá-los como se fossem reais. A razão pela qual ele pode fazer isso é porque ele sabe que todos os métodos e propriedades que deseja usar em suas dependências existem de fato e são acessíveis (porque implementam as interfaces), que são todos UserService
precisa saber (ou seja, não é como as dependências funcionam).
Nós injetaremos esses dois durante os testes, e isso tornará o processo de teste muito mais fácil e direto do que você está acostumado ao lidar com mocking over-the-top e bibliotecas de stub, trabalhando com As próprias ferramentas internas de Jest ou tentando fazer um monkey-patch.
Aqui estão os testes reais usando as falsificações:
//Fakes
let fakeUserRepository: FakeUserRepository;
let fakeEmailProvider: FakeEmailProvider; //SUT
deixe userService: UserService; //Queremos limpar os arrays internos de ambas as falsificações
//antes de cada teste.
beforeEach (()=> { fakeUserRepository=novo FakeUserRepository (); fakeEmailProvider=new FakeEmailProvider (); userService=novo UserService (fakeUserRepository, fakeEmailProvider);
}); //Uma fábrica para criar DTOs facilmente.
//Aqui, temos a escolha opcional de substituir os padrões
//graças ao tipo de utilitário embutido Partial
do TypeScript.
function createSeedRegisterUserDto (opta ?: Parcial ): IRegisterUserDto { Retorna { id:'someId', email: '[email protected] ', ... opta };
} test ('deve persistir corretamente um usuário e enviar um e-mail', async ()=> { //Organizar const dto=createSeedRegisterUserDto (); //Aja esperar userService.registerUser (dto); //Assert const expectUser=User.fromDto (dto); const persistedUser=await fakeUserRepository.findUserById (dto.id); const wasEmailSent=fakeEmailProvider.wasEmailSentToRecipient (dto.email); expect (persistedUser).toEqual (expectedUser); esperar (wasEmailSent).toBe (true);
}); test ('deve rejeitar com um DuplicateEmailError se um e-mail já existir', async ()=> { //Organizar const existingEmail='[email protected] '; const dto=createSeedRegisterUserDto ({email: existingEmail}); const existingUser=User.fromDto (dto); esperar fakeUserRepository.addUser (existingUser); //Act, Assert esperar, esperar (userService.registerUser (dto)) .rejects.toBeInstanceOf (DuplicateEmailError); expect (fakeUserRepository.getPersistedUserCount ()). toBe (1);
}); test ('deve retornar um usuário corretamente', async ()=> { //Organizar const user=User.fromDto (createSeedRegisterUserDto ()); esperar fakeUserRepository.addUser (usuário); //Aja const receivedUser=await userService.findUserById (user.id); //Assert expect (receivedUser).toEqual (usuário);
});
Você notará algumas coisas aqui: as falsificações escritas à mão são muito simples. Não há complexidade em estruturas de simulação que servem apenas para ofuscar. Tudo é enrolado à mão e isso significa que não há mágica na base de código. O comportamento assíncrono é simulado para corresponder às interfaces. Eu uso async/await nos testes, embora todo comportamento seja síncrono, porque sinto que ele corresponde mais a como eu esperava que as operações funcionassem no mundo real e porque adicionando async/await, posso executar este mesmo conjunto de testes contra implementações reais, além das falsificações, portanto, é necessário entregar a assincronia de forma adequada. Na verdade, na vida real, eu provavelmente nem me preocuparia em simular o banco de dados e, em vez disso, usaria um banco de dados local em um contêiner do Docker até que houvesse tantos testes que eu tivesse que simular para ver o desempenho. Eu poderia então executar os testes de banco de dados na memória após cada alteração e reservar os testes de banco de dados locais reais para o direito antes de confirmar as alterações e para o servidor de compilação no pipeline de CI/CD.
No primeiro teste, na seção”organizar”, simplesmente criamos o DTO. Na seção”agir”, chamamos o sistema em teste e executamos seu comportamento. As coisas ficam um pouco mais complexas ao fazer afirmações. Lembre-se, neste ponto do teste, nem sabemos se o usuário foi salvo corretamente. Portanto, definimos como esperamos que um usuário persistente se pareça e, em seguida, chamamos o Repositório falso e o solicitamos para um usuário com o ID que esperamos. Se o UserService
não persistir o usuário corretamente, isso lançará um NotFoundError
e o teste falhará, caso contrário, ele nos devolverá o usuário. Em seguida, ligamos para o provedor de e-mail falso e perguntamos se ele gravou o envio de um e-mail para aquele usuário. Por fim, fazemos as afirmações com Jest e isso conclui o teste. É expressivo e parece como o sistema está realmente funcionando. Não há indícios de bibliotecas de simulação e não há acoplamento à implementação do UserService
.
No segundo teste, criamos um usuário existente e o adicionamos ao repositório, então tentamos chamar o serviço novamente usando um DTO que já foi usado para criar e persistir um usuário, e esperamos que falhe. Também afirmamos que nenhum novo dado foi adicionado ao repositório.
Para o terceiro teste, a seção”organizar”agora consiste em criar um usuário e persisti-lo no Repositório falso. Em seguida, chamamos o SUT e, por fim, verificamos se o usuário que retorna é aquele que salvamos no repo anteriormente.
Esses exemplos são relativamente simples, mas quando as coisas ficam mais complexas, poder contar com injeção de dependência e interfaces dessa maneira mantém seu código limpo e torna a escrita de testes uma alegria.
Um breve aparte sobre o teste: em geral, você não precisa simular todas as dependências que o código usa. Muitas pessoas, erroneamente, afirmam que uma”unidade”em um”teste de unidade”é uma função ou uma classe. Isso não poderia estar mais incorreto. A”unidade”é definida como a”unidade de funcionalidade”ou a”unidade de comportamento”, não uma função ou classe. Portanto, se uma unidade de comportamento usa 5 classes diferentes, você não precisa simular todas essas classes a menos que elas alcancem fora do limite do módulo. Nesse caso, eu zombei do banco de dados e do provedor de e-mail porque não tenho escolha. Se eu não quiser usar um banco de dados real e não quiser enviar um e-mail, tenho que zombar deles. Mas se eu tivesse um monte de outras classes que não fazem nada em toda a rede, eu não zombaria delas porque são detalhes de implementação da unidade de comportamento. Eu também poderia decidir não simular o banco de dados e e-mails e ativar um banco de dados local real e um servidor SMTP real, ambos em contêineres Docker. No primeiro ponto, não tenho nenhum problema em usar um banco de dados real e ainda chamá-lo de teste de unidade, desde que não seja muito lento. Geralmente, eu usaria o banco de dados real primeiro até que se tornasse muito lento e eu tivesse que fazer uma simulação, conforme discutido acima. Mas, não importa o que você faça, você tem que ser pragmático-enviar e-mails de boas-vindas não é uma operação crítica, portanto, não precisamos ir tão longe em termos de servidores SMTP em contêineres Docker. Whenever I do mock, I would be very unlikely to use a mocking framework or try to assert on the number of times called or parameters passed except in very rare cases, because that would couple tests to the implementation of the system under test, and they should be agnostic to those details.
Performing Dependency Injection Without Classes And Constructors
So far, throughout the article, we’ve worked exclusively with classes and injected the dependencies through the constructor. If you’re taking a functional approach to development and wish not to use classes, one can still obtain the benefits of dependency injection using function arguments. For example, our UserService
class above could be refactored into:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider
): IUserService { return { registerUser: async dto=> { //... }, findUserById: id=> userRepository.findUserById(id) }
}
It’s a factory that receives the dependencies and constructs the service object. We can also inject dependencies into Higher Order Functions. A typical example would be creating an Express Middleware function that gets a UserRepository
and an ILogger
injected:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction)=> { //... //Has access to userRepository, logger, req, res, and next. }
}
In the first example, I didn’t define the type of dto
and id
because if we define an interface called IUserService
containing the method signatures for the service, then the TS Compiler will infer the types automatically. Similarly, had I defined a function signature for the Express Middleware to be the return type of authProvider
, I wouldn’t have had to declare the argument types there either.
If we considered the email provider and the repository to be functional too, and if we injected their specific dependencies as well instead of hard coding them, the root of the application could look like this:
import { sendMail } from'sendgrid'; async function main() { const app=express (); const dbConnection=await connectToDatabase(); //Change emailProvider to makeMailChimpEmailProvider
whenever we want //with no changes made to dependent code. const userRepository=makeUserRepository(dbConnection); const emailProvider=makeSendGridEmailProvider(sendMail); const userService=makeUserService(userRepository, emailProvider); //Put this into another file. It’s a controller action. app.post('/login', (req, res)=> { await userService.registerUser(req.body as IRegisterUserDto); return res.send(); }); //Put this into another file. It’s a controller action. app.delete( '/me', authProvider(userRepository, emailProvider), (req, res)=> {... } );
}
Notice that we fetch the dependencies that we need, like a database connection or third-party library functions, and then we utilize factories to make our first-party dependencies using the third-party ones. We then pass them into the dependent code. Since everything is coded against abstractions, I can swap out either userRepository
or emailProvider
to be any different function or class with any implementation I want (that still implements the interface correctly) and UserService
will just use it with no changes needed, which, once again, is because UserService
cares about nothing but the public interface of the dependencies, not how the dependencies work.
As a disclaimer, I want to point out a few things. As stated earlier, this demo was optimized for showing how dependency injection makes life easier, and thus it wasn’t optimized in terms of system design best practices insofar as the patterns surrounding how Repositories and DTOs should technically be used. In real life, one has to deal with managing transactions across repositories and the DTO should generally not be passed into service methods, but rather mapped in the controller to allow the presentation layer to evolve separately from the application layer. The userSerivce.findById
method here also neglects to map the User domain object to a DTO, which it should do in real life. None of this affects the DI implementation though, I simply wanted to keep the focus on the benefits of DI itself, not Repository design, Unit of Work management, or DTOs. Finally, although this may look a little like the NestJS framework in terms of the manner of doing things, it’s not, and I actively discourage people from using NestJS for reasons outside the scope of this article.
A Brief Theoretical Overview
All applications are made up of collaborating components, and the manner in which those collaborators collaborate and are managed will decide how much the application will resist refactoring, resist change, and resist testing. Dependency injection mixed with coding against interfaces is a primary method (among others) of reducing the coupling of collaborators within systems, and making them easily swappable. This is the hallmark of a highly cohesive and loosely coupled design.
The individual components that make up applications in non-trivial systems must be decoupled if we want the system to be maintainable, and the way we achieve that level of decoupling, as stated above, is by depending upon abstractions, in this case, interfaces, rather than concrete implementations, and utilizing dependency injection. Doing so provides loose coupling and gives us the freedom of swapping out implementations without needing to make any changes on the side of the dependent component/collaborator and solves the problem that dependent code has no business managing the lifetime of its dependencies and shouldn’t know how to create them or dispose of them.
Despite the simplicity of what we’ve seen thus far, there’s a lot more complexity that surrounds dependency injection.
Injection of dependencies can come in many forms. Constructor Injection is what we have been using here since dependencies are injected into a constructor. There also exists Setter Injection and Interface Injection. In the case of the former, the dependent component will expose a setter method which will be used to inject the dependency — that is, it could expose a method like setUserRepository(userRepository: UserRepository)
. In the last case, we can define interfaces through which to perform the injection, but I’ll omit the explanation of the last technique here for brevity since we’ll spend more time discussing it and more in the second article of this series.
Because wiring up dependencies manually can be difficult, various IoC Frameworks and Containers exist. These containers store your dependencies and resolve the correct ones at runtime, often through Reflection in languages like C# or Java, exposing various configuration options for dependency lifetime. Despite the benefits that IoC Containers provide, there are cases to be made for moving away from them, and only resolving dependencies manually. To hear more about this, see Greg Young’s 8 Lines of Code talk.
Additionally, DI Frameworks and IoC Containers can provide too many options, and many rely on decorators or attributes to perform techniques such as setter or field injection. I look down on this kind of approach because, if you think about it intuitively, the point of dependency injection is to achieve loose coupling, but if you begin to sprinkle IoC Container-specific decorators all over your business logic, while you may have achieved decoupling from the dependency, you’ve inadvertently coupled yourself to the IoC Container. IoC Containers like Awilix solve this problem since they remain divorced from your application’s business logic.
Conclusion
This article served to depict only a very practical example of dependency injection in use and mostly neglected the theoretical attributes. I did it this way in order to make it easier to understand what dependency injection is at its core in a manner divorced from the rest of the complexity that people usually associate with the concept.
In the second article of this series, we’ll take a much, much more in-depth look, including at:
- The difference between Dependency Injection and Dependency Inversion and Inversion of Control;
- Dependency Injection anti-patterns;
- IoC Container anti-patterns;
- The role of IoC Containers;
- The different types of dependency lifetimes;
- How IoC Containers are designed;
- Dependency Injection with React;
- Advanced testing scenarios;
- And more.
Stay tuned!