Entity Framework Core es el ORM estándar para .NET. Mapea tus clases de C# a tablas de base de datos, gestiona las migraciones a medida que el esquema evoluciona y convierte expresiones LINQ en SQL. Esta guía cubre todo, desde un proyecto en blanco hasta patrones listos para producción.
Cada tema aquí tiene un artículo dedicado con un sample ejecutable y verificado detrás — reunidos en Samples ejecutables de esta guía al final. Los fragmentos de código de abajo están verificados en EF Core 10, y cuando esta guía hace una afirmación sobre rendimiento, el número proviene de un programa en jorgenhoc-org/dotnet-samples que ejecuté yo mismo — conteos de sentencias y cifras de asignación de memoria que puedes reproducir, no adjetivos. Escribir esos samples cambió varias de mis propias recomendaciones por el camino (la más importante: la proyección le gana a Include más a menudo de lo que yo solía afirmar), así que el consejo que hay aquí es el que sobrevivió a ser medido.
LINQ (C#)
var users = await context.Users
.Where(u => u.IsActive)
.OrderBy(u => u.LastName)
.ToListAsync();Generated SQL
SELECT [u].[Id], [u].[Email], [u].[FirstName],
[u].[IsActive], [u].[LastName]
FROM [Users] AS [u]
WHERE [u].[IsActive] = 1
ORDER BY [u].[LastName]Where() → WHERE, OrderBy() → ORDER BY. EF Core translates LINQ operators 1:1.
Instalación
Comienza con un nuevo proyecto ASP.NET Core y agrega los paquetes de EF Core:
dotnet new webapi -n MyApp
cd MyApp
# Paquetes principales de EF
dotnet add package Microsoft.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer # o Npgsql.EntityFrameworkCore.PostgreSQL
dotnet add package Microsoft.EntityFrameworkCore.Tools # para la CLI de migracionesPara SQLite (ideal para desarrollo y aplicaciones pequeñas):
dotnet add package Microsoft.EntityFrameworkCore.SqliteDefinición de Entidades
Las entidades son clases de C# simples. EF Core utiliza convenciones para inferir nombres de tabla, claves primarias y tipos de columna:
// Models/Product.cs
public class Product
{
public int Id { get; set; } // Convención: "Id" → clave primaria
public required string Name { get; set; }
public decimal Price { get; set; }
public int StockQuantity { get; set; }
public DateTime CreatedAt { get; set; }
// Propiedad de navegación (uno a muchos)
public int CategoryId { get; set; }
public Category Category { get; set; } = null!;
}
// Models/Category.cs
public class Category
{
public int Id { get; set; }
public required string Name { get; set; }
// Propiedad de navegación de colección
public List<Product> Products { get; set; } = [];
}Usa required en las propiedades de tipo string (C# 11+) para exigir valores no nulos en tiempo de compilación. EF Core mapea required string a una columna no nullable automáticamente.
Configuración del DbContext
DbContext es la clase central — contiene tus propiedades DbSet<T> y administra la conexión a la base de datos:
// Data/AppDbContext.cs
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options)
: base(options) { }
public DbSet<Product> Products => Set<Product>();
public DbSet<Category> Categories => Set<Category>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
// Configuración con Fluent API (opcional pero recomendada)
modelBuilder.Entity<Product>(entity =>
{
entity.Property(p => p.Name)
.HasMaxLength(200)
.IsRequired();
entity.Property(p => p.Price)
.HasPrecision(18, 2);
entity.HasOne(p => p.Category)
.WithMany(c => c.Products)
.HasForeignKey(p => p.CategoryId)
.OnDelete(DeleteBehavior.Restrict);
});
modelBuilder.Entity<Category>(entity =>
{
entity.Property(c => c.Name)
.HasMaxLength(100)
.IsRequired();
});
}
}Registro del DbContext
En Program.cs:
using Microsoft.EntityFrameworkCore;
var builder = WebApplication.CreateBuilder(args);
// SQL Server
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// O SQLite
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlite("Data Source=app.db"));
// O PostgreSQL
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));Cadena de conexión en appsettings.json:
{
"ConnectionStrings": {
"DefaultConnection": "Server=(localdb)\\mssqllocaldb;Database=MyAppDb;Trusted_Connection=True;"
}
}Migraciones
Las migraciones registran los cambios de esquema como archivos versionados. Cada vez que modificas tus clases de entidad, debes agregar una migración.
# Instalar la herramienta global de EF (configuración única)
dotnet tool install --global dotnet-ef
# Crear la migración inicial
dotnet ef migrations add InitialCreate
# Aplicar las migraciones a la base de datos
dotnet ef database updateEsto genera una carpeta Migrations/ con archivos como:
// Migrations/20250116120000_InitialCreate.cs
public partial class InitialCreate : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(maxLength: 100, nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Products",
columns: table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Name = table.Column<string>(maxLength: 200, nullable: false),
Price = table.Column<decimal>(precision: 18, scale: 2, nullable: false),
StockQuantity = table.Column<int>(nullable: false),
CreatedAt = table.Column<DateTime>(nullable: false),
CategoryId = table.Column<int>(nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Products", x => x.Id);
table.ForeignKey(
name: "FK_Products_Categories_CategoryId",
column: x => x.CategoryId,
principalTable: "Categories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(name: "Products");
migrationBuilder.DropTable(name: "Categories");
}
}Nunca edites los archivos de migración manualmente después de haberlos aplicado a alguna base de datos. Si necesitas hacer un cambio, agrega una nueva migración en su lugar.
Referencia de Comandos de Migración
# Agregar una nueva migración después de modificar entidades
dotnet ef migrations add AddProductDescription
# Actualizar la base de datos a la última migración
dotnet ef database update
# Revertir a una migración específica
dotnet ef database update InitialCreate
# Eliminar la última migración no aplicada
dotnet ef migrations remove
# Generar un script SQL en lugar de aplicarlo directamente (recomendado para producción)
dotnet ef migrations script --output migration.sqlOperaciones CRUD
Crear
// Inyectar AppDbContext mediante inyección de constructor
public class ProductService
{
private readonly AppDbContext _db;
public ProductService(AppDbContext db) => _db = db;
public async Task<Product> CreateProductAsync(string name, decimal price, int categoryId)
{
var product = new Product
{
Name = name,
Price = price,
StockQuantity = 0,
CreatedAt = DateTime.UtcNow,
CategoryId = categoryId
};
_db.Products.Add(product);
await _db.SaveChangesAsync();
return product; // El Id se rellena después de SaveChangesAsync
}
}Leer — Consultas Básicas
// Obtener todos los productos
var products = await _db.Products.ToListAsync();
// Obtener por clave primaria (más eficiente — usa el índice PK)
var product = await _db.Products.FindAsync(42);
// Obtener uno solo con condición
var product = await _db.Products
.FirstOrDefaultAsync(p => p.Id == 42);
// Obtener con datos relacionados (carga ansiosa)
var productsWithCategory = await _db.Products
.Include(p => p.Category)
.ToListAsync();Leer — Consultas LINQ
// Filtrar
var expensiveProducts = await _db.Products
.Where(p => p.Price > 100)
.OrderBy(p => p.Price)
.ToListAsync();
// Proyectar a DTO (evita cargar la entidad completa cuando no es necesario)
var productDtos = await _db.Products
.Where(p => p.StockQuantity > 0)
.Select(p => new ProductDto(p.Id, p.Name, p.Price))
.ToListAsync();
// Paginación
int page = 1, pageSize = 20;
var pagedProducts = await _db.Products
.OrderBy(p => p.Name)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
// Contar
var inStockCount = await _db.Products
.CountAsync(p => p.StockQuantity > 0);
// Any / All
bool hasExpensiveItems = await _db.Products.AnyAsync(p => p.Price > 500);Usa siempre .Select() para proyectar a un DTO cuando solo necesitas un subconjunto de columnas. Cargar entidades completas cuando únicamente necesitas Name y Price desperdicia memoria y añade columnas SQL innecesarias.
Actualizar
// Patrón obtener-y-actualizar (el más seguro, gestiona la concurrencia)
public async Task<bool> UpdatePriceAsync(int productId, decimal newPrice)
{
var product = await _db.Products.FindAsync(productId);
if (product is null) return false;
product.Price = newPrice;
await _db.SaveChangesAsync();
return true;
}
// Actualización masiva (EF Core 7+ ExecuteUpdateAsync — sin carga de entidades)
await _db.Products
.Where(p => p.CategoryId == 5)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.Price, x => x.Price * 0.9m));Eliminar
// Obtener y eliminar
public async Task<bool> DeleteProductAsync(int productId)
{
var product = await _db.Products.FindAsync(productId);
if (product is null) return false;
_db.Products.Remove(product);
await _db.SaveChangesAsync();
return true;
}
// Eliminación masiva (EF Core 7+ ExecuteDeleteAsync — sin carga de entidades)
await _db.Products
.Where(p => p.StockQuantity == 0 && p.CreatedAt < DateTime.UtcNow.AddYears(-2))
.ExecuteDeleteAsync();Relaciones
Uno a Muchos (configurado anteriormente)
Carga de datos relacionados:
// Carga ansiosa con Include
var category = await _db.Categories
.Include(c => c.Products)
.FirstOrDefaultAsync(c => c.Id == categoryId);
// Carga explícita (carga la propiedad de navegación bajo demanda)
var category = await _db.Categories.FindAsync(categoryId);
await _db.Entry(category!).Collection(c => c.Products).LoadAsync();Muchos a Muchos (EF Core 5+)
public class Post
{
public int Id { get; set; }
public required string Title { get; set; }
public List<Tag> Tags { get; set; } = [];
}
public class Tag
{
public int Id { get; set; }
public required string Name { get; set; }
public List<Post> Posts { get; set; } = [];
}
// EF Core 5+ crea la tabla de unión automáticamente — no se necesita entidad adicional
modelBuilder.Entity<Post>()
.HasMany(p => p.Tags)
.WithMany(t => t.Posts)
.UsingEntity(j => j.ToTable("PostTags"));Uno a Uno
public class User
{
public int Id { get; set; }
public required string Email { get; set; }
public UserProfile? Profile { get; set; }
}
public class UserProfile
{
public int Id { get; set; }
public string? Bio { get; set; }
public int UserId { get; set; }
public User User { get; set; } = null!;
}Lo Que Realmente Cuesta Cada Estrategia de Carga
"La carga ansiosa es más rápida que la carga diferida" es el tipo de afirmación que se repite sin evidencia, así que la medí. El sample de N+1 siembra 500 pedidos con 8 líneas de detalle cada uno, ejecuta cada estrategia de carga contra los mismos datos y cuenta las sentencias SQL que EF Core realmente ejecuta:
| Estrategia | Sentencias SQL | Pedidos cargados |
|---|---|---|
| Consulta por fila (N+1, una navegación) | 501 | 500 |
| Consulta por fila (N+1, dos navegaciones) | 1,001 | 500 |
Include (ansiosa, consulta única) | 1 | 500 |
Include + AsSplitQuery() | 2 | 500 |
Proyección con Select() | 1 | 500 |
Reporto conteos en lugar de tiempos deliberadamente: los conteos son reproducibles en cualquier máquina y con cualquier proveedor relacional, así que puedes clonar el sample, ejecutar su seed.sql y obtener exactamente estos números en vez de aceptarlos por fe. Los tiempos dependen de dónde vive tu base de datos — y esa es precisamente la razón por la que los conteos importan. Contra LocalDB, 501 viajes de ida y vuelta cuestan milisegundos y el bug se esconde; contra una base de datos administrada en otra región, cada viaje paga latencia de red real y el mismo código tarda segundos. El conteo de sentencias es la señal honesta porque no cambia cuando cambia la latencia.
Dos cosas de esa tabla me sorprendieron la primera vez que la ejecuté. Primero, AsSplitQuery() emitió dos sentencias, no tres: Customer es una navegación de referencia, así que se queda en el JOIN, y solo la colección se separa — un detalle fácil de pasar por alto si solo has leído que las consultas divididas "emiten una consulta por include". Segundo, la fila de la proyección es la ganadora silenciosa: una sola sentencia y además transfiere únicamente las columnas que nombras. El cambio de mayor impacto en la mayoría de los códigos con EF Core que he revisado es reemplazar ToList()-y-luego-navegar por una proyección con Select(); el artículo de uno-a-muchos aplica la misma medición a cada patrón de carga de relaciones.
Para atrapar el N+1 antes de producción, registra las sentencias en desarrollo (ver Registro de Consultas SQL más abajo) y busca en tu código accesos a propiedades de navegación dentro de bucles. El análisis a fondo del N+1 muestra un DbCommandInterceptor que cuenta consultas en pruebas de integración y hace fallar el build cuando una solicitud supera un umbral.
Consultas Sin Seguimiento
Por defecto, EF Core rastrea las entidades cargadas para la detección de cambios. Para consultas de solo lectura, deshabilita el seguimiento para mejor rendimiento:
// Sin seguimiento para operaciones de lectura
var products = await _db.Products
.AsNoTracking()
.Where(p => p.Price > 50)
.ToListAsync();
// Configurar globalmente para un DbContext usado solo para lecturas
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);Usa .AsNoTracking() en cualquier consulta donde no vayas a actualizar las entidades devueltas. Omite la sobrecarga del rastreador de cambios y puede ofrecer entre un 10 y un 30 % de mejora en el rendimiento para cargas de trabajo intensivas en lectura.
Transacciones
using var transaction = await _db.Database.BeginTransactionAsync();
try
{
_db.Products.Add(newProduct);
await _db.SaveChangesAsync();
_db.Orders.Add(newOrder);
await _db.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}SQL Directo Sin Abrir un Agujero de Inyección
A veces el SQL que necesitas es más fácil de escribir que de conseguir con LINQ. EF Core lo soporta directamente — pero la superficie de la API se divide en una mitad segura y una mitad afilada, y la diferencia es un sufijo:
// SEGURO — FromSql (EF Core 7+) recibe un interpolated string handler.
// El {minPrice} de abajo se convierte en un DbParameter, NO en concatenación de strings.
var products = await _db.Products
.FromSql($"SELECT * FROM Products WHERE Price > {minPrice}")
.ToListAsync();
// PELIGROSO — FromSqlRaw con interpolación de strings es una vulnerabilidad de inyección.
// Nunca hagas esto con entrada del usuario:
var products = await _db.Products
.FromSqlRaw($"SELECT * FROM Products WHERE Name = '{userInput}'") // NO HAGAS ESTO
.ToListAsync();Ese primer ejemplo parece que debería ser vulnerable — se lee exactamente como interpolación de strings — y por eso no le pedí a nadie que lo creyera. El sample de SQL directo ejecuta un intento de inyección real a través de ambas formas contra una base de datos de verdad: la misma entrada de ataque ' OR '1'='1 filtra las 12 filas sembradas cuando se concatena en FromSqlRaw, y coincide con 0 filas a través de la API interpolada, porque ahí es solo un valor de parámetro. La misma entrada, a un sufijo de distancia — y el sample verifica ambos resultados con aserciones entre sus 19 comprobaciones. El artículo de SQL directo recorre la demo, además de SqlQueryRaw<T> para tipos de resultado no mapeados y cómo el SQL directo se compone con LINQ: apilar .Where() e .Include() sobre un SELECT directo sigue ejecutándose como una sola sentencia, con EF envolviendo tu SQL en una subconsulta (visible en ToQueryString()) — aunque un EXEC no puede envolverse así, por lo que los resultados de procedimientos almacenados no se componen.
La seguridad vive en el tipo en tiempo de compilación, no en cómo se ve el código. FromSql acepta únicamente un interpolated string handler, así que sus huecos {...} se convierten en DbParameters. Construye el mismo texto primero como un string normal y la interpolación ya habrá ocurrido — y la única sobrecarga que lo acepta es la raw. Si debes construir SQL dinámicamente, usa FromSqlRaw con objetos de parámetro explícitos y nunca concatenes entrada del usuario en el texto SQL.
Filtros Globales de Consulta: Soft Delete y Multi-Tenancy
Un filtro global de consulta es una cláusula Where que el modelo aplica a cada consulta contra una entidad — el mecanismo estándar para el soft delete y el multi-tenancy a nivel de fila:
public class Product
{
public int Id { get; set; }
public required string Name { get; set; }
public bool IsDeleted { get; set; } // Bandera de eliminación lógica (soft delete)
}
// En OnModelCreating — cada consulta sobre Products ahora recibe WHERE IsDeleted = 0
modelBuilder.Entity<Product>().HasQueryFilter(p => !p.IsDeleted);
// Excluir el filtro para pantallas de administración/restauración
var everything = await _db.Products.IgnoreQueryFilters().ToListAsync();El concepto cabe en un párrafo; los casos límite son donde los equipos se queman, y son la razón por la que el sample de filtros globales de consulta verifica 22 comportamientos separados con aserciones en lugar de narrarlos. Tres de esas comprobaciones vale la pena conocer antes de poner un filtro en producción. Los filtros se aplican a través de Include() y de las propiedades de navegación automáticamente, que es justamente el objetivo — pero corta en ambos sentidos, porque IgnoreQueryFilters() elimina todos los filtros de la entidad, incluido el aislamiento de tenants, no solo el de soft delete que querías omitir. Hay una trampa de fixup: después de una consulta con IgnoreQueryFilters(), las filas eliminadas quedan rastreadas, y una consulta posterior filtrada en el mismo contexto las adjunta a sus resultados de todos modos vía fixup de navegación — el sample lo reproduce, y la solución es un contexto nuevo o AsNoTracking(). Y la más costosa: construir un filtro de tenant con Expression.Constant(tenantProvider) hornea el proveedor de la primera solicitud en el modelo cacheado, lo que el sample demuestra entregando las filas del tenant A a una solicitud del tenant B. El artículo completo cubre cada una con la aserción que la prueba, además de los filtros con nombre de EF Core 10 (IgnoreQueryFilters(["SoftDelete"])) que por fin hacen seguro el bypass selectivo.
Errores Comunes a Evitar
Nunca llames a .ToList() antes de filtrar. _db.Products.ToList().Where(...) carga TODAS las filas en memoria y luego filtra en C#. Filtra siempre con .Where() antes de .ToList() o .ToListAsync() para que el filtro se ejecute en SQL.
No compartas DbContext entre hilos. DbContext no es seguro para uso en múltiples hilos. En ASP.NET Core, usa el tiempo de vida con ámbito (scoped) predeterminado: una instancia por solicitud HTTP.
Habilita el registro de datos sensibles solo en desarrollo. Agrega .EnableSensitiveDataLogging() para ver los valores de los parámetros en los registros de consultas, pero nunca en producción.
Registro de Consultas SQL
Para ver el SQL que genera EF Core (esencial para depurar el rendimiento):
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
{
options.UseSqlServer(connectionString);
if (builder.Environment.IsDevelopment())
{
options.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging();
}
});Aplicación de Migraciones en Tiempo de Ejecución
Para aplicaciones en contenedores, aplica las migraciones al iniciar en lugar de hacerlo como un paso separado:
// Program.cs
var app = builder.Build();
// Aplicar automáticamente las migraciones pendientes al iniciar
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
app.Run();La migración automática al arrancar funciona bien para equipos pequeños. Para equipos grandes o despliegues sin tiempo de inactividad, ejecuta las migraciones como un paso separado antes de desplegar la nueva versión de la aplicación.
¿Cuánto Cuesta el ORM? EF Core vs Dapper, Medido
Tarde o temprano alguien en cada equipo pregunta si EF Core es "demasiado lento", y la respuesta debería ser una medición, no un estado de ánimo. Comparé EF Core contra Dapper y ADO.NET puro con BenchmarkDotNet sobre el mismo esquema y las mismas consultas; el encabezado completo del entorno, las columnas de StdDev y la metodología están en el artículo de EF Core vs Dapper, pero estas filas sostienen el argumento:
| Escenario | Método | Media | Asignado |
|---|---|---|---|
| SELECT de 1,000 filas | EF Core (con seguimiento) | 2,210.5 µs | 1,079.7 KB |
EF Core AsNoTracking | 1,309.6 µs | 427.4 KB | |
| Dapper | 793.9 µs | 201.5 KB | |
| Búsqueda de una fila por PK | EF Core FirstOrDefaultAsync | 370.4 µs | 74.7 KB |
| Dapper | 151.9 µs | 6.8 KB |
Dos lecturas honestas de esa tabla. Sí, Dapper es aproximadamente 2–3× más rápido en lecturas y asigna una fracción de la memoria — si tu carga de trabajo está dominada por endpoints de lectura de alto volumen, esa brecha es real. Pero mira lo que una sola línea de EF Core recupera: AsNoTracking() por sí solo redujo la consulta de 1,000 filas de 2,210 µs a 1,310 µs y recortó las asignaciones en un 60 %, cerrando la mayor parte de la distancia con Dapper sin renunciar a LINQ, las migraciones ni el seguimiento de cambios donde todavía los quieres. El patrón con el que me he quedado para aplicaciones grandes es híbrido: EF Core para las escrituras y todo lo que toque el rastreador de cambios, Dapper para el puñado de rutas de lectura que el profiling — no la intuición — demuestra que están calientes. La comparación completa incluye proyecciones con JOIN, inserciones y una tabla de decisión para elegir por carga de trabajo en lugar de por religión.
Samples ejecutables de esta guía
Cada sección de arriba se amplía en un artículo enfocado con un proyecto de consola que puedes clonar y ejecutar — cada uno verifica sus afirmaciones con aserciones o reporta conteos deterministas de sentencias SQL, así que los números se reproducen en tu máquina en vez de tomarse por fe. Todos están en jorgenhoc-org/dotnet-samples.
| Sección de esta guía | Artículo dedicado | Sample |
|---|---|---|
| Migraciones | Recorrido por las migraciones | ef-core-migrations-walkthrough — cuatro archivos de migración reales |
| Relaciones → uno-a-muchos | Relaciones uno-a-muchos | ef-core-one-to-many — estrategias de carga por conteo de sentencias |
| Relaciones → muchos-a-muchos | Relaciones muchos-a-muchos | ef-core-many-to-many — unión implícita + entidad de unión explícita |
Sin seguimiento, SQL directo, ExecuteUpdate/ExecuteDelete | Consultas SQL directas | ef-core-raw-sql — parametrización vs una inyección real |
Consultas de lectura y rendimiento de Include | El problema N+1 | ef-core-n-plus-one — la avalancha, y luego las soluciones |
| Cuándo dejar el ORM | EF Core vs Dapper | ef-core-vs-dapper — compensaciones medidas |
| Filtros de consulta (soft delete, multi-tenancy) | Filtros globales de consulta | ef-core-global-query-filters — 22 comprobaciones con aserciones |
Próximos Pasos
Con los conceptos básicos bien asentados, explora:
- Rendimiento: Consultas compiladas, procesamiento por lotes y el flujo de migraciones para producción
- Relaciones avanzadas: Entidades propias, herencia por jerarquía de tabla
- Interceptores: Registro de auditoría, automatización de eliminación lógica (combina con los filtros globales de consulta)
- Características recientes de EF Core (8 a 10): Columnas JSON, tipos complejos y filtros de consulta con nombre