//JorgenHoc
← Todos los artículos
EF CorePor Jorge CalderónActualizado 20 min read

EF Core vs Dapper — ¿Qué ORM Deberías Usar?

Compara EF Core y Dapper en rendimiento, control de consultas, migraciones y productividad. Incluye benchmarks, código lado a lado y una matriz de decisión.

#entity-framework#dotnet#database

Dos bibliotecas dominan el acceso a datos en .NET: EF Core, un ORM completo que gestiona todo el ciclo de vida de tu base de datos, y Dapper, un micro-ORM que te ofrece SQL puro con solo lo suficiente de mapping. Elegir entre ellos — o combinarlos — depende de la complejidad de tus consultas, la experiencia SQL de tu equipo y cuánto de tu dominio vive en el esquema de base de datos.

Filosofía

EF Core: ORM Completo

EF Core trata la base de datos como un detalle de implementación. Modelas tu dominio en clases C#, defines relaciones con propiedades de navegación y dejas que el framework genere SQL, rastree cambios y gestione migraciones. La abstracción te permite cambiar de proveedor de base de datos sin modificar el código de aplicación.

El costo es complejidad: la capa ORM introduce convenciones, configuraciones y comportamientos que debes comprender para evitar trampas de rendimiento como consultas N+1 o cargas de tabla completa no intencionales.

Dapper: Micro-ORM

Dapper extiende IDbConnection con un puñado de métodos de extensión (Query<T>, Execute, QueryMultiple). Tú escribes SQL; Dapper mapea el conjunto de resultados a tus tipos. Esa es toda la superficie de la API. No hay rastreo de cambios, sistema de migraciones ni carga diferida — y tampoco magia.

La ventaja es predecibilidad: el SQL que escribes es el SQL que se ejecuta. La sobrecarga de rendimiento sobre ADO.NET puro es insignificante.

Código Lado a Lado

Los ejemplos a continuación usan una entidad Product con una Category asociada. No son solo ilustraciones: una versión ejecutable de esta comparación vive en samples/ef-core-vs-dapper, que ejecuta ambas bibliotecas contra la misma base de datos SQL Server sembrada y verifica con aserciones cada afirmación de esta sección — mismas filas en ambas, JOINs de una sola sentencia, actualizaciones dirigidas de una sola columna. Si algo deja de ser cierto en una versión futura de EF Core o Dapper, ese sample falla ruidosamente.

// Modelo compartido usado en todos los ejemplos
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public decimal Price { get; set; }
    public int CategoryId { get; set; }
    public Category? Category { get; set; }
}
 
public class Category
{
    public int Id { get; set; }
    public string Name { get; set; } = "";
    public ICollection<Product> Products { get; set; } = [];
}

Consulta Simple — Obtener Todos los Productos

EF Core

// DbContext resuelve la conexión, rastrea las entidades retornadas por defecto
var products = await context.Products
    .AsNoTracking()          // deshabilita el rastreo para consultas de solo lectura
    .ToListAsync();

Dapper

// Tú controlas completamente el SQL — la selección explícita de columnas evita SELECT *
const string sql = "SELECT Id, Name, Price, CategoryId FROM Products";
 
using var conn = new SqlConnection(connectionString);
var products = await conn.QueryAsync<Product>(sql);

Inserción

EF Core

var product = new Product { Name = "Widget", Price = 9.99m, CategoryId = 1 };
 
context.Products.Add(product);
await context.SaveChangesAsync();
// product.Id se llena después de SaveChanges — EF lee la clave generada

Dapper

const string sql = @"
    INSERT INTO Products (Name, Price, CategoryId)
    VALUES (@Name, @Price, @CategoryId);
    SELECT CAST(SCOPE_IDENTITY() AS INT);";
 
using var conn = new SqlConnection(connectionString);
int newId = await conn.ExecuteScalarAsync<int>(sql, new
{
    Name = "Widget",
    Price = 9.99m,
    CategoryId = 1
});

Actualización

EF Core

var product = await context.Products.FindAsync(id);
if (product is null) return;
 
product.Price = 14.99m;
await context.SaveChangesAsync();
// El rastreo de cambios detecta la modificación de Price y emite un UPDATE específico

Dapper

const string sql = "UPDATE Products SET Price = @Price WHERE Id = @Id";
 
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(sql, new { Price = 14.99m, Id = id });

Eliminación

EF Core

// ExecuteDelete evita cargar la entidad en memoria primero (EF Core 7+)
await context.Products
    .Where(p => p.Id == id)
    .ExecuteDeleteAsync();

