La lección anterior dejó clara la principal molestia de ADO.NET clásico: cada consulta exige
escribir SQL a mano, gestionar parámetros uno a uno, y mapear manualmente cada columna hacia
cada propiedad de un objeto. Un ORM (Object-Relational Mapper, mapeador
objeto-relacional) automatiza justamente ese trabajo repetitivo: traduce operaciones sobre
objetos y colecciones de C# en el SQL equivalente, y reconstruye objetos a partir de los
resultados sin código manual de mapeo. Esta lección presenta Entity Framework Core (EF
Core), el ORM oficial de .NET, y lo aplica al modelo de dominio de BiblioTech: Libro, Socio
y Prestamo pasan a vivir en una base de datos real, consultables con la misma sintaxis LINQ
que ya conoces desde el Módulo 4, y guardados con el mismo async/await de la lección de
Programación Asíncrona.
Contenido
- Qué es un ORM y qué problema resuelve frente a ADO.NET manual
- Instalar Entity Framework Core y el proveedor de SQLite
DbContextyDbSet<T>: mapeando el dominio de BiblioTech- Configurar el modelo sin tocar las clases de dominio: Fluent API en
OnModelCreating - Migraciones:
dotnet ef migrations addydotnet ef database update - Consultas LINQ contra un
DbSet<T> - Guardar cambios:
SaveChanges()ySaveChangesAsync()
- Qué es un ORM y qué problema resuelve frente a ADO.NET manual
Un ORM resuelve el llamado "desajuste de impedancia" (impedance mismatch) entre dos mundos que se organizan de forma distinta: los objetos de C# (con propiedades, herencia, colecciones anidadas) y las tablas de una base de datos relacional (filas, columnas, claves foráneas). En la lección anterior, ese desajuste se resolvía a mano, línea a línea:
| ADO.NET manual (lección anterior) | Entity Framework Core (esta lección) | |
|---|---|---|
| Escribir el SQL de cada consulta | A mano, como texto | Generado automáticamente a partir de LINQ |
| Mapear filas a objetos | A mano, columna a columna | Automático, según el modelo configurado |
| Rastrear qué cambió para guardar | El programador decide qué UPDATE/INSERT ejecutar |
EF Core detecta los cambios y genera el SQL necesario |
| Control sobre el SQL exacto | Total | Alto nivel, con la posibilidad de bajar a SQL manual si hace falta |
Un ORM no sustituye por completo a ADO.NET: por debajo, EF Core sigue usando ADO.NET para comunicarse con la base de datos; lo que aporta es una capa de abstracción que ahorra escribir ese SQL repetitivo a mano en el día a día.
- Instalar Entity Framework Core y el proveedor de SQLite
EF Core se instala como un conjunto de paquetes NuGet, más una herramienta de línea de comandos para gestionar migraciones (apartado 5):
dotnet add package Microsoft.EntityFrameworkCore.Sqlite
dotnet add package Microsoft.EntityFrameworkCore.Design
dotnet tool install --global dotnet-efMicrosoft.EntityFrameworkCore.Sqlite incluye el motor de EF Core más el proveedor específico
de SQLite (cada motor de base de datos tiene su propio paquete de proveedor, siguiendo la misma
idea de "proveedor" ya vista con ADO.NET). Microsoft.EntityFrameworkCore.Design y la
herramienta global dotnet-ef son necesarias para generar y aplicar migraciones desde la
terminal.
DbContext y DbSet<T>: mapeando el dominio de BiblioTech
DbContext y DbSet<T>: mapeando el dominio de BiblioTechEl punto de entrada de EF Core es una clase que hereda de DbContext, con una propiedad
DbSet<T> por cada tipo que se quiere persistir. Cada DbSet<T> representa, conceptualmente,
"la tabla de T en la base de datos":
using Microsoft.EntityFrameworkCore;
class BibliotecaDbContext : DbContext
{
public DbSet<Libro> Libros { get; set; } = null!;
public DbSet<Socio> Socios { get; set; } = null!;
public DbSet<Prestamo> Prestamos { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder opciones)
{
opciones.UseSqlite("Data Source=bibliotech_ef.db");
}
}OnConfiguring indica a qué base de datos conectarse (aquí, SQLite, con la misma cadena de
conexión que en la lección anterior); en aplicaciones más grandes esta configuración se suele
inyectar desde fuera mediante DbContextOptions, pero para un ejemplo autocontenido basta con
OnConfiguring. El operador = null! (el ! es el operador null-forgiving de C#, recordando
la lección de Pattern Matching y Características Modernas del Módulo 4) le dice al compilador
"confía en que esta propiedad no será null en tiempo de ejecución", ya que EF Core la asigna
automáticamente al construir el DbContext, aunque el compilador no pueda verlo por sí solo.
Libro y Socio ya son perfectamente válidos para EF Core tal cual están definidos desde
Módulos anteriores: no hace falta modificarlos. Prestamo, con sus propiedades de solo lectura
(Libro, Socio, FechaPrestamo) y su constructor Prestamo(Libro libro, Socio socio), es un
poco más particular; el siguiente apartado explica cómo EF Core lo mapea sin tocar esa clase.
- Configurar el modelo sin tocar las clases de dominio: Fluent API en
OnModelCreating
OnModelCreatingEF Core ofrece dos formas de indicarle detalles del mapeo que no puede deducir por convención
(como cuál es la clave primaria de cada tabla): Data Annotations (atributos como [Key]
directamente sobre las propiedades del modelo) o Fluent API (código de configuración
centralizado, dentro del propio DbContext, sin tocar las clases de dominio). Esta lección usa
Fluent API precisamente para no tener que añadir atributos de persistencia a Libro, Socio y
Prestamo —clases que, hasta ahora, no sabían nada de bases de datos ni de EF Core, y que
siguen sin saberlo:
class BibliotecaDbContext : DbContext
{
public DbSet<Libro> Libros { get; set; } = null!;
public DbSet<Socio> Socios { get; set; } = null!;
public DbSet<Prestamo> Prestamos { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder opciones)
{
opciones.UseSqlite("Data Source=bibliotech_ef.db");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Libro>().HasKey(libro => libro.Isbn);
modelBuilder.Entity<Socio>().HasKey(socio => socio.Id);
// Prestamo no tiene ninguna propiedad pensada como identificador propio;
// se le anade una clave "sombra" (shadow property), que existe en la base
// de datos pero no como propiedad visible en la clase C#.
modelBuilder.Entity<Prestamo>().Property<int>("PrestamoId");
modelBuilder.Entity<Prestamo>().HasKey("PrestamoId");
}
}HasKey(libro => libro.Isbn) indica que Isbn es la clave primaria de la tabla Libros
(igual que en la tabla SQLite de la lección anterior); HasKey(socio => socio.Id) hace lo
mismo con Socio.Id, que además ya era de solo lectura por diseño (lección de Encapsulamiento)
—un buen candidato natural a clave primaria. Para Prestamo, que no tiene ninguna propiedad
pensada como identificador único, se define una propiedad sombra ("PrestamoId"): existe
como columna en la base de datos y como clave primaria interna de EF Core, pero no aparece como
propiedad en la clase Prestamo de C#. Esta es una de las ventajas prácticas de un ORM maduro
como EF Core: el modelo de dominio puede permanecer limpio, centrado en las reglas de negocio,
sin mezclarse con detalles de persistencia.
EF Core también necesita saber cómo se relaciona Prestamo con Libro y Socio; por
convención, al detectar las propiedades Libro y Socio dentro de Prestamo, genera
automáticamente columnas de clave foránea (otras dos propiedades sombra, típicamente LibroIsbn
y SocioId) sin necesitar configuración explícita adicional para este ejemplo.
- Migraciones:
dotnet ef migrations add y dotnet ef database update
dotnet ef migrations add y dotnet ef database updateUna migración es un fichero de código C#, generado automáticamente, que describe cómo
transformar el esquema de la base de datos para que coincida con el modelo actual (qué tablas
crear, qué columnas añadir...). Se generan y aplican con la herramienta dotnet ef desde la
terminal, en la carpeta del proyecto:
| Comando | Qué hace |
|---|---|
dotnet ef migrations add <Nombre> |
Compara el modelo actual (DbSet<T> + configuración de OnModelCreating) con el historial de migraciones, y genera una nueva migración con los cambios detectados |
dotnet ef database update |
Aplica contra la base de datos real todas las migraciones pendientes, creando o modificando tablas según haga falta |
dotnet ef migrations add InicialBiblioTech crea una carpeta Migrations/ con dos ficheros
por migración: uno con el código que aplica el cambio (Up()) y otro con el que lo deshace
(Down()), más un fichero de "instantánea" del modelo completo tal como quedó tras esa
migración. Cada vez que el modelo cambie (una propiedad nueva en Libro, por ejemplo), se
genera una nueva migración con migrations add y se aplica con database update; EF Core
calcula automáticamente solo la diferencia respecto a la migración anterior, sin necesidad de
escribir el ALTER TABLE a mano.
- Consultas LINQ contra un
DbSet<T>
DbSet<T>Aquí es donde EF Core conecta directamente con algo que ya dominas: DbSet<T> implementa
IQueryable<T> (una extensión de IEnumerable<T>, la interfaz que cierra la lección de
Colecciones), así que se consulta con exactamente la misma sintaxis LINQ de la lección de LINQ
—solo que, por debajo, EF Core traduce esas operaciones a SQL y lo ejecuta contra la base de
datos, en vez de recorrer una colección ya cargada en memoria:
using BibliotecaDbContext contexto = new BibliotecaDbContext();
List<Libro> librosDisponibles = contexto.Libros
.Where(libro => libro.Disponible)
.OrderBy(libro => libro.Titulo)
.ToList();
foreach (Libro libro in librosDisponibles)
{
Console.WriteLine(libro.Titulo);
}
Libro? rayuela = contexto.Libros.FirstOrDefault(libro => libro.Isbn == "978-84-376-0495-4");Where, OrderBy, FirstOrDefault... son los mismos operadores de la lección de LINQ, escritos
exactamente igual; la diferencia es que aquí, contexto.Libros.Where(...) se traduce
internamente a una sentencia SELECT ... WHERE ... en SQLite, en vez de filtrar una lista ya
existente en memoria. BibliotecaDbContext, igual que SqliteConnection en la lección
anterior, implementa IDisposable: se declara con using por el mismo motivo.
- Guardar cambios:
SaveChanges() y SaveChangesAsync()
SaveChanges() y SaveChangesAsync()Añadir, modificar o eliminar entidades a través de un DbContext no impacta inmediatamente en
la base de datos: EF Core rastrea los cambios en memoria, y solo los traduce a SQL
(INSERT, UPDATE, DELETE) cuando se llama explícitamente a SaveChanges() (o su
equivalente asíncrono SaveChangesAsync(), recordando async/await de la lección de
Programación Asíncrona):
using BibliotecaDbContext contexto = new BibliotecaDbContext();
Libro nuevoLibro = new Libro("El Aleph", "Jorge Luis Borges", "978-84-376-0497-8");
contexto.Libros.Add(nuevoLibro);
await contexto.SaveChangesAsync(); // aqui, y solo aqui, se ejecuta el INSERT real contra SQLite
Console.WriteLine("Libro guardado correctamente.");using BibliotecaDbContext contexto = new BibliotecaDbContext();
Libro? libro = contexto.Libros.FirstOrDefault(l => l.Isbn == "978-84-376-0497-8");
if (libro is not null)
{
libro.Prestar(); // cambia Disponible a false; EF Core detecta este cambio automaticamente
await contexto.SaveChangesAsync(); // genera y ejecuta el UPDATE necesario
}En el segundo ejemplo no hace falta ningún contexto.Libros.Update(...) explícito: como
libro fue obtenido a través del propio contexto, EF Core ya lo está rastreando
(change tracking) y detecta, al llamar a SaveChangesAsync(), que su propiedad Disponible
cambió, generando el UPDATE correspondiente. Esta es una de las diferencias más visibles
frente a ADO.NET manual, donde cada UPDATE había que escribirlo y ejecutarlo explícitamente.
classDiagram
class BibliotecaDbContext {
+DbSet~Libro~ Libros
+DbSet~Socio~ Socios
+DbSet~Prestamo~ Prestamos
#OnConfiguring(DbContextOptionsBuilder)
#OnModelCreating(ModelBuilder)
}
BibliotecaDbContext --|> DbContext
BibliotecaDbContext --> "*" Libro
BibliotecaDbContext --> "*" Socio
BibliotecaDbContext --> "*" Prestamo
Errores Comunes y Consejos
- Olvidar
SaveChanges()/SaveChangesAsync(): añadir una entidad concontexto.Libros.Add(...)no la persiste por sí solo; sin llamar aSaveChanges(), el cambio solo existe en memoria y se pierde al cerrar elDbContext. - No generar ni aplicar una migración tras cambiar el modelo: si se añade una propiedad
nueva a
Librosin ejecutardotnet ef migrations addydotnet ef database update, la base de datos real queda desincronizada respecto al modelo, y las consultas fallarán en tiempo de ejecución al no encontrar la columna esperada. - Olvidar
HasKeypara un tipo sin una propiedad de identificador natural: EF Core exige que toda entidad tenga una clave primaria; si ninguna propiedad sirve como tal (el caso dePrestamo), hay que definir una propiedad sombra explícitamente conProperty<T>()+HasKey()enOnModelCreating. - Crear un
DbContextnuevo por cada operación pequeña sin necesidad, o reutilizar uno solo durante demasiado tiempo: la práctica habitual en EF Core es una vida corta porDbContext(típicamente, uno por operación o por petición HTTP en una API web, tema del Módulo 7), ni uno compartido para toda la aplicación ni uno distinto por cada línea de código. - Consejo: usa siempre las consultas LINQ para leer datos, y dosifica el SQL manual (posible
también desde EF Core, con
FromSqlRaw, fuera del alcance de esta lección) para los casos puntuales donde LINQ no exprese bien lo que necesitas; para la inmensa mayoría de operaciones del día a día, LINQ contra unDbSet<T>es más legible y menos propenso a errores que SQL escrito a mano.
Ejercicios
-
Define
BibliotecaDbContextconDbSet<Libro> LibrosyDbSet<Socio> Socios, configurandoHasKeypara ambos enOnModelCreatingtal como se ha mostrado en esta lección. Genera la migración inicial condotnet ef migrations addy aplícala condotnet ef database update. -
Usando el
BibliotecaDbContextdel ejercicio anterior, añade dosLibronuevos concontexto.Libros.Add(...)y guarda los cambios conSaveChangesAsync(). Después, en una nueva instancia deBibliotecaDbContext, consulta con LINQ (Where+OrderBy) los libros disponibles ordenados por título. -
Recupera un
Libroexistente conFirstOrDefaultpor suIsbn, llama aPrestar()sobre él, y guarda los cambios conSaveChangesAsync()sin llamar a ningún métodoUpdateexplícito. Vuelve a consultarlo en una nueva instancia del contexto y comprueba queDisponibleahora esfalse.
Soluciones
class BibliotecaDbContext : DbContext
{
public DbSet<Libro> Libros { get; set; } = null!;
public DbSet<Socio> Socios { get; set; } = null!;
protected override void OnConfiguring(DbContextOptionsBuilder opciones)
{
opciones.UseSqlite("Data Source=bibliotech_ef.db");
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Libro>().HasKey(libro => libro.Isbn);
modelBuilder.Entity<Socio>().HasKey(socio => socio.Id);
}
}
dotnet ef migrations add InicialBiblioTech
dotnet ef database update
using (BibliotecaDbContext contexto = new BibliotecaDbContext())
{
contexto.Libros.Add(new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4"));
contexto.Libros.Add(new Libro("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1"));
await contexto.SaveChangesAsync();
}
using (BibliotecaDbContext otroContexto = new BibliotecaDbContext())
{
List<Libro> disponibles = otroContexto.Libros
.Where(l => l.Disponible)
.OrderBy(l => l.Titulo)
.ToList();
foreach (Libro libro in disponibles)
{
Console.WriteLine(libro.Titulo);
}
}
using (BibliotecaDbContext contexto = new BibliotecaDbContext())
{
Libro? libro = contexto.Libros.FirstOrDefault(l => l.Isbn == "978-84-376-0495-4");
if (libro is not null)
{
libro.Prestar();
await contexto.SaveChangesAsync();
}
}
using (BibliotecaDbContext otroContexto = new BibliotecaDbContext())
{
Libro? libroActualizado = otroContexto.Libros.FirstOrDefault(l => l.Isbn == "978-84-376-0495-4");
Console.WriteLine(libroActualizado?.Disponible); // False
}
Conclusión
En esta lección has conocido Entity Framework Core: qué problema resuelve un ORM frente a
ADO.NET manual, cómo definir un DbContext con DbSet<T> para cada tipo del dominio de
BiblioTech, cómo configurar el modelo con Fluent API sin ensuciar las clases de dominio con
atributos de persistencia, cómo generar y aplicar migraciones, y cómo consultar y guardar datos
reutilizando LINQ y async/await, dos herramientas que ya dominabas desde el Módulo 4.
Libro, Socio y Prestamo ya viven en una base de datos relacional real, gestionada por un
ORM en vez de SQL escrito a mano.
Todo lo visto hasta ahora en el Módulo 5 asume que BiblioTech es la única aplicación que lee y
escribe estos datos. La última lección del módulo, Trabajo con JSON y Consumo de APIs REST, da
un paso más: profundiza en System.Text.Json para escenarios más complejos, y usa HttpClient
para que BiblioTech consulte información adicional desde un servicio externo —el escenario
habitual de cualquier aplicación moderna que no vive aislada, sino que se comunica con otros
sistemas a través de una API REST.
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
