EF Core یا Dapper؟ یک تفکیک آگاهانه بهجای یک ORM برای همهچیز
پاسخ کوتاه: برای سمت نوشتن از EF Core استفاده کنید؛ جایی که یک aggregate را بارگذاری میکنید، طبق قواعد کسبوکار تغییرش میدهید و در یک تراکنش ذخیرهاش میکنید. برای سمت خواندن از Dapper استفاده کنید؛ جایی که یک صفحه یا گزارش شکل خاصی از داده لازم دارد و میخواهید آن SQL را خودتان بنویسید. لازم نیست برای کل برنامه یک کتابخانه انتخاب کنید؛ یک تفکیک آگاهانه، امنیت EF Core را جایی که داده تغییر میکند و کنترل Dapper را جایی که داده فقط خوانده میشود به شما میدهد.
یک مثال واقعی: سفارش وارد میشود، گزارش خارج
بکاند یک فروشگاه را در نظر بگیرید. ثبت سفارش یک عملیات نوشتن است: یک Order با ردیفهایش میسازید، جمع را حساب میکنید، قواعد را اعمال میکنید (سفارش خالی نداریم، موجودی چک میشود) و همهچیز را یکجا ذخیره میکنید. این زمین بازی EF Core است: change tracking، رابطهها و SaveChanges در یک تراکنش، یک دسته کامل از باگها را حذف میکنند.
بعد تیم مالی «مشتریهای برتر این ماه» را میخواهد: تعداد سفارش، مبلغ کل و تاریخ آخرین سفارش، گروهبندی و مرتبشده. میشود این را با LINQ نوشت، اما وقت زیادی صرف بررسی SQL تولیدشده میکنید و همین که window function یا query hint لازم شود، با abstraction درگیر میشوید. با Dapper همان SQLی را مینویسید که گزارش لازم دارد و مستقیم به یک record نگاشتش میکنید.
این دو کار اولویتهای متفاوتی دارند. نوشتن درستی میخواهد: قواعد، تراکنش، همزمانی. خواندن شکل و سرعت میخواهد: دقیقاً همان ستونهایی که صفحه نشان میدهد، بدون tracking، بدون اضافه. این همان ایده اصلی CQRS است، بدون هیچ فریمورکی: لازم نیست مدلی که با آن مینویسید همان مدلی باشد که با آن میخوانید.
کد قابل اجرا
بستههای Microsoft.EntityFrameworkCore.SqlServer و Dapper را به یک پروژه .NET 10 اضافه کنید. سمت نوشتن یک aggregate معمولی EF Core است:
using Dapper;
using Microsoft.EntityFrameworkCore;
public sealed class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public long TotalRials { get; set; }
public DateTime CreatedAt { get; set; }
public List<OrderLine> Lines { get; set; } = [];
}
public sealed class OrderLine
{
public int Id { get; set; }
public int OrderId { get; set; }
public required string Sku { get; set; }
public int Quantity { get; set; }
public long PriceRials { get; set; }
}
public sealed class ShopDb(DbContextOptions<ShopDb> options) : DbContext(options)
{
public DbSet<Order> Orders => Set<Order>();
}
public sealed record OrderItem(string Sku, int Quantity, long PriceRials);
public sealed class OrderService(ShopDb db)
{
public async Task<int> PlaceAsync(int customerId, IReadOnlyList<OrderItem> items, CancellationToken ct)
{
if (items.Count == 0)
{
throw new ArgumentException("An order needs at least one line.", nameof(items));
}
var order = new Order
{
CustomerId = customerId,
CreatedAt = DateTime.UtcNow,
Lines = [.. items.Select(i => new OrderLine { Sku = i.Sku, Quantity = i.Quantity, PriceRials = i.PriceRials })],
};
order.TotalRials = order.Lines.Sum(l => l.Quantity * l.PriceRials);
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
سمت خواندن از Dapper استفاده میکند، اما اتصال EF Core را قرض میگیرد: یک connection string، یک مالک، و در یک request scope اتصال دومی باز نمیشود:
public sealed record CustomerSummary(int CustomerId, int Orders, long TotalRials, DateTime LastOrderAt);
public sealed class ReportQueries(ShopDb db)
{
public async Task<IReadOnlyList<CustomerSummary>> TopCustomersAsync(DateTime since, int take, CancellationToken ct)
{
var connection = db.Database.GetDbConnection();
const string sql = """
SELECT TOP (@take)
CustomerId,
COUNT(*) AS Orders,
SUM(TotalRials) AS TotalRials,
MAX(CreatedAt) AS LastOrderAt
FROM Orders
WHERE CreatedAt >= @since
GROUP BY CustomerId
ORDER BY SUM(TotalRials) DESC;
""";
var rows = await connection.QueryAsync<CustomerSummary>(
new CommandDefinition(sql, new { since, take }, cancellationToken: ct));
return rows.AsList();
}
}
چند جزئیات بار اصلی را میکشند:
- همیشه پارامتر.
@sinceو@takeپارامتر SQL هستند، نه چسباندن رشته؛ پس خطر injection نیست و SQL Server میتواند plan را دوباره استفاده کند. - نام ستونها با record یکی است. Dapper بر اساس نام نگاشت میکند و اینجا از constructor کلاس
CustomerSummaryاستفاده میکند. نوعCOUNT(*)همانintو نوعSUMرویbigintهمانbigintاست؛ دقیقاً مطابق record. CommandDefinitionتوکن لغو را منتقل میکند؛ اگر درخواست لغو شود، کوئری هم لغو میشود.- پول یک
longبر حسب ریال است، هرگزdouble.
انتخاب برای هر کوئری
| کوئری… | انتخاب |
|---|---|
| یک aggregate را بارگذاری میکند تا طبق قواعد تغییرش دهد | EF Core |
| چند ردیف مرتبط را با هم و اتمیک میسازد یا تغییر میدهد | EF Core |
| یک صفحه فهرست با فیلتر و صفحهبندی را پر میکند | EF Core با AsNoTracking() و Select، یا Dapper |
| یک گزارش است: گروهبندی، window function، CTE، hint | Dapper |
| خیلی پرتکرار است و باید SQL دقیقش را ببینید و تنظیم کنید | Dapper |
| تعداد زیادی ردیف را با یک شرط بهروز میکند | ExecuteUpdateAsync در EF Core (یا Dapper) |
ردیف وسط مهم است: خواندن با EF Core برای بیشتر صفحهها کافی است، به شرطی که به DTO پروجکت کنید و tracking را خاموش کنید. Dapper را وقتی بردارید که میخواهید مالک SQL باشید، نه از روی عادت.
مزایا و معایب
مزایا
- نوشتنها تضمینهای EF Core را نگه میدارند: change tracking، رابطهها، تراکنش، concurrency token.
- خواندنها دقیقاً همان SQLی را دارند که مد نظرتان است؛ در code review خوانا و با execution plan قابل تنظیم.
- به فریمورک CQRS نیازی نیست: دو سرویس، یک اتصال.
معایب
- دو سبک نگاشت در یک کدبیس؛ تیم باید هر دو را بشناسد.
- کوئریهای Dapper رشتهاند: تغییر نام یک ستون build را خراب نمیکند. با تست یکپارچه روی دیتابیس واقعی پوششان دهید.
- مدلهای خواندنی ممکن است از مدل نوشتن فاصله بگیرند؛ هدف همین است، ولی یک نفر باید مالکشان باشد.
قاعده سرانگشتی
اگر کوئری داده را تغییر میدهد، از مدلی بگذرد که قواعد را اعمال میکند: EF Core. اگر فقط داده را نشان میدهد، کوئریای بنویسید که با صفحه جور است؛ با projection در EF Core یا با Dapper. همین یک جمله بیشتر بحثهای «کدام ORM؟» را قبل از شروع تمام میکند.
بکاند داتنتی میخواهید که لایه دادهاش هم سریع باشد و هم تغییرش امن؟ ببینید چطور میتوانم کمک کنم.
پرسشهای متداول
Dapper از EF Core سریعتر است؟
برای خواندنهای ساده، در نسخههای جدید EF Core فاصله کم است؛ مخصوصاً با AsNoTracking و Select. مزیت اصلی Dapper کنترل است: دقیقاً همان SQLی را مینویسید که میخواهید، که برای گزارشهایی با گروهبندی، window function یا hint مهم است.
EF Core و Dapper میتوانند یک تراکنش مشترک داشته باشند؟
بله. اتصال را با Database.GetDbConnection() بگیرید و اگر تراکنشی باز است، Database.CurrentTransaction.GetDbTransaction() را به پارامتر transaction در Dapper بدهید. هر دو روی همان اتصال و همان تراکنش کار میکنند.
استفاده از دو کتابخانه دسترسی به داده نگهداری کد را سخت نمیکند؟
فقط وقتی مرز مبهم باشد. یک قاعده ساده بگذارید: دستورها و aggregateها از EF Core میگذرند و مدلهای خواندنی صفحهها و گزارشها از Dapper. آنوقت جای هر کوئری روشن است.