Aplicación del Principio de Inversión de Dependencias en C#

Resumen

Cuando un controlador instancia directamente sus dependencias con new, estás creando un acoplamiento que rompe pruebas unitarias y bloquea el mantenimiento. Aplicar el principio de inversión de dependencias en C# te permite desacoplar la lógica, inyectar contratos abstractos y controlar el comportamiento en distintos entornos, algo clave si trabajas con APIs en .NET.

Esto te interesa si desarrollas servicios web, escribes pruebas unitarias o buscas escribir código que respete los principios SOLID sin generar efectos secundarios indeseados.

¿Por qué un controlador no debe instanciar sus dependencias?

En el proyecto de ejemplo, StudentController crea internamente un StudentRepository y un Logbook con la palabra clave new. Esto genera una dependencia directa entre tipos concretos y obliga al controlador a cargar con toda la lógica interna de esas clases [1:30].

El problema real aparece al ejecutar las pruebas. Como Logbook escribe en un archivo logbook.txt dentro de la carpeta bin, cada vez que corres dotnet test se genera un efecto colateral en el sistema de archivos. Las pruebas pasan, pero por la razón equivocada: tienen acceso accidental a esa ruta.

¿Qué es una dependencia directa en programación? Es cuando una clase crea o conoce el tipo concreto de otra clase con la que trabaja. Esto impide sustituir esa dependencia sin modificar la clase original.

¿Por qué las pruebas unitarias no deben tener side effects?

Una prueba unitaria debe enfocarse únicamente en el código bajo prueba, sin tocar archivos, bases de datos ni servicios externos. Si tu test depende de una carpeta o de una conexión a internet, deja de ser unitario y se vuelve frágil.

En el ejemplo, las pruebas verifican que GetAll devuelva tres elementos, lo cual es correcto, pero al hacerlo ejecutan también la escritura del log. Eso jamás debería ocurrir [3:45].

¿Cómo se aplica el principio de inversión de dependencias en .NET?

La solución consiste en depender de abstracciones, no de implementaciones concretas. En C# eso se traduce en crear interfaces que definan el contrato y luego inyectarlas mediante el contenedor de dependencias de .NET.

El flujo de refactorización es claro:

  1. Crear la interfaz IStudentRepository con los métodos GetAll y Add.
  2. Hacer que StudentRepository implemente IStudentRepository.
  3. Crear la interfaz ILogbook con el método Add y que Logbook la implemente.
  4. Reemplazar en el controlador las propiedades concretas por las interfaces.
  5. Recibir las dependencias en el constructor del controlador.

csharp public class StudentController { private readonly IStudentRepository studentRepository; private readonly ILogbook logbook;

public StudentController(IStudentRepository student, ILogbook log) { studentRepository = student; logbook = log; }

}

El controlador ya no sabe ni le importa cómo está implementado el repositorio. Solo confía en que el contrato garantiza la existencia de los métodos que necesita [7:20].

¿Cómo registrar dependencias en el contenedor de .NET?

.NET incluye un contenedor de inyección de dependencias listo para usar. Dentro de Program.cs, en la configuración de servicios, registras cada interfaz con su implementación concreta. Tienes tres ciclos de vida disponibles:

  • Singleton: una única instancia compartida durante todo el ciclo de vida de la API.
  • Scoped: una instancia por cada petición o componente que la utilice.
  • Transient: una nueva instancia cada vez que se solicita la dependencia, incluso dentro del mismo componente.

El registro queda así:

csharp builder.Services.AddScoped<IStudentRepository, StudentRepository>(); builder.Services.AddScoped<ILogbook, Logbook>();

Con eso, cualquier controlador o clase que reciba IStudentRepository o ILogbook por constructor obtendrá automáticamente la implementación correspondiente [10:15].

¿Cuál es la diferencia entre Scoped, Singleton y Transient? Singleton mantiene la misma instancia siempre. Scoped crea una por petición. Transient crea una nueva cada vez que se inyecta, incluso dentro de la misma clase.

¿Qué ventaja real obtienes al invertir dependencias?

Ahora puedes cambiar el comportamiento de las dependencias según el entorno. En producción, Logbook puede seguir escribiendo en archivo, pero en las pruebas unitarias puedes inyectar una implementación falsa de ILogbook que no haga absolutamente nada, eliminando el side effect.

Imagina que tienes 50 controladores y agregas un parámetro al constructor de StudentRepository. Sin inversión de dependencias, tendrías que modificar los 50 controladores. Con interfaces e inyección, el cambio queda contenido en el registro del contenedor.

Después de la refactorización, al ejecutar dotnet build el proyecto compila correctamente, pero las pruebas marcan error porque ahora exigen que les inyectes manualmente las dependencias del controlador. Eso es exactamente lo que queremos: control total sobre qué versión de la dependencia se usa en cada contexto [13:50].

Conceptos clave que aparecen en la clase

  • Inversión de dependencias: depender de abstracciones (interfaces) en lugar de implementaciones concretas [1:30].
  • Interfaz como contrato: IStudentRepository e ILogbook definen qué métodos deben existir, sin dictar cómo se implementan [6:40].
  • Inyección por constructor: el patrón estándar en .NET para recibir dependencias declaradas como parámetros [8:05].
  • Contenedor de servicios: builder.Services registra el mapeo entre interfaz e implementación [10:15].
  • Side effect en pruebas: cualquier acceso a archivos, red o base de datos que contamina una prueba unitaria [4:00].

¿Has refactorizado alguna API tuya aplicando este principio? Cuéntame en los comentarios qué dependencias te costaron más desacoplar.