Aqui está uma classe que você não consegue testar com um teste unitário sem um banco de dados ativo e um servidor SMTP funcionando:
| |
OrderService cria o próprio SqlOrderRepository e SmtpEmailSender com new, connection string e tudo mais. Todo teste que toca PlaceOrder precisa de um banco de dados Orders real e um servidor de e-mail real, ou falha antes mesmo da sua assertion rodar. A lógica que você quer testar tem duas linhas: salvar o pedido, enviar a confirmação. Ela está soldada a duas classes concretas que você não consegue trocar.
O que dependency injection significa na prática
Dependency injection é entregar a uma classe os objetos de que ela precisa, em vez de deixá-la construí-los. A classe declara do que depende. O código fora dela decide qual implementação concreta ela recebe. Containers e lifetimes são ferramental construído em torno desse único movimento.
Reescreva OrderService para receber suas dependências pelo constructor, como interfaces em vez de classes concretas:
| |
OrderService não sabe mais se o repository grava no SQL Server ou numa lista em memória, nem se o e-mail sai via SMTP ou não sai para lugar nenhum. Ela conhece duas interfaces e chama os métodos delas. Quem cria o OrderService decide o que entra na jogada: um teste passa fakes, a aplicação web passa a implementação real.
Isso é constructor injection, e é a forma que você vai usar quase sempre. Property injection (atribuir uma propriedade pública depois da construção) e method injection (passar uma dependência só para o método que precisa dela) existem para dependências opcionais e valores pontuais. O container embutido do ASP.NET Core nem sequer suporta property injection. A regra é curta: se uma classe não consegue fazer o seu trabalho sem algo, esse algo vai no constructor.
Como dependency injection facilita testar uma classe
Com as dependências chegando como parâmetros de interface, um teste pode entregar a OrderService o que quiser:
| |
O teste roda em milissegundos e não toca banco de dados nem rede. Ele fica na ponta unitária do espectro descrito em Teste Unitário vs Teste de Integração vs End-to-End: uma classe, colaboradores substituídos, um comportamento verificado. Essas classes escritas à mão são fakes, implementações pequenas e funcionais com estado próprio. Uma biblioteca de mocking como Moq ou NSubstitute gera esse mesmo tipo de substituto para você, sem precisar escrever uma classe por interface.
O que um DI container faz, e por que você não precisa de um
Nada do que vimos até agora exigiu um framework. Você pode conectar as dependências reais manualmente no ponto de entrada da sua aplicação:
| |
Isso é chamado de “pure DI” (ou “poor man’s DI”), e para uma aplicação pequena já é suficiente. O padrão é passar as dependências para dentro. Um container automatiza essa conexão, e começa a se pagar quando você tem dezenas de classes com dependências que se sobrepõem, e mudar um constructor significa editar o código de montagem em cinco lugares.
O ASP.NET Core já vem com um container embutido. Você registra cada interface contra uma implementação uma única vez, no startup, e o framework resolve o grafo sempre que constrói um controller, um handler de minimal API ou qualquer outro serviço registrado:
| |
SqlOrderRepository e SmtpEmailSender recebem uma connection string e um nome de host, não um constructor sem parâmetros, então cada registro é uma factory: uma lambda que recebe o container (sp) e retorna a instância. Quando nenhuma das implementações precisa de um valor assim, a forma com dois type arguments é mais curta: AddScoped<IOrderRepository, SqlOrderRepository>().
Registre o próprio OrderService da mesma forma (builder.Services.AddScoped<OrderService>()) e você pode pedi-lo no constructor de um controller. O container o constrói, resolve IOrderRepository e IEmailSender a partir desses registros, e você nunca chama new.
Lifetimes de serviço no ASP.NET Core: singleton, scoped, transient
AddScoped, AddTransient e AddSingleton registram da mesma forma; o nome do método é o lifetime, e escolher o errado causa a maioria dos bugs de DI no ASP.NET Core.
| Lifetime | Nova instância criada | Uso típico |
|---|---|---|
Transient | Toda vez que é solicitada | Serviços sem estado e baratos (um formatter, um validator) |
Scoped | Uma vez por requisição HTTP | Qualquer coisa ligada à requisição atual: um DbContext, uma unit of work |
Singleton | Uma vez, durante a vida da aplicação | Estado compartilhado e thread-safe: configuração, um connection pool, um cache |
O registro AddScoped acima dá a cada requisição seu próprio SqlOrderRepository. Se o repository envolve um DbContext, duas requisições concorrentes nunca conseguem compartilhar uma transação pela metade. O registro AddTransient cria um novo SmtpEmailSender em cada ponto de injeção. Isso não é problema, porque o sender não guarda nenhum estado por requisição.
Captive dependencies: um serviço scoped preso dentro de um singleton
Essa é a regra de lifetime que costuma morder. Um serviço só pode depender com segurança de coisas que vivem pelo menos tanto quanto ele. Um singleton pode receber outros singletons. Um serviço scoped pode receber scoped ou singleton. Um transient pode receber qualquer coisa. A falha acontece quando um singleton recebe uma dependência scoped no constructor:
| |
O container constrói ReportCache uma única vez, e o AppDbContext que ele recebeu fica preso naquele campo durante toda a vida da aplicação. Isso é uma captive dependency. Toda requisição seguinte compartilha o mesmo DbContext, uma classe que não é thread-safe nem foi feita para viver muito tempo. Isso aparece como leituras desatualizadas, ou como InvalidOperationExceptions reclamando que uma segunda operação começou nesse context antes de uma anterior terminar.
O ASP.NET Core pega esse erro ainda em desenvolvimento. Com a scope validation ativada, o padrão no ambiente Development, resolver um singleton que precisa de um serviço scoped lança uma InvalidOperationException no startup, em vez de corromper dados em produção. Se um singleton realmente precisa de um serviço scoped de vez em quando, injete IServiceScopeFactory, crie um scope onde for necessário, e resolva o serviço a partir desse scope em vez de guardá-lo num campo.
Anti-patterns de dependency injection: service locator e construtores inchados
Service locator disfarçado. Injetar o próprio container e dar pull nas dependências de dentro dele num método parece DI, mas joga fora o que o DI oferece:
| |
O constructor não diz mais do que OrderService precisa. Você tem que ler cada método para descobrir, e um registro ausente aparece como um null no meio de PlaceOrder, em vez de uma falha no momento em que a classe é construída. Injete as interfaces específicas que você usa, não o objeto que as distribui.
O constructor de doze parâmetros. Uma classe que precisa de IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger e mais sete está fazendo trabalho demais. O DI não causou isso; só tornou visível. É o mesmo sintoma descrito em O Que É Spaghetti Code e Como Refatorá-lo. Divida a classe por responsabilidade e o constructor encolhe sozinho. Empacotar as doze dependências num único objeto “services” esconde o problema sem resolvê-lo.
Essas duas coisas passam despercebidas pelo code review com facilidade. Param de passar na primeira vez que alguém tenta escrever um teste unitário para a classe.
Por onde começar com dependency injection
Escolha uma classe no seu codebase que chama new num cliente de banco de dados, num HTTP client ou num mail sender. Mude o constructor dela para receber uma interface, conecte a implementação real manualmente em Program.cs, e escreva um teste que passe um fake. Numa aplicação ASP.NET Core, pule a conexão manual e registre direto em builder.Services, já que o container já está lá. Escolha o lifetime de propósito para qualquer coisa que guarde estado, e deixe a scope validation ativada para que o framework pegue uma captive dependency antes que seus usuários peguem.