Aquí tienes una clase que no puedes probar con un unit test sin una base de datos real y un 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 construye su propio SqlOrderRepository y SmtpEmailSender con new, con la connection string incluida. Cada test que toca PlaceOrder necesita una base de datos Orders real y un servidor de correo real, o falla antes de que se ejecute tu assertion. La lógica que quieres probar son dos líneas: guardar el pedido, enviar la confirmación. Está soldada a dos clases concretas que no puedes cambiar.

Qué significa dependency injection en la práctica

Dependency injection consiste en entregarle a una clase los objetos que necesita en vez de dejar que los construya ella misma. La clase declara de qué depende. El código externo decide qué implementación concreta recibe. Los containers y los lifetimes son herramientas construidas alrededor de ese único movimiento.

Reescribe OrderService para que reciba sus dependencias por el constructor, como interfaces en lugar de clases 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 ya no sabe si el repositorio escribe en SQL Server o en una lista en memoria, ni si el correo sale por SMTP o no sale a ningún lado. Conoce dos interfaces y llama a sus métodos. Quien crea el OrderService decide qué se conecta ahí: un test le pasa fakes, la aplicación web le pasa lo real.

Esto es constructor injection, y es la forma que vas a usar para casi todo. La property injection (asignar una propiedad pública después de construir el objeto) y la method injection (pasar una dependencia solo al método que la necesita) existen para dependencias opcionales y valores puntuales. El container integrado de ASP.NET Core ni siquiera soporta property injection. La regla es corta: si una clase no puede hacer su trabajo sin algo, ese algo va en el constructor.

Cómo la dependency injection hace que una clase sea fácil de testear

Con las dependencias llegando como parámetros de interfaz, un test puede entregarle a OrderService lo que quiera:

 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());
}

El test corre en milisegundos y no toca ninguna base de datos ni red. Se ubica en el extremo unitario del espectro descrito en Pruebas Unitarias vs de Integración vs End-to-End: una clase, colaboradores reemplazados, un comportamiento verificado. Estas clases escritas a mano son fakes, implementaciones pequeñas y funcionales con su propio estado. Una biblioteca de mocking como Moq o NSubstitute genera el mismo tipo de sustituto por ti, así no escribes una clase por cada interfaz.

Qué hace un contenedor de DI, y por qué no necesitas uno

Nada de lo anterior requirió un framework. Puedes conectar las dependencias reales a mano en el punto de entrada de tu aplicación:

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

A esto se le llama “pure DI” (o “poor man’s DI”), y para una aplicación pequeña alcanza. El patrón es pasar las dependencias desde afuera. Un container automatiza ese cableado, y empieza a justificarse cuando tienes decenas de clases con dependencias superpuestas y cambiar un constructor significa editar el código de ensamblado en cinco lugares distintos.

ASP.NET Core trae un container integrado. Registras cada interfaz contra una implementación una sola vez, al arrancar, y el framework resuelve el grafo cada vez que construye un controller, un minimal API handler o cualquier otro servicio 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 y SmtpEmailSender reciben una connection string y un nombre de host, no un constructor sin parámetros, así que cada registro es una factory: una lambda que recibe el container (sp) y devuelve la instancia. Cuando ninguna implementación necesita un valor así, la forma con dos argumentos de tipo es más corta: AddScoped<IOrderRepository, SqlOrderRepository>().

Registra el propio OrderService de la misma manera (builder.Services.AddScoped<OrderService>()) y podrás pedirlo en el constructor de un controller. El container lo construye, resuelve IOrderRepository e IEmailSender a partir de esos registros, y tú nunca llamas a new.

Lifetimes de ASP.NET Core: singleton, scoped, transient

AddScoped, AddTransient y AddSingleton se registran igual; el nombre del método es el lifetime, y elegir el equivocado causa la mayoría de los bugs de DI en ASP.NET Core.

LifetimeNueva instancia creadaUso típico
TransientCada vez que se solicitaServicios sin estado y baratos de crear (un formatter, un validator)
ScopedUna vez por request HTTPCualquier cosa atada al request actual: un DbContext, una unit of work
SingletonUna vez, durante toda la vida de la appEstado compartido y thread-safe: configuración, un connection pool, una cache

El registro AddScoped de arriba le da a cada request su propio SqlOrderRepository. Si el repositorio envuelve un DbContext, dos requests concurrentes nunca pueden compartir una transacción a medio terminar. El registro AddTransient crea un SmtpEmailSender nuevo en cada punto de inyección. Eso está bien, porque el sender no guarda estado por request.

Captive dependencies: un servicio scoped atrapado en un singleton

Esta es la regla de lifetimes que más duele. Un servicio solo puede depender con seguridad de cosas que viven tanto como él, o más. Un singleton puede recibir otros singletons. Un servicio scoped puede recibir scoped o singleton. Un transient puede recibir cualquier cosa. La falla ocurre cuando un singleton recibe una dependencia scoped en su constructor:

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

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

El container construye ReportCache una sola vez, y el AppDbContext que recibió se queda en ese campo durante toda la vida de la aplicación. Eso es una captive dependency. Cada request posterior comparte un único DbContext, una clase que no es thread-safe ni está pensada para vivir tanto. Lo ves como lecturas desactualizadas, o como InvalidOperationExceptions que se quejan de que una segunda operación arrancó en este context antes de que terminara la anterior.

ASP.NET Core detecta esto en desarrollo. Con la validación de scopes activada, que es la opción por defecto en el entorno Development, resolver un singleton que necesita un servicio scoped lanza una InvalidOperationException al arrancar la aplicación, en lugar de corromper datos en producción. Si un singleton de verdad necesita un servicio scoped de vez en cuando, inyecta IServiceScopeFactory, crea un scope donde lo necesites y resuelve el servicio desde ese scope en lugar de guardarlo en un campo.

Anti-patrones de dependency injection: service locator y constructores sobrecargados

Service locator disfrazado. Inyectar el container mismo y sacar las dependencias de ahí dentro de un método parece DI, pero tira por la borda lo que la DI te da:

 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);
    }
}

El constructor ya no dice qué necesita OrderService. Tienes que leer cada método para averiguarlo, y un registro faltante aparece como un null en medio de PlaceOrder en lugar de como un fallo al construir la clase. Inyecta las interfaces específicas que usas, no el objeto que las reparte.

El constructor de doce parámetros. Una clase que necesita IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger y siete más está haciendo demasiados trabajos a la vez. La DI no causó eso; lo hizo visible. Es el mismo síntoma descrito en Qué Es el Spaghetti Code y Cómo Refactorizarlo. Divide la clase por responsabilidad y el constructor se reduce solo. Empaquetar las doce dependencias en un objeto “services” esconde el problema sin resolverlo.

Las dos cosas pasan el code review sin ningún problema. Dejan de pasar la primera vez que alguien intenta escribir un unit test para la clase.

Por dónde empezar con dependency injection

Elige una clase de tu codebase que llame a new sobre un cliente de base de datos, un cliente HTTP o un mail sender. Cambia su constructor para que reciba una interfaz, conecta la implementación real a mano en Program.cs, y escribe un test que le pase un fake. En una aplicación ASP.NET Core, sáltate el cableado manual y regístrala en builder.Services, ya que el container ya está ahí. Elige el lifetime a propósito para todo lo que guarde estado, y deja la validación de scopes activada para que el framework detecte una captive dependency antes de que lo hagan tus usuarios.

Artículos relacionados