Dapper

const string sql = "DELETE FROM Products WHERE Id = @Id";
 
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(sql, new { Id = id });

Consulta con JOIN — Productos con Nombre de Categoría

EF Core

var results = await context.Products
    .AsNoTracking()
    .Include(p => p.Category)
    .Where(p => p.Price > 10)
    .Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Category!.Name))
    .ToListAsync();
// EF genera una sola consulta con JOIN cuando proyectas con Select

Dapper

const string sql = @"
    SELECT p.Id, p.Name, p.Price, c.Name AS CategoryName
    FROM   Products p
    JOIN   Categories c ON c.Id = p.CategoryId
    WHERE  p.Price > @MinPrice";
 
using var conn = new SqlConnection(connectionString);
var results = await conn.QueryAsync<ProductDto>(sql, new { MinPrice = 10m });

Multi-Mapping (División de Resultado en Dos Tipos)

El parámetro splitOn de Dapper mapea una fila de resultado a múltiples objetos:

const string sql = @"
    SELECT p.Id, p.Name, p.Price, p.CategoryId,
           c.Id, c.Name
    FROM   Products p
    JOIN   Categories c ON c.Id = p.CategoryId";
 
using var conn = new SqlConnection(connectionString);
 
var products = await conn.QueryAsync<Product, Category, Product>(
    sql,
    // splitOn le dice a Dapper dónde comienzan las columnas de Category
    (product, category) =>
    {
        product.Category = category;
        return product;
    },
    splitOn: "Id"   // la segunda columna "Id" dispara la división
);

EF Core maneja esto automáticamente cuando usas Include o proyectas con Select.

Procedimiento Almacenado

EF Core

// FromSqlRaw mapea el resultado de un procedimiento almacenado a un tipo de entidad
var products = await context.Products
    .FromSqlRaw("EXEC GetProductsByCategory @CategoryId = {0}", categoryId)
    .AsNoTracking()
    .ToListAsync();

Dapper

using var conn = new SqlConnection(connectionString);
 
var products = await conn.QueryAsync<Product>(
    "GetProductsByCategory",
    new { CategoryId = categoryId },
    commandType: CommandType.StoredProcedure
);

Seguridad en Consultas Parametrizadas

Ambas bibliotecas parametrizan por defecto, previniendo inyección SQL. Nunca interpoles entrada de usuario directamente en cadenas SQL.

// SEGURO — Dapper usa consultas parametrizadas para todas las propiedades del objeto anónimo
var results = await conn.QueryAsync<Product>(
    "SELECT * FROM Products WHERE Name LIKE @Search",
    new { Search = $"%{userInput}%" }
);
 
// INSEGURO — nunca hagas esto
var results = await conn.QueryAsync<Product>(
    $"SELECT * FROM Products WHERE Name LIKE '%{userInput}%'"
);

Aquí está el sample verificando toda esta sección en una sola ejecución — el SQL generado por EF impreso junto al equivalente escrito a mano, y cada afirmación de equivalencia comprobada contra la base de datos en lugar de afirmada en prosa:

Salida de consola del sample: el SELECT generado por EF Core junto al SQL de Dapper escrito a mano, ambos devolviendo 1.000 filas con checksums coincidentes; la proyección JOIN devolviendo 500 DTOs iguales elemento por elemento desde ambas bibliotecas; el change tracking emitiendo exactamente una sentencia; y la transacción compartida EF más Dapper revertida atómicamente. Todas las comprobaciones pasaron.
samples/ef-core-vs-dapper: cada afirmación de este artículo es una aserción, no una frase — la ejecución falla si EF Core y Dapper dejan de coincidir.

Benchmarks de Rendimiento

Los números a continuación están medidos con BenchmarkDotNet, no citados del folclore. La suite es EfCoreVsDapperBenchmark — ejecútala tú mismo contra la base de datos sembrada del sample (1.000 productos, exactamente 500 con precio mayor a 10 para el escenario JOIN). Cada método paga su propia configuración realista: los benchmarks de EF construyen un DbContext por invocación como lo haría una petición web, Dapper y ADO.NET abren una SqlConnection del pool por invocación, y todas las lecturas materializan una List.

