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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository("Server=prod-db;Database=Orders;...");
    private readonly SmtpEmailSender _emailSender = new SmtpEmailSender("smtp.company.com");

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailSender _emailSender;

    public OrderService(IOrderRepository repository, IEmailSender emailSender)
    {
        _repository = repository;
        _emailSender = emailSender;
    }

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FakeOrderRepository : IOrderRepository
{
    public List<Order> SavedOrders { get; } = new();
    public void Save(Order order) => SavedOrders.Add(order);
}

public class FakeEmailSender : IEmailSender
{
    public List<string> SentTo { get; } = new();
    public void Send(string to, string subject) => SentTo.Add(to);
}

[Fact]
public void PlaceOrder_SavesOrderAndSendsConfirmation()
{
    var repository = new FakeOrderRepository();
    var emailSender = new FakeEmailSender();
    var service = new OrderService(repository, emailSender);

    service.PlaceOrder(new Order { CustomerEmail = "[email protected]" });

    Assert.Single(repository.SavedOrders);
    Assert.Equal("[email protected]", emailSender.SentTo.Single());
}

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:

1
2
3
var repository = new SqlOrderRepository(connectionString);
var emailSender = new SmtpEmailSender(smtpHost);
var orderService = new OrderService(repository, emailSender);

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:

1
2
3
4
5
6
7
8
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository>(sp =>
    new SqlOrderRepository(builder.Configuration.GetConnectionString("Orders")));
builder.Services.AddTransient<IEmailSender>(sp =>
    new SmtpEmailSender(builder.Configuration["Smtp:Host"]));

var app = builder.Build();

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.

LifetimeNova instância criadaUso típico
TransientToda vez que é solicitadaServiços sem estado e baratos (um formatter, um validator)
ScopedUma vez por requisição HTTPQualquer coisa ligada à requisição atual: um DbContext, uma unit of work
SingletonUma vez, durante a vida da aplicaçãoEstado 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:

1
2
3
4
5
6
7
8
9
public class ReportCache // registrado como singleton
{
    private readonly AppDbContext _db; // registrado como scoped

    public ReportCache(AppDbContext db)
    {
        _db = db;
    }
}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class OrderService
{
    private readonly IServiceProvider _provider;

    public OrderService(IServiceProvider provider)
    {
        _provider = provider;
    }

    public void PlaceOrder(Order order)
    {
        var repository = _provider.GetService<IOrderRepository>();
        repository.Save(order);
    }
}

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.

Artigos relacionados