Uma introdução prática à injeção de dependência
Publicados: 2022-03-10O conceito de injeção de dependência é, em sua essência, uma noção fundamentalmente simples. É, no entanto, comumente apresentado de maneira ao lado dos conceitos mais teóricos de Inversão de Controle, Inversão de Dependência, Princípios SOLID 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, descrevendo exemplos que mostram precisamente os benefícios de seu uso, de uma maneira principalmente divorciada da teoria associada.
Vamos gastar apenas um pouco de 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. De fato, livros inteiros podem ser e foram escritos que fornecem 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 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 de arquitetura 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 perguntas sábias e razoáveis seriam “como você define 'dependência'?”, “o que significa 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 { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
Mas isso é injeção de dependência:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
Feito. É isso. Frio. O fim.
O que mudou? Em vez de permitir que a classe Car
instanciasse Engine
(como fez no primeiro exemplo), no segundo exemplo, Car
tinha uma instância de Engine
passada - ou injetada - de algum nível de controle mais alto para seu construtor. É isso. Em sua essência, isso é tudo que a injeção de dependência é – o ato de injetar (passar) uma dependência em outra classe ou função. Qualquer outra coisa que envolva a noção de injeção de dependência é simplesmente uma variação desse conceito fundamental e simples. Colocado de forma trivial, a 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 ele mesmo.
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 seja, A
tem uma dependência de B
. Claro, isso não se limita a classes e vale para funções também. Nesse caso, a classe Car
tem uma dependência 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 motor real está sempre sendo usado. Mas, neste último caso, temos controle sobre o Engine
que é usado, o que significa que, em um teste, podemos subclassificar Engine
e sobrescrever seus métodos.
Por exemplo, se quiséssemos ver o que Car.startEngine()
faz se engine.fireCylinders()
um erro, poderíamos simplesmente criar uma classe FakeEngine
, fazer com que ela estenda a classe Engine
e, em seguida, sobrescreva fireCylinders
para fazê-la gerar um erro . No teste, podemos injetar esse objeto FakeEngine
no construtor de Car
. Como FakeEngine
é um Engine
por implicação de herança, o sistema de tipos TypeScript é satisfeito. Usar herança e substituição de método não seria necessariamente a melhor maneira de fazer isso, como veremos mais adiante, mas certamente é uma opção.
Eu 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 qual motor precisa. Somente os engenheiros que constroem o carro entendem os requisitos para seus motores e rodas. Assim, faz sentido que as pessoas que constroem o carro forneçam o motor específico necessário, em vez de deixar o próprio Car
escolher o motor que deseja usar.
Eu uso a palavra “construir” especificamente porque você constrói o carro chamando o construtor, que é o lugar onde as dependências são injetadas. Se o carro também criou seus próprios pneus além do motor, como sabemos que os pneus usados são seguros para serem girados na RPM máxima que o motor pode produzir? Por todas essas e outras razões, deve fazer sentido, talvez intuitivamente, que o Car
não tenha nada a ver com decidir qual Engine
e quais Wheels
ele usa. Eles devem ser fornecidos a partir de algum nível mais alto 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 uma concreta, isso deve fazer ainda mais sentido - o carro sabe que precisa de um motor e sabe que o motor precisa ter alguma funcionalidade básica , 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 esperamos ajudar a explicar, novamente de forma intuitiva, por que a injeção de dependência é útil. Espero que, ao não insistir na teoria e, em vez disso, ir direto para os conceitos aplicáveis, você possa 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 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 ao teste. Ao longo do caminho, refatoramos gradualmente até corrigirmos todos os problemas.
Para começar, suponha que você tenha recebido a tarefa de criar duas classes — um provedor de email e uma classe para uma camada de acesso a dados que precisa ser usada por algum UserService
. Começaremos com o acesso a dados, mas ambos são facilmente definidos:
// UserRepository.ts import { dbDriver } from 'pg-driver'; export class UserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
Nota: 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 Repository Pattern, mas para os propósitos deste artigo, você pode simplesmente considerá-lo como uma classe que encapsula seu banco de dados para que, para a lógica de negócios, seu sistema de armazenamento de dados seja tratado apenas como um in-memory. coleção. A explicação completa do Padrão de Repositório está fora do escopo deste artigo.
É assim que normalmente esperamos que as coisas funcionem, e dbDriver
é codificado dentro do arquivo.
Em seu UserService
, você importaria a classe, instanciaria e começaria a usá-la:
import { UserRepository } from './UserRepository.ts'; class UserService { private readonly userRepository: UserRepository; public constructor () { // Not dependency injection. this.userRepository = new UserRepository(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send a welcome email // ... } public async findUserById(id: string): Promise<User> { // No need for await here, the promise will be unwrapped by the caller. return this.userRepository.findUserById(id); } }
Mais uma vez, tudo continua normal.
Um breve aparte: um DTO é um objeto de transferência de dados — é um objeto que atua como uma bolsa de propriedades para definir uma forma de dados padronizada à medida que 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 assunto, aqui. Nesse caso, IRegisterUserDto
define um contrato para qual deve ser a forma dos dados à medida que surgem do cliente. Eu só tenho duas propriedades - id
e email
. Você pode pensar 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 eu 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 brevidade. Finalmente, no mundo real, eu não teria um modelo de domínio de User
contendo um método fromDto
. Isso não é bom para a pureza do domínio. Mais uma vez, seu propósito é a brevidade aqui.
Em seguida, você deseja lidar com o envio de e-mails. Mais uma vez, como de costume, você pode simplesmente criar uma classe de provedor de email e importá-la para seu UserService
.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Dentro do UserService
:
import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor () { // Still not doing dependency injection. this.userRepository = new UserRepository(); this.sendGridEmailProvider = new SendGridEmailProvider(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Agora temos uma classe totalmente trabalhadora, e em um mundo onde não nos importamos com testabilidade ou escrever código limpo por qualquer tipo de definição, e em um mundo onde a dívida técnica é inexistente e os gerentes de programas irritantes não t definir prazos, isso está perfeitamente bem. Infelizmente, esse não é um mundo em que temos o benefício de viver.
O que acontece quando decidimos que precisamos migrar do SendGrid para e-mails e usar o MailChimp? Da mesma forma, o que acontece quando queremos testar nossos métodos de unidade – 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 nessa configuração são repletos de complexidade e engenharia excessiva. As pessoas trazem bibliotecas inteiras simplesmente para fornecer funcionalidade de stubbing, que adiciona todos os tipos de camadas de indireção e, pior ainda, pode 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 do UserService
e aplicamos novas técnicas de injeção de dependência.
Considere, por um momento, o que um UserService
faz. O ponto principal da existência do UserService
é executar casos de uso específicos envolvendo usuários — registrá-los, lê-los, atualizá-los etc. e a responsabilidade do UserService
é lidar com as operações relacionadas ao usuário. Por que, então, UserService
é responsável por controlar o tempo de vida de UserRepository
e SendGridEmailProvider
neste exemplo?
Imagine se tivéssemos alguma outra classe usada pelo UserService
que abrisse uma conexão de longa duração. O UserService
também deve ser responsável por descartar essa conexão? Claro que não. Todas essas dependências têm um tempo de vida associado a elas — elas podem ser singletons, podem ser transitórias e ter como escopo uma solicitação HTTP específica, etc. O controle desses tempos de vida 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 } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor ( userRepository: UserRepository, sendGridEmailProvider: SendGridEmailProvider ) { // Yay! Dependencies are injected. this.userRepository = userRepository; this.sendGridEmailProvider = sendGridEmailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Excelente! Agora UserService
recebe objetos pré-instanciados, e qualquer pedaço de código que chama e cria um novo UserService
é o pedaço de código responsável por controlar o tempo de vida das dependências. Invertemos o controle do UserService
para um nível superior. Se eu apenas quisesse mostrar como poderíamos injetar dependências através do construtor para explicar o inquilino básico da injeção de dependência, eu poderia parar por aqui. No entanto, ainda existem alguns problemas do ponto de vista do design que, quando corrigidos, servirão para tornar nosso uso de injeção de dependência ainda mais poderoso.
Em primeiro lugar, por que o 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. Esse relacionamento é muito rígido — estamos presos tendo que passar algum objeto que é um UserRepository
e é um SendGridEmailProvider
.
Isso não é ótimo porque queremos que UserService
seja completamente independente da implementação de suas dependências. Ao deixar o UserService
cego dessa maneira, podemos trocar as implementações sem afetar o serviço - isso significa que, se decidirmos migrar do SendGrid e usar o MailChimp, podemos fazê-lo. 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, enquanto ainda UserService
sendo agnóstico aos 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 através de interfaces.
Comece definindo uma interface para o UserRepository
e implemente-a:
// UserRepository.ts import { dbDriver } from 'pg-driver'; export interface IUserRepository { addUser(user: User): Promise<void>; findUserById(id: string): Promise<User>; existsByEmail(email: string): Promise<boolean>; } export class UserRepository implements IUserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }
E defina um para o provedor de e-mail, implementando-o também:
// IEmailProvider.ts export interface IEmailProvider { sendWelcomeEmail(to: string): Promise<void>; } // SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; import { IEmailProvider } from './IEmailProvider'; export class SendGridEmailProvider implements IEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Nota: Este é o padrão de adaptador do Gang of Four Design Patterns.
Agora, nosso UserService
pode depender das interfaces em vez das implementações concretas das dependências:
import { IUserRepository } from './UserRepository.ts'; import { IEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: IUserRepository; private readonly emailProvider: IEmailProvider; public constructor ( userRepository: IUserRepository, emailProvider: IEmailProvider ) { // Double yay! Injecting dependencies and coding against interfaces. this.userRepository = userRepository; this.emailProvider = emailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.emailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }
Se as interfaces são novas para você, isso pode parecer muito, muito complexo. De fato, o conceito de construir software fracamente acoplado pode ser novo para você também. Pense em recipientes de parede. Você pode conectar qualquer dispositivo em qualquer receptáculo, desde que o plugue se encaixe na tomada. Isso é o acoplamento fraco em ação. Sua torradeira não está conectada à parede, porque se estivesse, e você decidir atualizar sua torradeira, você está sem sorte. Em vez disso, as saídas são usadas e a saída 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ê só se importa se o plugue se encaixa na tomada. Você pode chamar um eletricista e trocar todos os fios por trás dessa tomada, e você não terá problemas para conectar sua torradeira, desde que essa tomada não mude. Além disso, sua fonte de eletricidade pode ser alterada para vir da cidade ou de seus próprios painéis solares e, mais uma vez, você não se importa, desde que ainda possa conectar a essa tomada.
A interface é a tomada, fornecendo a 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
(depende da eletricidade) — a fonte de eletricidade pode mudar e a torradeira ainda funciona bem e não precisa ser tocado, porque a saída, atuando como interface, define o meio padrão para que ambos se comuniquem. De fato, pode-se 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 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 mudar desde que a interface nunca mude. Lembre-se, 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 de utilizar interfaces e injetar dependências fazem toda a diferença no mundo quando se trata de construir software fracamente acoplado e resolve todos os problemas que encontramos acima.
Se decidirmos amanhã que queremos confiar no Mailchimp para e-mails, simplesmente criamos uma nova classe Mailchimp que respeita a interface IEmailProvider
e a injetamos em vez do SendGrid. A classe UserService
real nunca precisa mudar, mesmo que tenhamos feito uma mudança enorme em nosso sistema, mudando para um novo provedor de e-mail. A beleza desses padrões é que UserService
permanece alegremente inconsciente de como as dependências que ele 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. Aqui, você pode ver um repositório falso e um provedor de e-mail falso.
// Both fakes: class FakeUserRepository implements IUserRepository { private readonly users: User[] = []; public async addUser(user: User): Promise<void> { this.users.push(user); } public async findUserById(id: string): Promise<User> { const userOrNone = this.users.find(u => u.id === id); return userOrNone ? Promise.resolve(userOrNone) : Promise.reject(new NotFoundError()); } public async existsByEmail(email: string): Promise<boolean> { return Boolean(this.users.find(u => u.email === email)); } public getPersistedUserCount = () => this.users.length; } class FakeEmailProvider implements IEmailProvider { private readonly emailRecipients: string[] = []; public async sendWelcomeEmail(to: string): Promise<void> { this.emailRecipients.push(to); } public wasEmailSentToRecipient = (recipient: string) => Boolean(this.emailRecipients.find(r => r === recipient)); }
Observe que ambas as falsificações implementam as mesmas interfaces que UserService
espera que suas dependências honrem. Agora, podemos passar essas falsificações para UserService
em vez das classes reais e UserService
não será mais sábio; ele vai usá-los como se fossem o negócio real. 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 de fato existem e são realmente acessíveis (porque eles implementam as interfaces), que é tudo que o UserService
precisa saber (ou seja, não como as dependências funcionam).
Vamos injetar esses dois durante os testes, e isso tornará o processo de teste muito mais fácil e direto do que você pode estar acostumado ao lidar com bibliotecas de mocking e stubbing exageradas, trabalhando com as próprias bibliotecas internas do Jest. ferramental, ou tentando fazer um patch de macaco.
Aqui estão os testes reais usando as falsificações:
// Fakes let fakeUserRepository: FakeUserRepository; let fakeEmailProvider: FakeEmailProvider; // SUT let userService: UserService; // We want to clean out the internal arrays of both fakes // before each test. beforeEach(() => { fakeUserRepository = new FakeUserRepository(); fakeEmailProvider = new FakeEmailProvider(); userService = new UserService(fakeUserRepository, fakeEmailProvider); }); // A factory to easily create DTOs. // Here, we have the optional choice of overriding the defaults // thanks to the built in `Partial` utility type of TypeScript. function createSeedRegisterUserDto(opts?: Partial<IRegisterUserDto>): IRegisterUserDto { return { id: 'someId', email: '[email protected]', ...opts }; } test('should correctly persist a user and send an email', async () => { // Arrange const dto = createSeedRegisterUserDto(); // Act await userService.registerUser(dto); // Assert const expectedUser = User.fromDto(dto); const persistedUser = await fakeUserRepository.findUserById(dto.id); const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient(dto.email); expect(persistedUser).toEqual(expectedUser); expect(wasEmailSent).toBe(true); }); test('should reject with a DuplicateEmailError if an email already exists', async () => { // Arrange const existingEmail = '[email protected]'; const dto = createSeedRegisterUserDto({ email: existingEmail }); const existingUser = User.fromDto(dto); await fakeUserRepository.addUser(existingUser); // Act, Assert await expect(userService.registerUser(dto)) .rejects.toBeInstanceOf(DuplicateEmailError); expect(fakeUserRepository.getPersistedUserCount()).toBe(1); }); test('should correctly return a user', async () => { // Arrange const user = User.fromDto(createSeedRegisterUserDto()); await fakeUserRepository.addUser(user); // Act const receivedUser = await userService.findUserById(user.id); // Assert expect(receivedUser).toEqual(user); });
Você notará algumas coisas aqui: As falsificações escritas à mão são muito simples. Não há complexidade em estruturas de zombaria que servem apenas para ofuscar. Tudo é feito à mão e isso significa que não há mágica na base de código. O comportamento assíncrono é falsificado para corresponder às interfaces. Eu uso async/await nos testes, mesmo que todo o comportamento seja síncrono, porque sinto que ele se aproxima mais de como eu esperava que as operações funcionassem no mundo real e porque, adicionando async/await, posso executar esse mesmo conjunto de testes contra implementações reais também, além das falsificações, portanto, é necessário entregar a assincronia adequadamente. Na verdade, na vida real, eu provavelmente nem me preocuparia em zombar do 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 zombar dele para obter 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 antes de confirmar as alterações e no servidor de compilação no pipeline de CI/CD.
No primeiro teste, na seção “arranjar”, simplesmente criamos o DTO. Na seção “act”, 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. Então, definimos como esperamos que um usuário persistente se pareça e, em seguida, chamamos o repositório falso e solicitamos 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 esse usuário. Por fim, fazemos as afirmações com Jest e isso conclui o teste. É expressivo e lê exatamente como o sistema está realmente funcionando. Não há indireção de bibliotecas simuladas e não há acoplamento com a implementação do UserService
.
No segundo teste, criamos um usuário existente e o adicionamos ao repositório, depois 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 dado novo foi adicionado ao repositório.
Para o terceiro teste, a seção “arranjar” 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 volta é aquele que salvamos no repositório 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 testes: 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 unitário” é 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 ultrapassem o limite do módulo. Nesse caso, zombei do banco de dados e zombei 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 classes que não fizessem nada na rede, eu não iria zombar delas porque são detalhes de implementação da unidade de comportamento. Eu também poderia decidir não zombar do banco de dados e dos e-mails e criar um banco de dados local real e um servidor SMTP real, ambos em contêineres do 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 zombar, conforme discutido acima. Mas, não importa o que você faça, você precisa ser pragmático — enviar emails de boas-vindas não é uma operação de missão crítica, portanto, não precisamos ir tão longe em termos de servidores SMTP em contêineres do Docker. Sempre que eu faço mocks, é muito improvável que eu use um framework de mocking ou tente afirmar o número de vezes chamadas ou parâmetros passados, exceto em casos muito raros, porque isso acoplaria testes à implementação do sistema em teste, e eles deve ser agnóstico a esses detalhes.
Executando injeção de dependência sem classes e construtores
Até agora, ao longo do artigo, trabalhamos exclusivamente com classes e injetamos as dependências por meio do construtor. Se você está adotando uma abordagem funcional para o desenvolvimento e deseja não usar classes, ainda é possível obter os benefícios da injeção de dependência usando argumentos de função. Por exemplo, nossa classe UserService
acima pode ser refatorada em:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
É uma fábrica que recebe as dependências e constrói o objeto de serviço. Também podemos injetar dependências em funções de ordem superior. Um exemplo típico seria criar uma função Express Middleware que obtém um UserRepository
e um ILogger
injetados:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
No primeiro exemplo, eu não defini o tipo de dto
e id
porque se definirmos uma interface chamada IUserService
contendo as assinaturas de método para o serviço, então o TS Compiler irá inferir os tipos automaticamente. Da mesma forma, se eu tivesse definido uma assinatura de função para o Express Middleware para ser o tipo de retorno de authProvider
, eu também não teria que declarar os tipos de argumento lá.
Se considerarmos o provedor de e-mail e o repositório funcionais também, e se injetarmos suas dependências específicas em vez de codificá-las, a raiz do aplicativo pode ficar assim:
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. This doesn't mean that everything should be injected and no collaborators should ever be directly coupled to each other. There are certainly many cases where having that direct coupling is no problem at all, such as with utilities, mappers, models, and more.
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 by Jeff Hansen solve this problem since they remain divorced from your application's business logic.
Conclusão
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;
- E mais.
Fique ligado!