BenchmarkDotNet v0.14.0, Windows 11 (10.0.26100.9106)
11th Gen Intel Core i7-1165G7 2.80GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK 10.0.303 — .NET 10.0.11, X64 RyuJIT AVX-512
SQL Server LocalDB, pool de conexiones caliente
EscenarioMétodoMediaAsignadovs base
SELECT 1.000 filasEF Core (tracking)2.210,5 µs1.079,7 KB1,00×
EF Core AsNoTracking1.309,6 µs427,4 KB0,60×
EF Core compiled query1.673,2 µs415,6 KB0,76×
Dapper793,9 µs201,5 KB0,36×
Raw ADO.NET779,7 µs163,8 KB0,36×
Búsqueda por PK (1 fila)EF Core FirstOrDefaultAsync370,4 µs74,7 KB1,00×
EF Core compiled query313,2 µs70,3 KB0,87×
Dapper151,9 µs6,8 KB0,42×
Raw ADO.NET229,6 µs6,0 KB0,64×
Proyección JOIN, 500 filasEF Core proyección Select1.451,8 µs283,8 KB1,00×
Dapper1.125,0 µs116,6 KB0,78×
INSERT individualEF Core Add + SaveChanges2.516,2 µs138,9 KB1,00×
Dapper1.409,7 µs8,6 KB0,58×
Tabla de resultados de BenchmarkDotNet para EF Core versus Dapper versus ADO.NET puro en .NET 10 contra SQL Server LocalDB: Dapper lee 1.000 filas en 794 microsegundos contra 2.211 de EF Core con tracking, EF Core AsNoTracking queda en 1.310, y la brecha de la proyección JOIN se reduce a 1.452 contra 1.125.
La ejecución en sí, con encabezado de entorno incluido. Reporte completo commiteado bajo BenchmarkDotNet.Artifacts en el repositorio de samples.

Cuatro cosas que estas mediciones realmente dicen — dos de las cuales contradicen las ratios que citaba una versión anterior de este artículo (eran aproximadas y no reproducibles; ya no están):

AsNoTracking es la victoria más barata de la tabla. La brecha del folclore — "Dapper es 2× EF" — solo existe contra EF Core con tracking (2,8× aquí, y 5× la asignación de memoria). Agregar una llamada de método la reduce a 1,6× y recorta la asignación en 60%. Si una lectura nunca necesita SaveChanges, esa llamada ya debería estar ahí.

Las proyecciones convergen. En el JOIN hacia un DTO, Dapper es solo 1,3× más rápido. Cuando EF Core proyecta con Select en lugar de materializar entidades con tracking, las dos bibliotecas hacen casi el mismo trabajo — el instinto de "reescríbelo en Dapper" paga mucho menos en consultas EF bien escritas que en las descuidadas.

Las compiled queries son situacionales, no magia. En el SELECT de 1.000 filas la compiled query rindió peor que un simple AsNoTracking en la media (con mucha más varianza) — el costo de traducción se amortiza cuando la materialización domina. Se ganó su lugar en la búsqueda por PK (313 vs 370 µs), que es exactamente la forma de ruta caliente para la que existe esta característica.

Dapper le ganó a ADO.NET puro en la búsqueda por PK (152 vs 230 µs). A esta escala estás midiendo la maquinaria de comandos cacheados, no la sobrecarga de mapeo — trata a Dapper y al ADO.NET escrito a mano como el mismo piso de rendimiento y elige Dapper por la legibilidad.

Dos notas de honestidad. Estos son tiempos de LocalDB: los round trips son casi gratis, lo que maximiza la sobrecarga visible del mapper — contra una base de datos remota, la latencia de red domina y cada ratio de arriba se comprime hacia 1. Y los números de inserción masiva desaparecieron de esta tabla porque un benchmark masivo defendible (TVP vs AddRange vs SqlBulkCopy) necesita su propia metodología; el patrón TVP de abajo sigue siendo la herramienta correcta, solo que sin un multiplicador inventado adjunto.

Compiled Queries en EF Core

Pre-compila una expresión de consulta para omitir la sobrecarga de traducción LINQ en cada llamada:

// Definir una vez a nivel de clase o estático — la compilación ocurre una sola vez
private static readonly Func<AppDbContext, int, Task<Product?>> GetByIdQuery =
    EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
        ctx.Products
           .AsNoTracking()
           .FirstOrDefault(p => p.Id == id));
 
// Usar en todos lados — sin traducción LINQ→SQL en llamadas posteriores
var product = await GetByIdQuery(context, 42);
💡

