Cheatsheet Entity Framework
ORM para .NET
Entity Framework
Configuração
Instalar paquetes NuGet
dotnet add package Microsoft.EntityFrameworkCore dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools dotnet add package Microsoft.EntityFrameworkCore.Design
Los paquetes EntityFrameworkCore (core), SqlServer (provider) y Tools/Design (CLI y migrations) son esenciales. Instala vía dotnet add package o NuGet Manager.
OnModelCreating
protected override void OnModelCreating(
ModelBuilder modelBuilder)
{
modelBuilder.Entity<Cliente>(e =>
{
e.HasKey(c => c.Id);
e.Property(c => c.Nombre)
.HasMaxLength(100)
.IsRequired();
});
}El OnModelCreating configura entidades vía Fluent API. Se llama una vez en la inicialización. Tiene prioridad sobre las Data Annotations.
Retry on failure
options.UseSqlServer(conn, sqlOptions =>
{
sqlOptions.EnableRetryOnFailure(
maxRetryCount: 3,
maxRetryDelay: TimeSpan.FromSeconds(5),
errorNumbersToAdd: null
);
});El EnableRetryOnFailure() reintenta automáticamente en fallos transitorios de red. Esencial para la nube (Azure SQL). Configura los intentos y el delay.
DbContext básico
public class AppDbContext : DbContext
{
public DbSet<Cliente> Clientes { get; set; }
public DbSet<Producto> Productos { get; set; }
protected override void OnConfiguring(
DbContextOptionsBuilder options)
{
options.UseSqlServer(connectionString);
}
}El DbContext es la clase principal de acceso a datos. Cada DbSet<T> representa una tabla. El OnConfiguring define el provider y la connection string.
Múltiples providers
// SQL Server
options.UseSqlServer(conn);
// PostgreSQL
options.UseNpgsql(conn);
// SQLite
options.UseSqlite("Data Source=app.db");
// InMemory (tests)
options.UseInMemoryDatabase("TestDb");EF Core soporta múltiples providers: SqlServer, Npgsql (PostgreSQL), Sqlite e InMemory (tests). Cada uno tiene su propio paquete NuGet.
Command timeout
options.UseSqlServer(conn, sqlOptions =>
{
sqlOptions.CommandTimeout(60); // segundos
});
// O por consulta:
context.Database.SetCommandTimeout(120);El CommandTimeout define el tiempo máximo (segundos) para comandos SQL. El valor por defecto es 30s. Auméntalo para queries pesadas o informes.
Dependency Injection
// Program.cs
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("Default")
)
);
// Inyectar en el controller:
public class ClientesController : ControllerBase
{
private readonly AppDbContext _context;
public ClientesController(AppDbContext context)
=> _context = context;
}El AddDbContext registra el contexto en el contenedor DI con scope por request. Inyéctalo vía constructor en los controllers y servicios.
DbContext Pooling
builder.Services.AddDbContextPool<AppDbContext>(
options => options.UseSqlServer(conn),
poolSize: 128
);El AddDbContextPool reutiliza instancias de DbContext en vez de crear nuevas. Mejora el rendimiento en escenarios de alto rendimiento. El pool es thread-safe.
ApplyConfigurationsFromAssembly
// Clase de configuración separada:
public class ClienteConfig
: IEntityTypeConfiguration<Cliente>
{
public void Configure(EntityTypeBuilder<Cliente> e)
{
e.HasKey(c => c.Id);
e.Property(c => c.Nombre).HasMaxLength(100);
}
}
// En OnModelCreating:
modelBuilder.ApplyConfigurationsFromAssembly(
typeof(AppDbContext).Assembly);El IEntityTypeConfiguration<T> separa la configuración en clases propias. El ApplyConfigurationsFromAssembly() las registra todas automáticamente.
Connection string
// appsettings.json
"ConnectionStrings": {
"Default": "Server=localhost;Database=MiDb;Trusted_Connection=True;TrustServerCertificate=True"
}
// Acceso:
var conn = builder.Configuration
.GetConnectionString("Default");La connection string está en appsettings.json. El GetConnectionString() la lee por el nombre. Para SQL Server usa Trusted_Connection o user/password.
Logging y Debug
options.UseSqlServer(conn)
.LogTo(Console.WriteLine, LogLevel.Information)
.EnableSensitiveDataLogging()
.EnableDetailedErrors();El LogTo() muestra el SQL generado en la consola. El EnableSensitiveDataLogging() incluye los valores de los parámetros. Úsalo solo en desarrollo.
Segundo DbContext (read-only)
builder.Services.AddDbContext<ReadOnlyContext>(
options => options.UseSqlServer(readReplicaConn),
ServiceLifetime.Transient
);Puedes tener múltiples DbContext para separar lectura/escritura o conectar a bases diferentes. El ServiceLifetime controla el tiempo de vida.
Modelos e Mapeamento
Entidad simple (POCO)
public class Cliente
{
public int Id { get; set; }
public string Nombre { get; set; }
public string Email { get; set; }
public DateTime CriadoEm { get; set; }
}Una entidad es una clase POCO simple. EF la mapea automáticamente por convención: Id es la clave, las propiedades se convierten en columnas.
Enum como string
public enum Estado { Activo, Inactivo, Suspendido }
// Fluent API:
e.Property(p => p.Estado)
.HasConversion<string>()
.HasMaxLength(20);
// O con una Data Annotation:
[EnumDataType(typeof(Estado))]
public string Estado { get; set; }El HasConversion<string>() guarda el enum como texto legible en la BD en vez de numero. Facilita las consultas directas y el debugging.
Default values
e.Property(p => p.CriadoEm)
.HasDefaultValueSql("GETUTCDATE()");
e.Property(p => p.Estado)
.HasDefaultValue("activo");
e.Property(p => p.Ordem)
.HasDefaultValue(0);El HasDefaultValueSql() define valores SQL (ej.: GETUTCDATE()). El HasDefaultValue() usa valores literales. Se aplican en la migration.
Table-per-Type (TPT)
modelBuilder.Entity<Perro>().ToTable("Caes");
modelBuilder.Entity<Gato>().ToTable("Gatos");El TPT crea una tabla por tipo en la jerarquía, enlazadas por una FK. Normaliza los datos pero las queries son más complejas (JOINs). Usa ToTable() en cada subtipo.
Data Annotations
public class Producto
{
[Key]
public int Id { get; set; }
[Required]
[MaxLength(200)]
public string Nombre { get; set; }
[Column(TypeName = "decimal(18,2)")]
public decimal Precio { get; set; }
[NotMapped]
public string NomeCompleto => $"{Nombre} ({Precio})";
}Las Data Annotations configuran vía atributos: [Key], [Required], [MaxLength], [Column], [NotMapped]. Más simple que la Fluent API.
Value Conversions
e.Property(p => p.Tags)
.HasConversion(
v => string.Join(",", v), // al guardar
v => v.Split(",",
StringSplitOptions.None).ToList() // al leer
);
// Convertir DateTime a Unix:
e.Property(p => p.Data)
.HasConversion<long>();Las Value Conversions transforman tipos al guardar/leer. El primer lambda convierte a la BD, el segundo convierte desde la BD. Útil para listas, JSON, etc.
Computed columns
e.Property(p => p.NomeCompleto)
.HasComputedColumnSql("[Nombre] + ' ' + [Apellido]");
// Stored (persistida):
e.Property(p => p.Total)
.HasComputedColumnSql("[Precio] * [Cantidad]",
stored: true);El HasComputedColumnSql() crea columnas calculadas en la BD. Con stored: true el valor se persiste (mejor para lectura frecuente).
Concurrency Token
[Timestamp]
public byte[] RowVersion { get; set; }
// O Fluent API:
e.Property(p => p.RowVersion)
.IsRowVersion();
// Token manual:
e.Property(p => p.Versión)
.IsConcurrencyToken();El IsRowVersion() crea un token de concurrencia optimista. SQL Server lo actualiza automáticamente. Si otro usuario lo modifica, lanza DbUpdateConcurrencyException.
Fluent API: propiedades
modelBuilder.Entity<Producto>(e =>
{
e.ToTable("Productos");
e.Property(p => p.Nombre)
.HasMaxLength(200)
.IsRequired();
e.Property(p => p.Precio)
.HasPrecision(18, 2);
e.HasIndex(p => p.Nombre).IsUnique();
});La Fluent API ofrece más control: ToTable() define el nombre, HasPrecision() para decimales, HasIndex() crea índices. Tiene prioridad sobre las annotations.
Shadow Properties
modelBuilder.Entity<Post>()
.Property<DateTime>("UltimaModificacion");
// Acceder:
context.Entry(post)
.Property("UltimaModificacion").CurrentValue = DateTime.Now;
// En queries:
context.Posts.OrderBy("UltimaModificacion");Las Shadow Properties existen en la BD pero no en la clase. Útiles para auditoría. Accede vía Entry().Property() o string en queries.
Owned types (Value Objects)
public class Direccion
{
public string Calle { get; set; }
public string Ciudad { get; set; }
public string CodigoPostal { get; set; }
}
// Configuración:
e.OwnsOne(c => c.Direccion, m =>
{
m.Property(x => x.Calle).HasMaxLength(200);
m.Property(x => x.Ciudad).HasMaxLength(100);
});Los Owned Types mapean value objects en la misma tabla. No tienen identidad propia. Ideal para Direccion, Coordenadas, etc.
Backing fields
public class Pedido
{
private readonly List<Item> _ítems = new();
public IReadOnlyCollection<Item> Ítems
=> _ítems.AsReadOnly();
public void AddItem(Item item) => _ítems.Add(item);
}
// Config:
e.Metadata.FindNavigation(nameof(Pedido.Ítems))!
.SetPropertyAccessMode(PropertyAccessMode.Field);Los Backing Fields permiten encapsular colecciones. EF accede al campo privado directamente. Exponlo como una IReadOnlyCollection y controla las mutaciones vía métodos.
Clave primaria compuesta
public class ItemPedido
{
public int PedidoId { get; set; }
public int ProdutoId { get; set; }
public int Cantidad { get; set; }
}
// Fluent API:
modelBuilder.Entity<ItemPedido>()
.HasKey(i => new { i.PedidoId, i.ProdutoId });Una clave compuesta usa múltiples columnas como PK. Defínela con HasKey() pasando un tipo anónimo. No puede ser auto-incremento.
Índices
// Índice simple:
e.HasIndex(p => p.Email).IsUnique();
// Índice compuesto:
e.HasIndex(p => new { p.Nombre, p.Ciudad });
// Con filtro (SQL Server):
e.HasIndex(p => p.Email)
.HasFilter("[Email] IS NOT NULL");El HasIndex() crea índices para el rendimiento. El IsUnique() impide duplicados. Los índices compuestos aceptan tipos anónimos. El HasFilter() crea índices filtrados.
Table-per-Hierarchy (TPH)
public abstract class Animal
{
public int Id { get; set; }
public string Nombre { get; set; }
}
public class Perro : Animal { public string Raca { get; set; } }
public class Gato : Animal { public bool Interior { get; set; } }
// Discriminator:
e.HasDiscriminator<string>("Tipo")
.HasValue<Perro>("perro")
.HasValue<Gato>("gato");El TPH (por defecto) guarda toda la jerarquía en una tabla con un Discriminator. Simple y rápido. Las columnas de subtipos pasan a nullable.
Keyless entities (Views)
public class RelatorioVendas
{
public string Mes { get; set; }
public decimal Total { get; set; }
}
// Config:
modelBuilder.Entity<RelatorioVendas>(e =>
{
e.HasNoKey();
e.ToView("vw_RelatorioVendas");
});
// Query:
var datos = await context.Set<RelatorioVendas>().ToListAsync();Las Keyless Entities mapean views o queries sin PK. Usa HasNoKey() y ToView(). Son de solo lectura y no soportan tracking.
CRUD
Crear (Insert)
var cliente = new Cliente
{
Nombre = "Ana",
Email = "ana@mail.com"
};
context.Clientes.Add(cliente);
await context.SaveChangesAsync();
// ID generado automáticamente:
Console.WriteLine(cliente.Id);El Add() marca la entidad como nueva y el SaveChangesAsync() ejecuta el INSERT. El ID auto-incremento se rellena después de guardar.
Eliminar (Delete)
var cliente = await context.Clientes.FindAsync(1); context.Clientes.Remove(cliente); await context.SaveChangesAsync(); // Múltiples: context.Clientes.RemoveRange(inativos); await context.SaveChangesAsync();
El Remove() marca para eliminación y el SaveChangesAsync() ejecuta el DELETE. El RemoveRange() elimina varios de una vez.
Upsert (AddOrUpdate)
// EF Core no tiene upsert nativo — patrón manual:
var existente = await context.Productos
.FirstOrDefaultAsync(p => p.Sku == sku);
if (existente != null)
{
existente.Precio = novoPreco;
}
else
{
context.Productos.Add(new Producto { Sku = sku, Precio = novoPreco });
}
await context.SaveChangesAsync();EF Core no tiene upsert nativo. El patrón es buscar y decidir entre update o insert. Alternativa: ExecuteUpdate + fallback a insert.
SaveChanges con validación
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
// Error de BD (constraint, etc.)
Console.WriteLine(ex.InnerException?.Message);
}
catch (DbUpdateConcurrencyException ex)
{
// Conflicto de concurrencia
var entry = ex.Entries.Single();
}El SaveChangesAsync() lanza DbUpdateException en errores de BD y DbUpdateConcurrencyException en conflictos. Maneja ambos para robustez.
Leer por ID (Find)
// Por clave primaria (usa la cache local): var cliente = await context.Clientes.FindAsync(1); // Con clave compuesta: var item = await context.Ítems.FindAsync(pedidoId, produtoId); // Devuelve null si no existe
El FindAsync() búsqueda por clave primaria. Comprueba primero el Change Tracker (cache local) antes de ir a la BD. Devuelve null si no existe.
AddRange (múltiples)
var productos = new List<Producto>
{
new() { Nombre = "Teclado", Precio = 49.99m },
new() { Nombre = "Ratón", Precio = 29.99m },
new() { Nombre = "Monitor", Precio = 299.99m },
};
context.Productos.AddRange(productos);
await context.SaveChangesAsync();El AddRange() añade varias entidades de una vez. Más eficiente que Add() en un bucle porque genera un único batch de INSERTs.
Attach y estados
// Attach (estado Unchanged): context.Clientes.Attach(cliente); // Marcar como modificado: context.Entry(cliente).State = EntityState.Modified; // Estados posibles: // Detached, Unchanged, Added, Modified, Deleted
El Attach() enlaza una entidad desconectada al contexto. El Entry().State define el estado manualmente. Útil en APIs stateless.
Find vs FirstOrDefault
// Find: usa la cache local, no genera SQL si ya está rastreado
var c1 = await context.Clientes.FindAsync(1);
// FirstOrDefault: siempre va a la BD
var c2 = await context.Clientes
.FirstOrDefaultAsync(c => c.Id == 1);
// Find no acepta IncludeEl FindAsync() comprueba la cache local primero (más rápido). El FirstOrDefaultAsync() genera siempre SQL. El Find no soporta Include().
Leer con condiciones
// Primero con condición:
var c = await context.Clientes
.FirstOrDefaultAsync(c => c.Email == email);
// Único (lanza excepción si hay múltiples):
var c2 = await context.Clientes
.SingleAsync(c => c.Id == 1);
// Todos:
var todos = await context.Clientes.ToListAsync();El FirstOrDefaultAsync() devuelve el primero o null. El SingleAsync() lanza excepción si hay más de uno. El ToListAsync() trae todos.
ExecuteUpdate (EF Core 7+)
int afetados = await context.Clientes
.Where(c => c.Activo == false)
.ExecuteUpdupTosync(s => s
.SetProperty(c => c.Estado, "arquivado")
.SetProperty(c => c.ArquivadoEm, DateTime.Now)
);El ExecuteUpdupTosync() (EF Core 7+) hace actualizaciones masivas sin cargar entidades. Genera un UPDATE SQL directo. Devuelve el numero de filas afectadas.
Change Tracker
// Ver cambios pendientes:
var entries = context.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified);
foreach (var entry in entries)
{
Console.WriteLine(entry.Entity.GetType().Name);
foreach (var prop in entry.Properties)
{
if (prop.IsModified)
Console.WriteLine($" {prop.Metadata.Name}: {prop.OriginalValue} -> {prop.CurrentValue}");
}
}El Change Tracker detecta cambios automáticamente. El Entries() lista las entidades rastreadas y el Properties muestra los valores originales vs actuales.
Actualizar (Update)
var cliente = await context.Clientes.FindAsync(1); cliente.Nombre = "Ana Silva"; cliente.Email = "ana.silva@mail.com"; await context.SaveChangesAsync(); // O attach + estado: context.Clientes.Update(cliente); await context.SaveChangesAsync();
Con tracking activo, basta modificar las propiedades y llamar a SaveChangesAsync(). El Update() marca todas las propiedades como modificadas.
ExecuteDelete (EF Core 7+)
int removidos = await context.Logs
.Where(l => l.Data < DateTime.Now.AddYears(-1))
.ExecuteDeleteAsync();El ExecuteDeleteAsync() elimina en masa sin cargar. Genera un DELETE SQL directo. Mucho más rápido que RemoveRange() para miles de registros.
DetectChanges manual
// Desactivar auto-detect (rendimiento en bulk): context.ChangeTracker.AutoDetectChangesEnabled = false; // Detectar manualmente: context.ChangeTracker.DetectChanges(); // Limpiar tracking: context.ChangeTracker.Clear();
El AutoDetectChangesEnabled = false desactiva la detección automática (mejor en operaciones bulk). El Clear() limpia el tracker sin guardar.
Consultas (LINQ)
Where (filtrar)
var ativos = await context.Clientes
.Where(c => c.Activo && c.Ciudad == "Lisbon")
.ToListAsync();
// Condiciones múltiples:
var resultado = await context.Productos
.Where(p => p.Precio > 10 && p.Precio < 100)
.Where(p => p.Stock > 0)
.ToListAsync();El Where() filtra con expresiones lambda. Encadena varios Where() (AND implícito). Se traduce a WHERE SQL.
Any / All
bool existe = await context.Clientes
.AnyAsync(c => c.Email == email);
bool todosAtivos = await context.Pedidos
.AllAsync(p => p.Estado == "concluido");
// Equivalente SQL: EXISTS / NOT EXISTSEl AnyAsync() verifica la existencia (genera EXISTS). El AllAsync() verifica si todos cumplen la condición. Más eficiente que Count() > 0.
AsNoTracking
var productos = await context.Productos
.AsNoTracking()
.Where(p => p.Activo)
.ToListAsync();
// O global:
context.ChangeTracker.QueryTrackingBehavior =
QueryTrackingBehavior.NoTracking;El AsNoTracking() desactiva el rastreo (solo lectura). Más rápido y menos memoria. Ideal para queries de lectura. Puede definirse globalmente.
Conditional (ternario en SQL)
var resultado = await context.Productos
.Select(p => new
{
p.Nombre,
Classificacao = p.Precio > 100 ? "caro" : "barato"
})
.ToListAsync();
// Genera: CASE WHEN Precio > 100 THEN 'caro' ELSE 'barato' ENDLas expresiones ternarias en el Select() se traducen a CASE WHEN en SQL. Útil para clasificaciones y formateos en la query.
OrderBy / ThenBy
var ordenados = await context.Productos
.OrderBy(p => p.Categoria)
.ThenByDescending(p => p.Precio)
.ToListAsync();
// Descendente:
var recientes = await context.Clientes
.OrderByDescending(c => c.CriadoEm)
.ToListAsync();El OrderBy() ordena de forma ascendente y OrderByDescending() descendente. El ThenBy() añade una ordenación secundaria.
Paginación (Skip/Take)
int pagina = 2, tamano = 10;
var ítems = await context.Productos
.OrderBy(p => p.Nombre)
.Skip((pagina - 1) * tamano)
.Take(tamano)
.ToListAsync();
// Total para la UI:
int total = await context.Productos.CountAsync();El Skip() salta registros y el Take() limita. Requiere OrderBy() para un orden consistente. Genera OFFSET/FETCH en SQL Server.
Contains / In
var ids = new List<int> { 1, 2, 3, 5 };
var productos = await context.Productos
.Where(p => ids.Contains(p.Id))
.ToListAsync();
// Genera: WHERE Id IN (1, 2, 3, 5)El Contains() con una lista genera WHERE IN (...). Útil para filtrar por múltiples valores. La lista puede venir de otra query.
AsSplitQuery
var clientes = await context.Clientes
.Include(c => c.Pedidos)
.ThenInclude(p => p.Ítems)
.AsSplitQuery()
.ToListAsync();El AsSplitQuery() divide el Include en múltiples queries SQL en vez de un JOIN gigante. Evita la cartesian explosion con múltiples colecciones.
Select (proyección)
// Tipo anónimo:
var nombres = await context.Clientes
.Select(c => new { c.Nombre, c.Email })
.ToListAsync();
// DTO:
var dtos = await context.Productos
.Select(p => new ProdutoDto(p.Nombre, p.Precio))
.ToListAsync();El Select() proyecta a tipos anónimos o DTOs. Trae solo las columnas necesarias — mucho más eficiente que cargar la entidad completa.
Distinct
var categorias = await context.Productos
.Select(p => p.Categoria)
.Distinct()
.ToListAsync();
// DistinctBy (EF Core 6+):
var unicos = await context.Clientes
.DistinctBy(c => c.Ciudad)
.ToListAsync();El Distinct() elimina duplicados. El DistinctBy() (EF Core 6+) los elimina por un campo específico. Se traduce a SELECT DISTINCT.
First / Single / Last
// Primero (o excepción):
var primero = await context.Productos
.OrderBy(p => p.Precio)
.FirstAsync();
// Único (excepción si 0 o >1):
var unico = await context.Clientes
.SingleAsync(c => c.Email == email);
// Versiones "OrDefault" devuelven null:
var quizás = await context.Productos
.FirstOrDefaultAsync(p => p.Id == 999);El FirstAsync() lanza si está vacío. El SingleAsync() lanza si no es exactamente uno. Las versiones OrDefault devuelven null en vez de lanzar.
AsQueryable dinámico
IQueryable<Producto> query = context.Productos;
if (!string.IsNullOrEmpty(nombre))
query = query.Where(p => p.Nombre.Contains(nombre));
if (precoMin > 0)
query = query.Where(p => p.Precio >= precoMin);
if (categoria != null)
query = query.Where(p => p.Categoria == categoria);
var resultado = await query.ToListAsync();Construye queries dinámicamente con IQueryable. Cada Where() solo se aplica si el filtro existe. El SQL final solo incluye las condiciones activas.
Agregaciones
int total = await context.Productos.CountAsync(); decimal max = await context.Productos.MaxAsync(p => p.Precio); decimal min = await context.Productos.MinAsync(p => p.Precio); decimal suma = await context.Productos.SumAsync(p => p.Precio); double media = await context.Productos.AverageAsync(p => p.Precio);
Métodos de agregación asíncronos: CountAsync(), MaxAsync(), MinAsync(), SumAsync(), AverageAsync(). Generan SQL agregado.
GroupBy
var grupos = await context.Productos
.GroupBy(p => p.Categoria)
.Select(g => new
{
Categoria = g.Key,
Total = g.Count(),
PrecoMedio = g.Average(p => p.Precio)
})
.ToListAsync();El GroupBy() agrupa y el Select() proyecta los agregados. Genera GROUP BY con COUNT/AVG en el SQL.
Like / String methods
var resultados = await context.Productos
.Where(p => p.Nombre.Contains("ratón"))
.ToListAsync();
// StartsWith / EndsWith:
var porPrefixo = await context.Clientes
.Where(c => c.Nombre.StartsWith("Ana"))
.ToListAsync();
// EF.Functions.Like:
var like = await context.Productos
.Where(p => EF.Functions.Like(p.Nombre, "%port%"))
.ToListAsync();El Contains(), StartsWith() y EndsWith() se traducen a LIKE. El EF.Functions.Like() da control total del patrón.
FromSql (raw queries)
var clientes = await context.Clientes
.FromSql($"SELECT * FROM Clientes WHERE Ciudad = {ciudad}")
.ToListAsync();
// Componer con LINQ:
var ativos = await context.Clientes
.FromSql($"SELECT * FROM Clientes")
.Where(c => c.Activo)
.OrderBy(c => c.Nombre)
.ToListAsync();El FromSql() (interpolado, seguro) ejecuta SQL raw y permite componer con LINQ. El FromSqlRaw() usa placeholders {0} para parámetros.
Relações
One-to-Many
public class Cliente
{
public int Id { get; set; }
public List<Pedido> Pedidos { get; set; } = new();
}
public class Pedido
{
public int Id { get; set; }
public int ClienteId { get; set; } // FK
public Cliente Cliente { get; set; }
}La relación One-to-Many usa una FK (ClienteId) en el lado "muchos". Las propiedades de navegación (Pedidos, Cliente) permiten acceder a los relacionados.
Include (Eager Loading)
var cliente = await context.Clientes
.Include(c => c.Pedidos)
.ThenInclude(p => p.Ítems)
.ThenInclude(i => i.Producto)
.FirstOrDefaultAsync(c => c.Id == 1);El Include() carga relaciones eagerly en una sola query. El ThenInclude() encadena sub-relaciones. Evita N+1 pero puede generar JOINs grandes.
Filtered Include
var clientes = await context.Clientes
.Include(c => c.Pedidos
.Where(p => p.Estado == "pendiente")
.OrderByDescending(p => p.Data)
.Take(5))
.ToListAsync();El Filtered Include (EF Core 5+) aplica Where(), OrderBy() y Take() dentro del Include. Carga solo los relacionados que interesan.
InverseProperty
public class Post
{
public int Id { get; set; }
public int AutorId { get; set; }
public int RevisorId { get; set; }
[ForeignKey("AutorId")]
public User Autor { get; set; }
[ForeignKey("RevisorId")]
public User Revisor { get; set; }
}Cuando hay múltiples relaciones entre las mismas entidades, usa [ForeignKey] o HasOne().WithMany().HasForeignKey() para desambiguar qué FK pertenece a qué navegación.
One-to-One
public class Persona
{
public int Id { get; set; }
public Passaporte Passaporte { get; set; }
}
public class Passaporte
{
public int Id { get; set; }
public int PessoaId { get; set; }
public Persona Persona { get; set; }
}
// Fluent API:
e.HasOne(p => p.Passaporte)
.WithOne(p => p.Persona)
.HasForeignKey<Passaporte>(p => p.PessoaId);La relación One-to-One usa HasOne().WithOne() y define la FK con HasForeignKey<T>(). La FK queda en una de las tablas.
Multiple Includes
var pedido = await context.Pedidos
.Include(p => p.Cliente)
.Include(p => p.Ítems)
.ThenInclude(i => i.Producto)
.Include(p => p.Cupao)
.FirstOrDefaultAsync(p => p.Id == id);Múltiples Include() cargan varias relaciones del mismo nivel. Cada uno añade un JOIN o split query. Combínalo con ThenInclude() para profundidad.
ForeignKey sin navegación
// Solo FK, sin propiedad de navegación:
public class Pedido
{
public int Id { get; set; }
public int ClienteId { get; set; }
}
// Config:
e.HasOne<Cliente>()
.WithMany()
.HasForeignKey(p => p.ClienteId);Puedes tener solo la FK sin propiedad de navegación. El HasOne<T>().WithMany() sin argumentos define la relación. Útil para simplificar el model.
Cargar relacionados (query)
// Join manual con Select:
var datos = await context.Pedidos
.Select(p => new
{
PedidoId = p.Id,
ClienteNome = p.Cliente.Nombre,
TotalItens = p.Ítems.Count
})
.ToListAsync();En vez de Include(), proyecta con Select() y accede a las navegaciones directamente. Genera JOINs optimizados y trae solo lo necesario.
Many-to-Many
public class Estudiante
{
public int Id { get; set; }
public List<Curso> Cursos { get; set; } = new();
}
public class Curso
{
public int Id { get; set; }
public List<Estudiante> Estudiantes { get; set; } = new();
}
// EF Core 5+ crea la tabla join automáticamenteLa relación Many-to-Many (EF Core 5+) crea la tabla join implícitamente. Basta tener una List<T> en ambos lados. Sin FK explícita.
Explicit Loading
var cliente = await context.Clientes.FindAsync(1);
// Cargar colección:
await context.Entry(cliente)
.Collection(c => c.Pedidos)
.LoadAsync();
// Cargar referencia:
await context.Entry(pedido)
.Reference(p => p.Cliente)
.LoadAsync();La carga explícita usa Entry().Collection().LoadAsync() para colecciones y Reference().LoadAsync() para una navegación única. Carga bajo demanda.
Delete behavior
e.HasOne(p => p.Cliente)
.WithMany(c => c.Pedidos)
.HasForeignKey(p => p.ClienteId)
.OnDelete(DeleteBehavior.Cascade);
// Opciones:
// Cascade → elimina hijos
// Restrict → impide eliminación
// SetNull → FK queda null
// ClientCascade → cascade en el clienteEl OnDelete() define el comportamiento al eliminar el padre. Cascade elimina hijos, Restrict lo impide, SetNull anula la FK.
Many-to-Many con payload
public class EstudianteCurso
{
public int EstudianteId { get; set; }
public int CursoId { get; set; }
public DateTime FechaInscripcion { get; set; }
public Estudiante Estudiante { get; set; }
public Curso Curso { get; set; }
}
// Config:
e.HasOne(sc => sc.Estudiante)
.WithMany(s => s.EstudianteCursos)
.HasForeignKey(sc => sc.EstudianteId);Cuando la tabla join tiene datos extra (payload), crea una entidad explícita. Define las dos relaciones HasOne().WithMany() con las FKs.
Lazy Loading (Proxies)
// Instalar: Microsoft.EntityFrameworkCore.Proxies
options.UseLazyLoadingProxies();
// Las propiedades deben ser virtual:
public class Cliente
{
public virtual List<Pedido> Pedidos { get; set; }
}
// Acceso automático:
var cliente = await context.Clientes.FindAsync(1);
var pedidos = cliente.Pedidos; // query automáticaEl Lazy Loading carga relaciones automáticamente al acceder. Requiere UseLazyLoadingProxies() y propiedades virtual. Cuidado con N+1.
Self-referencing
public class Categoria
{
public int Id { get; set; }
public string Nombre { get; set; }
public int? PaiId { get; set; }
public Categoria Pai { get; set; }
public List<Categoria> Filhos { get; set; } = new();
}
// Config:
e.HasOne(c => c.Pai)
.WithMany(c => c.Filhos)
.HasForeignKey(c => c.PaiId);Las relaciones self-referencing enlazan una entidad consigo misma (árboles, jerarquías). La FK es nullable para la raíz. Usa HasOne().WithMany() en la misma clase.
Migrações
Instalar CLI tools
dotnet tool install --global dotnet-ef dotnet tool update --global dotnet-ef # Verificar: dotnet ef --version
Las dotnet-ef tools son globales. Instálalas con dotnet tool install --global. Necesario para crear y aplicar migrations vía CLI.
Generar script SQL
dotnet ef migrations script # Desde una migración específica: dotnet ef migrations script Mig1 Mig2 # Idempotente (para producción): dotnet ef migrations script --idempotent -o deploy.sql
El migrations script genera SQL para ejecución manual en producción. El --idempotent verifica lo que ya se aplicó. El -o guarda en un archivo.
SQL custom en la migration
protected override void Up(MigrationBuilder mb)
{
mb.Sql(@"
UPDATE Clientes
SET NomeCompleto = Nombre + ' ' + Apellido
WHERE NomeCompleto IS NULL
");
mb.Sql("CREATE INDEX IX_Custom ON Clientes(Ciudad)");
}El mb.Sql() ejecuta SQL arbitrario en la migration. Útil para transformaciones de datos, índices custom u operaciones no soportadas por el builder.
Crear migración
dotnet ef migrations add InitialCreate dotnet ef migrations add AdicionarCampoEmail dotnet ef migrations add CriarTabelaPedidos # Con proyecto separado: dotnet ef migrations add Nombre --project Core --startup-project Web
El migrations add compara el modelo con el snapshot y genera ficheros de migración. Cada migración tiene un Up() y un Down().
Seed data (HasData)
modelBuilder.Entity<País>().HasData(
new País { Id = 1, Nombre = "Portugal" },
new País { Id = 2, Nombre = "Brasil" },
new País { Id = 3, Nombre = "Angola" }
);El HasData() inserta datos iniciales vía migration. Requiere IDs explícitos. Los datos se aplican en el Up() y se eliminan en el Down().
Migraciones pendientes
// Verificar en código:
var pendentes = await context.Database
.GetPendingMigrationsAsync();
var aplicadas = await context.Database
.GetAppliedMigrationsAsync();
bool existe = await context.Database
.CanConnectAsync();El GetPendingMigrationsAsync() lista las migraciones por aplicar. El GetAppliedMigrationsAsync() muestra las aplicadas. Útil para health checks y deploy.
Aplicar migraciones
dotnet ef database update dotnet ef database update NombreMigracion // En código (startup): await context.Database.MigrupTosync();
El database update aplica migraciones pendientes. Con un nombre, aplica hasta una específica. El MigrupTosync() las aplica programáticamente (útil en deploy).
Estructura de una migration
public partial class CriarClientes : Migration
{
protected override void Up(MigrationBuilder mb)
{
mb.CreateTable("Clientes", table => new
{
Id = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
Nombre = table.Column<string>(maxLength: 100),
Email = table.Column<string>(nullable: true)
});
}
protected override void Down(MigrationBuilder mb)
{
mb.DropTable("Clientes");
}
}Cada migration tiene Up() (aplicar) y Down() (revertir). El MigrationBuilder ofrece CreateTable(), AddColumn(), DropTable(), etc.
Drop y recrear (dev)
// Borrar la BD entera: await context.Database.EnsureDeletedAsync(); // Recrear sin migrations (solo modelo): await context.Database.EnsureCreatedAsync(); // Con migrations: await context.Database.MigrupTosync();
El EnsureDeleted() + MigrupTosync() recrea todo. El EnsureCreated() crea sin migrations (no recomendado en producción). Úsalo en dev/tests.
Revertir migración
# Revertir la BD a una migración anterior: dotnet ef database update NomeAnterior # Eliminar la última migración (no aplicada): dotnet ef migrations remove # Revertir todo: dotnet ef database update 0
El database update NomeAnterior ejecuta el Down(). El migrations remove elimina el archivo de la última migración no aplicada. El update 0 revierte todo.
Operaciones en la migration
mb.AddColumn<string>("Clientes", "Telefono",
maxLength: 20, nullable: true);
mb.DropColumn("Clientes", "CampoAntigo");
mb.RenameColumn("Clientes", "Nombre", "NomeCompleto");
mb.AlterColumn<decimal>("Productos", "Precio",
precision: 18, scale: 2);
mb.CreateIndex("IX_Productos_Nombre", "Productos", "Nombre");Operaciones disponibles: AddColumn(), DropColumn(), RenameColumn(), AlterColumn(), CreateIndex(), AddForeignKey().
Migrations en equipo
# Si la migration de otro dev entra en conflicto: dotnet ef migrations remove # Corregir el modelo y recrear: dotnet ef migrations add NomeCorrigido # Nunca edites migrations ya aplicadas en producción # Crea siempre una nueva migration
En equipo, nunca edites migrations ya aplicadas. Si entra en conflicto, elimínala con migrations remove y recréala. El ModelSnapshot debe estar siempre en sync.
Recursos Avançados
Transacciones explícitas
using var transacción =
await context.Database.BeginTransactionAsync();
try
{
context.Clientes.Add(nuevo);
await context.SaveChangesAsync();
context.Logs.Add(log);
await context.SaveChangesAsync();
await transacción.CommitAsync();
}
catch
{
await transacción.RollbackAsync();
throw;
}El BeginTransactionAsync() inicia una transacción explícita. El CommitAsync() confirma y el RollbackAsync() revierte todo si algo falla.
ExecuteSqlRaw (sin entidades)
int afetados = await context.Database
.ExecuteSqlRawAsync(
"UPDATE Productos SET Precio = Precio * 1.1 WHERE Categoria = {0}",
categoria
);
// Interpolado:
int n = await context.Database
.ExecuteSql($"DELETE FROM Logs WHERE Data < {cutoff}");El ExecuteSqlRawAsync() ejecuta SQL sin mapear entidades. Devuelve el numero de filas afectadas. Ideal para bulk updates y operaciones directas.
Compiled Queries
private static readonly Func<AppDbContext, int, Task<Cliente?>>
_getCliente = EF.CompileAsyncQuery(
(AppDbContext ctx, int id) =>
ctx.Clientes.FirstOrDefault(c => c.Id == id));
// Uso:
var cliente = await _getCliente(context, 1);Las Compiled Queries precompilan la expresión LINQ. Eliminan el overhead de parsing en queries repetidas. Útil en hot paths con miles de ejecuciones.
Database facade
bool existe = await context.Database.CanConnectAsync(); string nombre = context.Database.GetDbConnection().Database; // Crear/eliminar: await context.Database.EnsureCreatedAsync(); await context.Database.EnsureDeletedAsync(); // Transacción: await context.Database.BeginTransactionAsync();
El facade Database da acceso a operaciones de BD: CanConnectAsync(), EnsureCreatedAsync(), BeginTransactionAsync(), ExecuteSqlRawAsync().
TransactionScope
using var scope = new TransactionScope(
TransactionScopeAsyncFlowOption.Enabled);
await context1.SaveChangesAsync();
await context2.SaveChangesAsync();
scope.Complete();El TransactionScope coordina transacciones entre múltiples contextos o recursos. El Complete() hace commit. Sin él, hace rollback automático.
Global Query Filters
// Config (soft delete):
modelBuilder.Entity<Post>()
.HasQueryFilter(p => !p.Eliminado);
// Multi-tenant:
modelBuilder.Entity<Producto>()
.HasQueryFilter(p => p.TenantId == _tenantId);
// Ignorar el filtro:
var todos = context.Posts
.IgnoreQueryFilters()
.ToList();Los Global Query Filters aplican condiciones automáticas a todas las queries. Ideales para soft delete y multi-tenancy. El IgnoreQueryFilters() lo desactiva.
Temporal Tables (EF Core 6+)
// Config:
e.ToTable("Clientes", b => b.IsTemporal());
// Query histórica:
var datos = await context.Clientes
.TemporalAsOf(DateTime.Now.AddDays(-7))
.Where(c => c.Id == 1)
.ToListAsync();
// Todas las versiones:
var historico = await context.Clientes
.TemporalAll()
.Where(c => c.Id == 1)
.ToListAsync();Las Temporal Tables (SQL Server) guardan histórico automático. El IsTemporal() lo activa y TemporalAsOf()/TemporalAll() consultan versiones pasadas.
Retry con resiliencia
// Estrategia de ejecución personalizada:
options.UseSqlServer(conn, sqlOptions =>
{
sqlOptions.ExecutionStrategy(d =>
new SqlServerRetryingExecutionStrategy(
d, maxRetryCount: 5,
maxRetryDelay: TimeSpan.FromSeconds(30),
errorNumbersToAdd: new List<int> { 4060 }
));
});El ExecutionStrategy personaliza la resiliencia. El SqlServerRetryingExecutionStrategy reintenta en errores transitorios. Añade códigos de error custom.
Raw SQL (FromSqlRaw)
var clientes = await context.Clientes
.FromSqlRaw(
"SELECT * FROM Clientes WHERE Ciudad = {0}",
ciudad
).ToListAsync();
// Interpolado (más seguro):
var resultado = await context.Clientes
.FromSql($"SELECT * FROM Clientes WHERE Id = {id}")
.ToListAsync();El FromSqlRaw() usa placeholders {0} para parámetros (previene SQL injection). El FromSql() interpolado es más legible e igualmente seguro.
Optimistic Concurrency
try
{
await context.SaveChangesAsync();
}
catch (DbUpdateConcurrencyException ex)
{
var entry = ex.Entries.Single();
var dbValues = await entry.GetDatabaseValuesAsync();
// Resolver el conflicto:
entry.OriginalValues.SetValues(dbValues);
await context.SaveChangesAsync(); // retry
}La concurrencia optimista detecta conflictos vía RowVersion. El catch de DbUpdateConcurrencyException permite resolver: mantener valores, usar los de la BD o merge.
JSON columns (EF Core 7+)
public class Producto
{
public int Id { get; set; }
public DetalheJson Detalle { get; set; }
}
public class DetalheJson
{
public string Color { get; set; }
public List<string> Tags { get; set; }
}
// Config:
e.OwnsOne(p => p.Detalle, d => d.ToJson());
// Query:
var resultados = await context.Productos
.Where(p => p.Detalle.Color == "azul")
.ToListAsync();El ToJson() (EF Core 7+) mapea owned types como columna JSON. Permite queries dentro del JSON. Soportado en SQL Server y PostgreSQL.
Stored Procedures
var resultados = await context.Clientes
.FromSqlRaw("EXEC sp_GetClientes @p0", ciudad)
.ToListAsync();
// Con retorno:
var total = await context.Database
.ExecuteSqlRawAsync("EXEC sp_AtualizarStock @p0", produtoId);El FromSqlRaw() llama stored procedures con EXEC. El ExecuteSqlRawAsync() ejecuta sin retornar entidades (INSERT/UPDATE/DELETE).
Interceptors
public class AuditInterceptor : SaveChangesInterceptor
{
public override ValueTask<InterceptionResult<int>>
SavingChangesAsync(
DbContextEventData data,
InterceptionResult<int> result,
CancellationToken ct = default)
{
var entries = data.Context!.ChangeTracker.Entries()
.Where(e => e.State == EntityState.Modified);
// registrar auditoría
return base.SavingChangesAsync(data, result, ct);
}
}
// Registrar:
options.AddInterceptors(new AuditInterceptor());Los Interceptors interceptan eventos de EF (save, query, connection). El SaveChangesInterceptor permite auditoría, logging o validación antes de guardar.
Entity States y Audit
public override async Task<int> SaveChangesAsync(
CancellationToken ct = default)
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State == EntityState.Added)
entry.Property("CriadoEm").CurrentValue = DateTime.Now;
if (entry.State == EntityState.Modified)
entry.Property("ModificadoEm").CurrentValue = DateTime.Now;
}
return await base.SaveChangesAsync(ct);
}Sobrescribe SaveChangesAsync() para auditoría automática. Verifica el EntityState (Added/Modified) y define timestamps. Patrón común en producción.
Performance e Boas Práticas
Evitar N+1
// MALO (N+1):
var pedidos = await context.Pedidos.ToListAsync();
foreach (var p in pedidos)
Console.WriteLine(p.Cliente.Nombre); // query por pedido!
// BUENO:
var pedidos = await context.Pedidos
.Include(p => p.Cliente)
.ToListAsync();El problema N+1 ocurre cuando accedes a navegaciones sin Include. Genera 1 query + N queries. Resuélvelo con Include() o proyección Select().
Split queries para Includes
// Single query (JOIN gigante):
var datos = await context.Clientes
.Include(c => c.Pedidos).ThenInclude(p => p.Ítems)
.ToListAsync();
// Split query (múltiples queries):
var datos = await context.Clientes
.Include(c => c.Pedidos).ThenInclude(p => p.Ítems)
.AsSplitQuery()
.ToListAsync();El AsSplitQuery() divide en múltiples queries SQL. Evita la cartesian explosion (duplicación de filas en JOINs múltiples). Mejor con colecciones grandes.
Pruebas con SQLite
var connection = new SqliteConnection("DataSource=:memory:");
connection.Open();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlite(connection)
.Options;
using var context = new AppDbContext(options);
await context.Database.EnsureCreatedAsync();
// Probar con SQL real (soporta más features)El SQLite in-memory es más fiel que InMemory (soporta FKs, SQL real). Mantén la conexión abierta durante la prueba. El EnsureCreatedAsync() crea el esquema.
Índices y performance
// Crear índices para queries frecuentes:
e.HasIndex(p => p.Email).IsUnique();
e.HasIndex(p => new { p.Categoria, p.Activo });
e.HasIndex(p => p.CriadoEm).IsDescending();
// Verificar queries lentas con:
options.LogTo(Console.WriteLine, LogLevel.Information);Crea índices para columnas usadas en WHERE, JOIN y ORDER BY. Compuestos para filtros múltiples. Monitoriza con LogTo() y optimiza con Include/Select.
Proyectar en vez de cargar
// MALO (carga todo):
var clientes = await context.Clientes.ToListAsync();
var nombres = clientes.Select(c => c.Nombre);
// BUENO (solo lo necesario):
var nombres = await context.Clientes
.Select(c => c.Nombre)
.ToListAsync();Proyecta con Select() para traer solo las columnas necesarias. Reduce memoria y tráfico de red. El SQL generado usa SELECT con campos específicos.
Patrón Repository
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<List<T>> GetAllAsync();
Task AddAsync(T entity);
void Remove(T entity);
Task<int> SaveAsync();
}
public class Repository<T> : IRepository<T> where T : class
{
private readonly AppDbContext _context;
public Repository(AppDbContext context) => _context = context;
public async Task<T?> GetByIdAsync(int id)
=> await _context.Set<T>().FindAsync(id);
}El patrón Repository abstrae el acceso a datos. Facilita pruebas con mocks y desacopla la lógica de negocio de EF. El Set<T>() da acceso genérico.
DTOs en vez de entidades
// Nunca exponer entidades en la API:
public record ClienteDto(int Id, string Nombre, string Email);
// Mapear en la query:
var dtos = await context.Clientes
.Select(c => new ClienteDto(c.Id, c.Nombre, c.Email))
.ToListAsync();
return Ok(dtos);Nunca expongas entidades directamente en la API (ciclos, datos sensibles). Usa DTOs o records con Select(). Previene over-posting y serialización circular.
Checklist de buenas prácticas
// ✓ AsNoTracking() para lectura // ✓ Select() para proyección (no cargar todo) // ✓ Include() para evitar N+1 // ✓ AsSplitQuery() con múltiples Includes // ✓ ExecuteUpdate/Delete para bulk (EF 7+) // ✓ Async en todas las operaciones // ✓ DTOs en la API (nunca entidades) // ✓ Migrations en source control // ✓ Índices para queries frecuentes // ✓ DbContext con scope corto
Resumen de las prácticas esenciales: AsNoTracking(), proyección, Include(), async, DTOs, migrations versionadas e índices. Sigue esto para código EF Core robusto.
AsNoTracking para lectura
// Lecturas que no necesitan update:
var productos = await context.Productos
.AsNoTracking()
.Where(p => p.Activo)
.ToListAsync();
// O por contexto:
context.ChangeTracker.QueryTrackingBehavior =
QueryTrackingBehavior.NoTracking;El AsNoTracking() elimina el overhead del Change Tracker en queries de lectura. Menos memoria, más rápido. Úsalo siempre que no vayas a modificar los datos.
Unit of Work
public interface IUnitOfWork
{
IRepository<Cliente> Clientes { get; }
IRepository<Producto> Productos { get; }
Task<int> SaveAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public IRepository<Cliente> Clientes { get; }
public IRepository<Producto> Productos { get; }
public async Task<int> SaveAsync()
=> await _context.SaveChangesAsync();
}El Unit of Work coordina múltiples repositorios en una única transacción. El SaveAsync() aplica todos los cambios atómicamente. Complementa el Repository.
Async en todas partes
// SIEMPRE async: var clientes = await context.Clientes.ToListAsync(); var cliente = await context.Clientes.FindAsync(1); await context.SaveChangesAsync(); // NUNCA .Result o .Wait(): // var c = context.Clientes.First(); // bloquea el thread!
Usa siempre los métodos Async (ToListAsync(), FindAsync(), SaveChangesAsync()). Nunca bloquees con .Result o .Wait() — causa deadlocks.
Batch con ExecuteUpdate/Delete
// MALO (carga + elimina uno a uno):
var logs = await context.Logs
.Where(l => l.Data < cutoff).ToListAsync();
context.Logs.RemoveRange(logs);
await context.SaveChangesAsync();
// BUENO (EF Core 7+):
await context.Logs
.Where(l => l.Data < cutoff)
.ExecuteDeleteAsync();El ExecuteDeleteAsync()/ExecuteUpdupTosync() (EF Core 7+) operan en masa sin cargar. Un único SQL en vez de miles de operaciones.
Pruebas con InMemory
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseInMemoryDatabase("TestDb_" + Guid.NewGuid())
.Options;
using var context = new AppDbContext(options);
context.Clientes.Add(new Cliente { Nombre = "Prueba" });
await context.SaveChangesAsync();
var cliente = await context.Clientes.FirstAsync();
Assert.Equal("Prueba", cliente.Nombre);El provider InMemory permite pruebas sin BD real. Cada prueba usa un nombre único para el aislamiento. No soporta todas las features (ej.: transacciones).
DbContext de corta duración
// Correcto: scope por request (DI por defecto) builder.Services.AddDbContext<AppDbContext>(...); // NO usar como Singleton: // builder.Services.AddSingleton<AppDbContext>(); // ERROR! // Para background jobs, crear scope: using var scope = app.Services.CreateScope(); var context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
El DbContext no es thread-safe — usa un scope por request. Nunca como Singleton. En background jobs, crea un scope manual con CreateScope().