En tres lecciones distintas del Módulo 7 —ASP.NET Core, Blazor y MAUI— apareció el mismo mensaje
de pasada: "esto es inyección de dependencias, se estudiará en profundidad en el Módulo 8". Ha
llegado ese momento. Esta lección explica primero el problema de fondo que resuelve la
inversión de control (acoplar código a implementaciones concretas dificulta el cambio y,
sobre todo, dificulta las pruebas), después compara las distintas formas de inyectar una
dependencia, y por último aplica todo eso al caso más concreto de BiblioTech: extraer una
interfaz IRepositorioBiblioteca a partir de las cuatro formas de persistencia construidas en el
Módulo 5 (texto, JSON, SQLite, Entity Framework Core), de modo que Biblioteca pueda usar
cualquiera de ellas sin saber cuál está usando en cada momento. De paso, se explica en detalle el
contenedor de servicios de ASP.NET Core que ya usaste, sin profundizar, en la lección de Minimal
APIs.
Contenido
- El problema: acoplamiento fuerte y dificultad para testear
- La inversión de control como solución
- Inyección por constructor frente a por propiedad o método
- Extrayendo
IRepositorioBibliotecade los repositorios del Módulo 5 Bibliotecainyectada conIRepositorioBiblioteca- Contenedores de DI en profundidad:
AddSingleton/AddScoped/AddTransient - Ejemplo completo: registrar y resolver en ASP.NET Core
- El problema: acoplamiento fuerte y dificultad para testear
Imagina que Biblioteca guarda y carga su catálogo llamando directamente a los métodos de
persistencia en SQLite construidos en el Módulo 5:
class Biblioteca
{
public List<MaterialBibliotecario> Catalogo { get; } = new List<MaterialBibliotecario>();
public void GuardarCatalogo()
{
// Biblioteca "sabe" que la persistencia es SQLite, con detalles concretos de conexion
using SqliteConnection conexion = new SqliteConnection("Data Source=bibliotech.db");
conexion.Open();
// ... SQL de INSERT/UPDATE, uno por cada Libro del catalogo ...
}
}Este diseño tiene dos problemas serios, aunque el código "funcione":
- Acoplamiento fuerte:
Bibliotecaqueda ligada a SQLite de forma permanente. Cambiar a JSON, o a Entity Framework, o a un tercer sistema de persistencia futuro, obliga a modificarBibliotecadirectamente —una clase que, en principio, solo debería preocuparse de la lógica de préstamos y catálogo, no de los detalles de cómo se guardan los datos. - Dificultad para testear: para escribir una prueba unitaria (Lección 4 de este módulo) sobre
la lógica de
Biblioteca, haría falta una base de datos SQLite real disponible, con datos conocidos, cada vez que se ejecuten los tests. Eso hace las pruebas lentas, frágiles (dependen de un fichero externo) y difíciles de ejecutar en cualquier máquina sin preparación previa.
- La inversión de control como solución
La inversión de control (IoC) invierte quién decide qué implementación concreta se usa: en
vez de que Biblioteca cree directamente su propia dependencia de persistencia (new SqliteConnection(...)), la recibe ya construida desde fuera, a través de una abstracción
(una interfaz) que no revela los detalles de la implementación concreta:
flowchart LR
subgraph "Sin inversion de control"
B1["Biblioteca"] -->|"new SqliteConnection(...)"| S1["SQLite concreto"]
end
subgraph "Con inversion de control"
B2["Biblioteca"] -->|"depende de"| I["IRepositorioBiblioteca (interfaz)"]
S2["RepositorioSqlite"] -.->|"implementa"| I
S3["RepositorioJson"] -.->|"implementa"| I
Ext["Codigo externo (Program.cs, un contenedor de DI...)"] -->|"decide cual crear e inyecta"| B2
end
Biblioteca deja de decidir "cómo" se persisten los datos: solo declara que necesita algo que
cumpla el contrato IRepositorioBiblioteca, y quien la construye decide qué implementación
concreta le entrega. La inyección de dependencias es, precisamente, la técnica mediante la
cual esa dependencia "entra" desde fuera —por constructor, propiedad o método, como ve el
siguiente apartado— en vez de crearse internamente con new. Es la forma más común de aplicar el
principio de inversión de control.
- Inyección por constructor frente a por propiedad o método
Existen tres formas de inyectar una dependencia en una clase:
| Forma | Cómo se declara | Cuándo se resuelve la dependencia |
|---|---|---|
| Por constructor | Parámetro del constructor, asignado a un campo/propiedad de solo lectura | Al crear el objeto; nunca puede quedar en un estado a medio configurar |
| Por propiedad | Una propiedad pública con set, asignada después de crear el objeto |
En cualquier momento tras la construcción; el objeto puede existir sin la dependencia todavía asignada |
| Por método | Un parámetro de un método concreto, en vez de un miembro de la clase | Solo en el momento de llamar a ese método concreto |
// Inyeccion por constructor (la recomendada por defecto)
class Biblioteca
{
private readonly IRepositorioBiblioteca _repositorio;
public Biblioteca(IRepositorioBiblioteca repositorio)
{
_repositorio = repositorio; // obligatoria: no se puede crear Biblioteca sin ella
}
}
// Inyeccion por propiedad
class Biblioteca
{
public IRepositorioBiblioteca? Repositorio { get; set; } // opcional: puede quedar sin asignar
public void GuardarCatalogo()
{
Repositorio?.Guardar(Catalogo); // hay que comprobar que no sea null antes de usarla
}
public List<MaterialBibliotecario> Catalogo { get; } = new List<MaterialBibliotecario>();
}
// Inyeccion por metodo
class ImportadorMateriales
{
public void ImportarDesde(IRepositorioBiblioteca repositorio, Biblioteca biblioteca)
{
biblioteca.Catalogo.AddRange(repositorio.CargarCatalogo()); // solo valida durante esta llamada
}
}La inyección por constructor es la práctica recomendada por defecto en la inmensa mayoría de
casos, por dos razones: hace la dependencia obligatoria (no se puede crear una Biblioteca
sin proporcionar su repositorio, lo que elimina de raíz cualquier NullReferenceException por
olvido), y hace explícito, con solo mirar la firma del constructor, de qué depende exactamente
la clase. La inyección por propiedad se reserva para dependencias genuinamente opcionales (por
ejemplo, un servicio de registro/logging que puede faltar sin que el objeto deje de funcionar);
la inyección por método, para dependencias que solo hacen falta durante una operación puntual, no
durante toda la vida del objeto.
- Extrayendo
IRepositorioBiblioteca de los repositorios del Módulo 5
IRepositorioBiblioteca de los repositorios del Módulo 5El Módulo 5 construyó cuatro formas distintas de persistir el catálogo de BiblioTech: texto plano
(GuardarCatalogoTexto/CargarCatalogoTexto), JSON
(GuardarEstadoJson/CargarEstadoJson), SQLite con ADO.NET
(GuardarLibrosEnSqlite/CargarLibrosDesdeSqlite) y Entity Framework Core
(BibliotecaDbContext). Las cuatro resuelven, en el fondo, la misma pregunta: "guarda este
catálogo" y "recupérame el catálogo guardado". Esa es la señal exacta de que hace falta una
interfaz común:
interface IRepositorioBiblioteca
{
void GuardarCatalogo(List<MaterialBibliotecario> catalogo);
List<MaterialBibliotecario> CargarCatalogo();
}Cada mecanismo del Módulo 5 pasa a ser una clase independiente que implementa esta interfaz, reutilizando la lógica ya escrita entonces:
class RepositorioTexto : IRepositorioBiblioteca
{
private readonly string _ruta;
public RepositorioTexto(string ruta) => _ruta = ruta;
public void GuardarCatalogo(List<MaterialBibliotecario> catalogo)
{
using StreamWriter escritor = new StreamWriter(_ruta); // Modulo 5, GuardarCatalogoTexto
foreach (MaterialBibliotecario material in catalogo)
{
escritor.WriteLine($"{material.Titulo}|{material.Autor}|{material.Disponible}");
}
}
public List<MaterialBibliotecario> CargarCatalogo()
{
// misma logica que CargarCatalogoTexto del Modulo 5, adaptada para devolver la lista
// en vez de rellenar directamente Biblioteca.Catalogo
List<MaterialBibliotecario> catalogo = new List<MaterialBibliotecario>();
// ... lectura linea a linea con StreamReader, reconstruyendo cada Libro ...
return catalogo;
}
}
class RepositorioJson : IRepositorioBiblioteca
{
private readonly string _ruta;
public RepositorioJson(string ruta) => _ruta = ruta;
public void GuardarCatalogo(List<MaterialBibliotecario> catalogo)
{
string json = JsonSerializer.Serialize(catalogo); // Modulo 5, GuardarEstadoJson
File.WriteAllText(_ruta, json);
}
public List<MaterialBibliotecario> CargarCatalogo()
{
string json = File.ReadAllText(_ruta);
return JsonSerializer.Deserialize<List<MaterialBibliotecario>>(json) ?? new List<MaterialBibliotecario>();
}
}
class RepositorioSqlite : IRepositorioBiblioteca
{
private readonly string _cadenaConexion;
public RepositorioSqlite(string cadenaConexion) => _cadenaConexion = cadenaConexion;
public void GuardarCatalogo(List<MaterialBibliotecario> catalogo)
{
// misma logica que GuardarLibrosEnSqlite del Modulo 5, con SqliteConnection/SqliteCommand
}
public List<MaterialBibliotecario> CargarCatalogo()
{
// misma logica que CargarLibrosDesdeSqlite del Modulo 5, con SqliteDataReader
return new List<MaterialBibliotecario>();
}
}
class RepositorioEntityFramework : IRepositorioBiblioteca
{
private readonly BibliotecaDbContext _contexto;
public RepositorioEntityFramework(BibliotecaDbContext contexto) => _contexto = contexto;
public void GuardarCatalogo(List<MaterialBibliotecario> catalogo)
{
_contexto.AddRange(catalogo.OfType<Libro>()); // simplificado: EF Core distingue Libro/Revista por su tipo
_contexto.SaveChanges(); // version sincrona de SaveChangesAsync, Modulo 5
}
public List<MaterialBibliotecario> CargarCatalogo()
{
return _contexto.Libros.Cast<MaterialBibliotecario>().ToList(); // consulta LINQ, Modulo 5
}
}Ninguna de estas cuatro clases modifica la lógica de persistencia que ya construiste en el Módulo
5 —solo la reorganiza detrás de un contrato común. RepositorioEntityFramework, en particular,
recibe su propio BibliotecaDbContext también por inyección de constructor: la inyección de
dependencias se aplica en cadena, no solo en el punto más externo.
Biblioteca inyectada con IRepositorioBiblioteca
Biblioteca inyectada con IRepositorioBibliotecaCon la interfaz ya definida, Biblioteca deja de conocer cualquier detalle concreto de
persistencia:
class Biblioteca
{
private readonly IRepositorioBiblioteca _repositorio;
public List<MaterialBibliotecario> Catalogo { get; private set; } = new List<MaterialBibliotecario>();
public Biblioteca(IRepositorioBiblioteca repositorio)
{
_repositorio = repositorio;
}
public void GuardarCatalogo() => _repositorio.GuardarCatalogo(Catalogo);
public void CargarCatalogo() => Catalogo = _repositorio.CargarCatalogo();
}// Con RepositorioJson
Biblioteca bibliotecaJson = new Biblioteca(new RepositorioJson("catalogo.json"));
// Exactamente la misma clase Biblioteca, ahora con SQLite, sin cambiar ni una linea de Biblioteca
Biblioteca bibliotecaSqlite = new Biblioteca(new RepositorioSqlite("Data Source=bibliotech.db"));Biblioteca es idéntica en ambos casos: lo único que cambia es qué implementación concreta de
IRepositorioBiblioteca se le entrega al construirla. Este es exactamente el objetivo de la
inversión de control planteado en el apartado 2, ya resuelto de forma concreta sobre el dominio de
BiblioTech. Además, como se anticipó en el apartado 1, esta misma estructura es la que permitirá,
en la Lección 4, sustituir IRepositorioBiblioteca por un doble de prueba (mock) sin necesitar
ningún fichero ni base de datos real durante los tests.
- Contenedores de DI en profundidad:
AddSingleton/AddScoped/AddTransient
AddSingleton/AddScoped/AddTransientLa lección de ASP.NET Core (Módulo 7) introdujo el contenedor de servicios de forma básica, con
builder.Services.AddSingleton<Biblioteca>(). Ahora que conoces la teoría completa de la
inyección de dependencias, es el momento de entender por qué existen tres métodos distintos de
registro, no solo AddSingleton:
| Método | Cuántas instancias crea | Cuándo usarlo |
|---|---|---|
AddSingleton<T>() |
Una única instancia, compartida durante toda la vida de la aplicación | Estado verdaderamente global y seguro de compartir entre peticiones concurrentes (por ejemplo, IPoliticaSancion de la lección anterior, si no guarda estado propio) |
AddScoped<T>() |
Una instancia nueva por cada petición HTTP (o "ámbito"), compartida dentro de esa misma petición | Dependencias con estado propio de una operación, como BibliotecaDbContext (recuerda el error común de la lección de ASP.NET Core: nunca como singleton) |
AddTransient<T>() |
Una instancia nueva cada vez que se pide, incluso varias veces dentro de la misma petición | Dependencias ligeras, sin estado, que no importa crear repetidamente |
var builder = WebApplication.CreateBuilder(args);
// IRepositorioBiblioteca resuelto como una instancia de RepositorioEntityFramework
builder.Services.AddScoped<IRepositorioBiblioteca, RepositorioEntityFramework>();
builder.Services.AddScoped<BibliotecaDbContext>(); // una instancia nueva por peticion, correcto (Modulo 7)
builder.Services.AddScoped<Biblioteca>(); // Biblioteca ahora tambien depende de IRepositorioBiblioteca
var app = builder.Build();
app.MapGet("/libros", (Biblioteca biblioteca) =>
{
biblioteca.CargarCatalogo();
return Results.Ok(biblioteca.Catalogo);
});AddScoped<IRepositorioBiblioteca, RepositorioEntityFramework>() registra dos tipos: el primero
es el tipo que se pedirá (la interfaz), el segundo es la implementación concreta que el
contenedor debe construir cuando alguien pida ese tipo. Cuando el endpoint declara Biblioteca biblioteca como parámetro, ASP.NET Core resuelve automáticamente toda la cadena: construye un
RepositorioEntityFramework, que a su vez necesita un BibliotecaDbContext (también registrado),
y con ese repositorio ya construido, construye finalmente la Biblioteca que el endpoint recibe.
Ninguna de estas construcciones intermedias la escribe el programador a mano: el contenedor de DI
la resuelve leyendo los constructores de cada clase registrada.
AddScoped para Biblioteca (en vez de AddSingleton, como se vio de forma simplificada en el
Módulo 7) es ahora la elección correcta: como Biblioteca depende de un BibliotecaDbContext
—que ya sabes que debe vivir solo durante una petición—, Biblioteca misma debe tener el mismo
ciclo de vida que su dependencia, o arrastraría un DbContext obsoleto entre peticiones distintas.
- Ejemplo completo: registrar y resolver en ASP.NET Core
Uniendo todas las piezas anteriores, así queda el registro completo de servicios de una API de
BiblioTech que usa Entity Framework Core como mecanismo de persistencia, con Biblioteca
inyectada con IRepositorioBiblioteca en vez de acoplada directamente a EF Core:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<BibliotecaDbContext>(opciones =>
opciones.UseSqlite("Data Source=bibliotech.db")); // AddDbContext ya registra el ciclo de vida correcto (Scoped)
builder.Services.AddScoped<IRepositorioBiblioteca, RepositorioEntityFramework>();
builder.Services.AddScoped<Biblioteca>();
var app = builder.Build();
app.MapPost("/catalogo/guardar", (Biblioteca biblioteca) =>
{
biblioteca.GuardarCatalogo();
return Results.Ok("Catalogo guardado.");
});
app.Run();Para probar con otro mecanismo de persistencia —por ejemplo, en un entorno de pruebas manuales sin base de datos— bastaría con cambiar una sola línea:
// En vez de EF Core, usa JSON: ni Biblioteca ni los endpoints necesitan ningun cambio
builder.Services.AddSingleton<IRepositorioBiblioteca>(new RepositorioJson("catalogo.json"));Este es el resultado práctico de haber invertido el control desde el apartado 2: cambiar por
completo el mecanismo de persistencia de toda la API es, ahora, una única línea en Program.cs,
en vez de una modificación dispersa por todo Biblioteca.
Errores Comunes y Consejos
- Registrar
BibliotecacomoAddSingletoncuando depende de unBibliotecaDbContextScoped: el contenedor de ASP.NET Core lo detecta y lanza una excepción al arrancar (captive dependency, una dependencia de vida corta "atrapada" dentro de un objeto de vida más larga). El ciclo de vida de una clase nunca debe ser más largo que el de sus dependencias. - Inyectar por propiedad "por comodidad" en vez de por constructor: hace que la dependencia parezca opcional cuando en realidad es obligatoria, y permite crear el objeto en un estado incompleto (sin la dependencia asignada) que solo falla más tarde, al usarla.
- Confundir la interfaz
IRepositorioBibliotecacon "una capa inútil de más": parece redundante mientras solo hay una implementación real, pero su valor aparece justo cuando hace falta cambiar de implementación (apartado 7) o sustituirla por un doble de prueba (Lección 4). - Consejo: si al diseñar una clase te preguntas "¿esto debería ser
newaquí dentro, o recibido desde fuera?", la pregunta de oro es: ¿alguna vez necesitaré sustituir esta dependencia por otra implementación, o por una versión de prueba? Si la respuesta es sí, inyéctala.
Ejercicios
-
Define una interfaz
INotificadorcon un único métodovoid Notificar(string mensaje), y dos implementaciones:NotificadorConsola(que useConsole.WriteLine) yNotificadorSilencioso(que no haga nada). ModificaBibliotecapara que reciba unINotificadorpor constructor y lo use dentro deRegistrarPrestamoen vez de llamar directamente aConsole.WriteLine. -
En el registro de servicios de ASP.NET Core del apartado 7, explica por qué
IRepositorioBibliotecayBibliotecadeben registrarse comoAddScoped(y noAddSingleton) mientras dependan, directa o indirectamente, deBibliotecaDbContext.
Soluciones
interface INotificador
{
void Notificar(string mensaje);
}
class NotificadorConsola : INotificador
{
public void Notificar(string mensaje) => Console.WriteLine(mensaje);
}
class NotificadorSilencioso : INotificador
{
public void Notificar(string mensaje) { /* no hace nada */ }
}
class Biblioteca
{
private readonly IRepositorioBiblioteca _repositorio;
private readonly INotificador _notificador;
public List<Prestamo> Prestamos { get; } = new List<Prestamo>();
public Biblioteca(IRepositorioBiblioteca repositorio, INotificador notificador)
{
_repositorio = repositorio;
_notificador = notificador;
}
public void RegistrarPrestamo(Prestamo prestamo)
{
Prestamos.Add(prestamo);
_notificador.Notificar($"Prestamo de '{prestamo.Libro.Titulo}' registrado.");
}
}
BibliotecaDbContext se registra con AddDbContext (equivalente a AddScoped) porque no está
pensado para compartirse entre peticiones concurrentes (lección de ASP.NET Core, Módulo 7). Si
IRepositorioBiblioteca (que envuelve ese DbContext) o Biblioteca (que depende de
IRepositorioBiblioteca) se registraran como AddSingleton, la primera petición HTTP dejaría
"atrapado" dentro de ese singleton un BibliotecaDbContext que debería haberse desechado al
terminar esa petición, y las peticiones siguientes reutilizarían un contexto obsoleto o ya
liberado, provocando errores en tiempo de ejecución.
Conclusión
En esta lección has entendido el problema de fondo que resuelve la inversión de control
(acoplamiento fuerte y dificultad para testear), comparado la inyección por constructor —la
recomendada por defecto— con la inyección por propiedad y por método, extraído una interfaz
IRepositorioBiblioteca que unifica las cuatro formas de persistencia del Módulo 5 detrás de un
contrato común, e inyectado esa interfaz en Biblioteca para desacoplarla de cualquier mecanismo
concreto. También has entendido en profundidad, por fin, la diferencia entre AddSingleton,
AddScoped y AddTransient en el contenedor de servicios de ASP.NET Core, que solo se había
mencionado de pasada en el Módulo 7.
Todo este trabajo de desacoplar Biblioteca de sus dependencias concretas tiene un beneficio que
todavía no has explotado: ahora es posible sustituir IRepositorioBiblioteca por una versión de
prueba que no toque ningún fichero ni base de datos real. Esa es, precisamente, la puerta de
entrada a la siguiente lección: pruebas unitarias, donde IRepositorioBiblioteca se
sustituirá por un doble de prueba (mock) para verificar la lógica de Biblioteca de forma
rápida, aislada y repetible.
Curso de Programación en C#
Módulo 1: Introducción a C#
- Introducción a C#
- Configuración del Entorno de Desarrollo
- Programa Hola Mundo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Arrays y Cadenas de Texto
Módulo 2: Estructuras de Control
Módulo 3: Programación Orientada a Objetos
- Clases y Objetos
- Métodos
- Constructores y Destructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- Structs y Records: Tipos por Valor y por Referencia
Módulo 4: Conceptos Avanzados de C#
- Interfaces
- Delegados y Eventos
- Pattern Matching y Características Modernas de C#
- Genéricos
- Colecciones
- LINQ (Consulta Integrada en el Lenguaje)
- Programación Asíncrona
Módulo 5: Trabajando con Datos
- Entrada/Salida de Archivos
- Serialización
- Conectividad con Bases de Datos
- Entity Framework
- Trabajo con JSON y Consumo de APIs REST
Módulo 6: Temas Avanzados
- Reflexión
- Atributos
- Programación Dinámica
- Gestión de Memoria y Recolección de Basura
- Multihilo y Programación Paralela
Módulo 7: Construcción de Aplicaciones
- Formularios de Windows
- WPF (Windows Presentation Foundation)
- ASP.NET Core
- Blazor
- Xamarin y .NET MAUI
Módulo 8: Mejores Prácticas y Patrones de Diseño
- Estándares de Codificación y Mejores Prácticas
- Patrones de Diseño
- Inyección de Dependencias e Inversión de Control
- Pruebas Unitarias
- Revisión y Refactorización de Código