Usa compiled queries para rutas calientes que devuelven resultados pequeños — la forma de búsqueda por PK, donde midieron 15% más rápido arriba. En consultas que materializan cientos de filas el costo de traducción ya es ruido junto a la materialización, y el beneficio medido desaparece. La primera llamada aún paga el costo de compilación; las posteriores lo omiten.

Dapper con TVP para Operaciones Masivas

// Parámetro de valor de tabla para inserciones masivas — mucho más rápido que fila por fila
var table = new DataTable();
table.Columns.Add("Name", typeof(string));
table.Columns.Add("Price", typeof(decimal));
table.Columns.Add("CategoryId", typeof(int));
 
foreach (var p in products)
    table.Rows.Add(p.Name, p.Price, p.CategoryId);
 
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync(
    "INSERT INTO Products SELECT Name, Price, CategoryId FROM @Products",
    new { Products = table.AsTableValuedParameter("dbo.ProductType") }
);

EF Core no tiene una API propia de inserción masiva — ExecuteUpdate/ExecuteDelete (EF Core 7+) cubren actualizaciones y eliminaciones por conjuntos, pero las inserciones pasan por el batching de SaveChanges. Para volúmenes realmente grandes, baja a SqlBulkCopy o a un paquete de terceros como EFCore.BulkExtensions.

Fortalezas de EF Core

Migraciones

EF Core gestiona el ciclo de vida de tu esquema. dotnet ef migrations add genera una clase de migración C# a partir del diff de tu modelo; dotnet ef database update la aplica.

dotnet ef migrations add AddProductDiscountColumn
dotnet ef database update

Obtienes un historial completo de migraciones en control de versiones, soporte para rollback y seeding a través de HasData.

Rastreo de Cambios

El mapa de identidad de EF Core significa que puedes cargar una entidad, mutarla en código de aplicación y llamar a SaveChanges — el ORM emite un UPDATE dirigido solo para las columnas modificadas.

// EF rastrea el snapshot original y compara en SaveChanges
var product = await context.Products.FindAsync(id);
product!.Price = newPrice;          // solo Price cambió
product.Name = newName;             // Name también cambió
await context.SaveChangesAsync();
// SQL generado: UPDATE Products SET Price=@p0, Name=@p1 WHERE Id=@p2

Ese comentario sobre el SQL generado es comprobable, así que el sample lo comprueba. Cambia una propiedad y registra lo que realmente se ejecuta:

SQL registrado por EF Core después de cambiar solo la propiedad Price: un SELECT TOP(1) para cargar el producto, luego UPDATE Products SET Price = @p0 sin ninguna otra columna en la cláusula SET, seguido de las sentencias de la transacción compartida de la sección final del sample.
El sample ejecutado con --sql: modifica una propiedad y SaveChanges emite un único UPDATE cuya cláusula SET nombra solo [Price].

Composición LINQ

Construye consultas dinámicamente sin manipulación de cadenas:

IQueryable<Product> query = context.Products.AsNoTracking();
 
if (minPrice.HasValue)
    query = query.Where(p => p.Price >= minPrice.Value);
 
if (!string.IsNullOrEmpty(category))
    query = query.Where(p => p.Category!.Name == category);
 
if (inStockOnly)
    query = query.Where(p => p.Stock > 0);
 
// El SQL solo se genera aquí, combinando todos los filtros en una sola cláusula WHERE
var results = await query.ToListAsync();

Carga de Relaciones

// Carga eager — una sola consulta con JOIN
var orders = await context.Orders
    .Include(o => o.Customer)
    .Include(o => o.Lines)
        .ThenInclude(l => l.Product)
    .ToListAsync();
 
// Carga explícita — cargar navegación después del hecho
await context.Entry(order).Collection(o => o.Lines).LoadAsync();
 
// Split query — SQL separado por Include, evita explosión cartesiana
var orders = await context.Orders
    .Include(o => o.Lines)
    .AsSplitQuery()
    .ToListAsync();
⚠️

Nunca uses lazy loading en Web APIs. Dispara un nuevo viaje de ida y vuelta a la base de datos por cada acceso a propiedad de navegación, multiplicando silenciosamente tu conteo de consultas con cada solicitud HTTP.

Independencia de Base de Datos

Cambia de proveedor modificando la llamada UseXxx en el registro DI:

// SQL Server
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(connectionString));
 
// PostgreSQL
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseNpgsql(connectionString));
 
// SQLite (pruebas / embebido)
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlite("Data Source=app.db"));

Fortalezas de Dapper

Control Total del SQL

