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

  1. Qué es un ORM y qué problema resuelve frente a ADO.NET manual
  2. Instalar Entity Framework Core y el proveedor de SQLite
  3. DbContext y DbSet<T>: mapeando el dominio de BiblioTech
  4. Configurar el modelo sin tocar las clases de dominio: Fluent API en OnModelCreating
  5. Migraciones: dotnet ef migrations add y dotnet ef database update
  6. Consultas LINQ contra un DbSet<T>
  7. Guardar cambios: SaveChanges() y SaveChangesAsync()

  1. 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.

  1. 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-ef

Microsoft.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.

  1. DbContext y DbSet<T>: mapeando el dominio de BiblioTech

El 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.

  1. Configurar el modelo sin tocar las clases de dominio: Fluent API en OnModelCreating

EF 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.

  1. Migraciones: dotnet ef migrations add y dotnet ef database update

Una 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:

dotnet ef migrations add InicialBiblioTech
dotnet ef database update
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.

  1. Consultas LINQ contra un 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.

  1. Guardar cambios: 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 con contexto.Libros.Add(...) no la persiste por sí solo; sin llamar a SaveChanges(), el cambio solo existe en memoria y se pierde al cerrar el DbContext.
  • No generar ni aplicar una migración tras cambiar el modelo: si se añade una propiedad nueva a Libro sin ejecutar dotnet ef migrations add y dotnet 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 HasKey para 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 de Prestamo), hay que definir una propiedad sombra explícitamente con Property<T>() + HasKey() en OnModelCreating.
  • Crear un DbContext nuevo 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 por DbContext (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 un DbSet<T> es más legible y menos propenso a errores que SQL escrito a mano.

Ejercicios

  1. Define BibliotecaDbContext con DbSet<Libro> Libros y DbSet<Socio> Socios, configurando HasKey para ambos en OnModelCreating tal como se ha mostrado en esta lección. Genera la migración inicial con dotnet ef migrations add y aplícala con dotnet ef database update.

  2. Usando el BibliotecaDbContext del ejercicio anterior, añade dos Libro nuevos con contexto.Libros.Add(...) y guarda los cambios con SaveChangesAsync(). Después, en una nueva instancia de BibliotecaDbContext, consulta con LINQ (Where + OrderBy) los libros disponibles ordenados por título.

  3. Recupera un Libro existente con FirstOrDefault por su Isbn, llama a Prestar() sobre él, y guarda los cambios con SaveChangesAsync() sin llamar a ningún método Update explícito. Vuelve a consultarlo en una nueva instancia del contexto y comprueba que Disponible ahora es false.

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#

Módulo 2: Estructuras de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Conceptos Avanzados de C#

Módulo 5: Trabajando con Datos

Módulo 6: Temas Avanzados

Módulo 7: Construcción de Aplicaciones

Módulo 8: Mejores Prácticas y Patrones de Diseño

Módulo 9: Proyecto Final

© Copyright 2026. Todos los derechos reservados