Aquí tienes una clase que no puedes probar con un unit test sin una base de datos real y un servidor SMTP funcionando:
| |
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:
| |
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:
| |
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:
| |
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:
| |
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.
| Lifetime | Nueva instancia creada | Uso típico |
|---|---|---|
Transient | Cada vez que se solicita | Servicios sin estado y baratos de crear (un formatter, un validator) |
Scoped | Una vez por request HTTP | Cualquier cosa atada al request actual: un DbContext, una unit of work |
Singleton | Una vez, durante toda la vida de la app | Estado 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:
| |
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:
| |
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.