Escribes exactamente el SQL que se ejecuta. Esto importa para:

  • Consultas que usan características específicas de la base de datos (CTEs, funciones de ventana, operadores JSON)
  • Consultas de reportes donde los planes de ejecución finamente ajustados son críticos
  • Procedimientos almacenados con parámetros de salida complejos
// Función de ventana — difícil de expresar en LINQ
const string sql = @"
    SELECT
        p.Id,
        p.Name,
        p.Price,
        RANK() OVER (PARTITION BY p.CategoryId ORDER BY p.Price DESC) AS PriceRank
    FROM Products p
    WHERE p.Price > @MinPrice";
 
var ranked = await conn.QueryAsync<ProductRankDto>(sql, new { MinPrice = 0m });

Techo de Rendimiento

Cuando el rendimiento es la restricción — lecturas de alta frecuencia, endpoints de reportes que sirven miles de usuarios concurrentes — la mínima sobrecarga de Dapper te permite exprimir cada milisegundo.

Parámetros de Salida de Procedimientos Almacenados

var parameters = new DynamicParameters();
parameters.Add("@CustomerId", customerId);
parameters.Add("@TotalSpend", dbType: DbType.Decimal, direction: ParameterDirection.Output);
parameters.Add("@OrderCount", dbType: DbType.Int32, direction: ParameterDirection.Output);
 
using var conn = new SqlConnection(connectionString);
await conn.ExecuteAsync("GetCustomerStats", parameters,
    commandType: CommandType.StoredProcedure);
 
decimal totalSpend = parameters.Get<decimal>("@TotalSpend");
int orderCount = parameters.Get<int>("@OrderCount");

El FromSqlRaw de EF Core no admite parámetros de salida directamente — necesitarías bajar a DbCommand manualmente.

Modelo Mental Más Simple

Dapper tiene un README de tres páginas. No hay convenciones que aprender, ni propiedades shadow, ni estados de entidad, ni clases proxy. Un desarrollador que conoce SQL puede ser productivo en minutos.

Usando Ambos en el Mismo Proyecto

EF Core y Dapper no son mutuamente excluyentes. Un patrón híbrido común usa EF Core para escrituras y gestión de relaciones, y Dapper para consultas de lectura complejas y reportes.

Registro en DI

// Registrar ambos — DbContext para escrituras, fábrica IDbConnection para lecturas
builder.Services.AddDbContext<AppDbContext>(o =>
    o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
 
// Registrar una fábrica para que los repositorios puedan abrir conexiones Dapper bajo demanda
builder.Services.AddScoped<IDbConnection>(_ =>
    new SqlConnection(builder.Configuration.GetConnectionString("Default")));

Patrón Repository — Híbrido

public class ProductRepository
{
    private readonly AppDbContext _context;
    private readonly IDbConnection _db;
 
    public ProductRepository(AppDbContext context, IDbConnection db)
    {
        _context = context;
        _db = db;
    }
 
    // Las escrituras van a través de EF Core — rastreo de cambios, validación, eventos
    public async Task<Product> CreateAsync(CreateProductRequest request)
    {
        var product = new Product
        {
            Name = request.Name,
            Price = request.Price,
            CategoryId = request.CategoryId
        };
        _context.Products.Add(product);
        await _context.SaveChangesAsync();
        return product;
    }
 
    // La consulta de lectura compleja va a través de Dapper — SQL puro, sin sobrecarga
    public async Task<IEnumerable<ProductSalesDto>> GetTopSellersByCategoryAsync(
        int categoryId, int top)
    {
        const string sql = @"
            SELECT TOP (@Top)
                p.Id,
                p.Name,
                p.Price,
                SUM(ol.Quantity) AS TotalSold,
                SUM(ol.Quantity * p.Price) AS Revenue
            FROM   Products p
            JOIN   OrderLines ol ON ol.ProductId = p.Id
            JOIN   Orders o ON o.Id = ol.OrderId
            WHERE  p.CategoryId = @CategoryId
              AND  o.PlacedAt >= DATEADD(month, -3, GETUTCDATE())
            GROUP  BY p.Id, p.Name, p.Price
            ORDER  BY TotalSold DESC";
 
        return await _db.QueryAsync<ProductSalesDto>(sql, new { CategoryId = categoryId, Top = top });
    }
 
    // Las búsquedas simples aún usan EF Core con compiled query
    private static readonly Func<AppDbContext, int, Task<Product?>> FindQuery =
        EF.CompileAsyncQuery((AppDbContext ctx, int id) =>
            ctx.Products.AsNoTracking().FirstOrDefault(p => p.Id == id));
 
    public Task<Product?> FindByIdAsync(int id) => FindQuery(_context, id);
}

Compartir Transacciones

Cuando necesitas que EF Core y Dapper participen en la misma transacción:

using var transaction = await _context.Database.BeginTransactionAsync();
 
try
{
    // Escritura de EF Core
    _context.Orders.Add(newOrder);
    await _context.SaveChangesAsync();
 
    // Operación Dapper usando la misma conexión subyacente y transacción
    var conn = _context.Database.GetDbConnection();
    await conn.ExecuteAsync(
        "UPDATE Inventory SET Reserved = Reserved + @Qty WHERE ProductId = @ProductId",
        new { Qty = newOrder.Quantity, ProductId = newOrder.ProductId },
        transaction: _context.Database.CurrentTransaction!.GetDbTransaction()
    );
 
    await transaction.CommitAsync();
}
catch
{
    await transaction.RollbackAsync();
    throw;
}
💡

Al compartir una transacción, usa _context.Database.GetDbConnection() en lugar de abrir un nuevo IDbConnection. Esto garantiza que Dapper opere en exactamente la misma conexión a la que está ligada la transacción de EF Core.

Este patrón es la cuarta comprobación del sample ejecutable: una inserción de EF Core y una actualización de Dapper dentro de una misma transacción, ambas visibles antes del rollback, ambas desaparecidas después — una transacción gobernando genuinamente ambas bibliotecas, verificado con aserciones en lugar de asumido.

Cuándo Elegir EF Core

  • Aplicaciones greenfield donde posees el esquema y quieres migraciones en control de versiones
  • Modelos de dominio ricos con relaciones complejas, lógica de validación y eventos de dominio
  • Equipos con experiencia SQL mixta — LINQ reduce la barrera para un acceso a datos correcto y seguro
  • Iteración rápida — agregar una columna es un cambio de una línea en el modelo más una migración
  • Múltiples objetivos de base de datos — un codebase, múltiples proveedores
  • Testeabilidad unitariaUseInMemoryDatabase o SQLite en memoria para pruebas rápidas sin BD real

Cuándo Elegir Dapper

  • Rutas de lectura críticas para rendimiento — dashboards de reportes, endpoints API de alta frecuencia
  • Base de datos existente con un esquema que no controlas
  • Gran inversión en SQL — el equipo ya tiene procedimientos almacenados, vistas y consultas optimizadas
  • Consultas analíticas complejas — funciones de ventana, CTEs, consultas recursivas, JSON shredding
  • Microservicios con acceso a datos simple — no se necesita un ORM completo
  • Read replicas / lado de lectura CQRS — Dapper es una opción natural para el lado de consultas de CQRS

Matriz de Decisión

CriterioEF CoreDapperHíbrido
Migraciones de esquemaNativasManual / FlywayEF Core gestiona el esquema
Rastreo de cambiosNoEF Core para escrituras
Composición LINQNoEF Core para escrituras
Control SQL puroLimitadoTotalDapper para lecturas
Soporte procedimientos almacenadosParcialTotalDapper
Rendimiento (lecturas)Bueno (AsNoTracking cierra casi toda la brecha)ExcelenteExcelente
Rendimiento (escrituras)BuenoBuenoBueno
Operaciones masivasVía extensionesTVP nativoDapper
Curva de aprendizajeMedia-AltaBajaMedia
Soporte multi-BDManualMixto
Test doublesInMemory / SQLiteNecesita BD real o mockNecesita BD real o mock
Experiencia SQL necesariaBajaAltaMedia
Mejor paraGreenfield, apps de dominioLecturas críticas, BD existenteApps grandes con cargas mixtas

Resumen

EF Core y Dapper resuelven problemas diferentes. EF Core es el predeterminado correcto para nuevas aplicaciones — gestiona migraciones, mantiene tu esquema y código sincronizados, y permite a desarrolladores con menos experiencia SQL escribir consultas seguras y correctas. Dapper gana su lugar en rutas de lectura críticas para rendimiento y cuando tu equipo posee SQL complejo que un ORM solo oscurecería.

Para la mayoría de las aplicaciones medianas y grandes, la respuesta es ambos: EF Core para operaciones del lado de comando y lecturas simples, Dapper para reportes y endpoints de consulta de alta frecuencia. El patrón de transacción compartida mostrado arriba significa que nunca tienes que elegir entre corrección y rendimiento.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados