مقالات › معماری نرم‌افزار

الگوی Repository و Unit of Work در EF Core

نگاهی صادقانه به الگوهای Repository و Unit of Work روی EF Core؛ اینکه کِی ارزش اضافه می‌کنند، کِی آسیب می‌زنند و یک پیاده‌سازی عملی برای پروژه‌های واقعی.

کمتر موضوعی در جامعه‌ی دات‌نت به اندازه‌ی این یکی با حرارت بحث می‌شود: آیا با وجود Entity Framework Core هنوز الگوی Repository معنا دارد؟ پاسخ کوتاه «بستگی دارد» است. پاسخ بلند — همراه با مزایا و معایب، ضدالگوها و یک پیاده‌سازی عملی — همین مقاله است.

این الگوها چه وعده‌ای می‌دهند؟

  • Repository — یک انتزاع شبیه کالکشن روی دسترسی به داده. کد بیزینس «مقالات منتشرشده‌ی این نویسنده» را می‌خواهد بدون اینکه از SQL یا EF Core خبر داشته باشد.
  • Unit of Work — تغییرات چند Repository را ردیابی می‌کند و همه را در یک تراکنش ثبت می‌کند.

استدلال مخالفان: EF Core خودش هر دو را پیاده کرده است

به DbContext دقیق‌تر نگاه کنید:

  • DbSet<T> مثل یک Repository رفتار می‌کند: اضافه، حذف و کوئری.
  • DbContext تغییرات همه‌ی Setها را ردیابی می‌کند و SaveChanges() آن‌ها را به‌صورت اتمی ثبت می‌کند؛ یعنی یک Unit of Work.

پیچیدن آن‌ها در یک لایه‌ی جنریک دیگر اغلب این شکلی است:

// ❌ یک پوسته‌ی جنریک نازک که هیچ ارزشی اضافه نمی‌کند
public interface IRepository<T>
{
    Task<T?> GetByIdAsync(int id);
    Task<List<T>> GetAllAsync();
    Task<List<T>> FindAsync(Expression<Func<T, bool>> predicate);
    void Add(T entity);
    void Remove(T entity);
}

مشکلات این طراحی:

  1. قدرت EF Core را پنهان می‌کند — بدون Projection، بدون Include، بدون AsNoTracking و بدون صفحه‌بندی، مگر اینکه مدام پارامتر اضافه کنید.
  2. GetAllAsync() دعوت به فاجعه است — بارگذاری کل جدول در حافظه.
  3. انتزاع نشت‌کننده — Expression<Func<T,bool>> همچنان به چیزی وابسته است که EF Core می‌تواند ترجمه کند.
  4. کد اضافه برای نگهداری با سود بسیار کم.

استدلال موافقان: Repository واقعاً در چه چیزی خوب است

Repositoryها وقتی ارزش واقعی دارند که مشخص باشند، نه جنریک:

public interface IArticleRepository
{
    Task<Article?> GetBySlugAsync(string slug, CancellationToken ct);
    Task<IReadOnlyList<ArticleSummary>> GetPublishedAsync(int page, int pageSize, CancellationToken ct);
    Task<bool> SlugExistsAsync(string slug, CancellationToken ct);
    void Add(Article article);
}

مزایا:

  • زبان بیزینس — GetPublishedAsync همان چیزی را می‌گوید که منظورش است و جزئیات کوئری در یک جا زندگی می‌کند.
  • بدون کوئری تکراری — «مقالات منتشرشده» یک بار تعریف می‌شود، نه اینکه در ده سرویس کپی شود و یک باگ ده جا اصلاح شود.
  • تست‌پذیری — سرویس‌های اپلیکیشن با یک Repository جعلی ساده تست واحد می‌شوند.
  • مرزهای Clean Architecture — لایه‌ی Application اینترفیس را تعریف می‌کند و لایه‌ی Infrastructure آن را با EF Core پیاده می‌کند.

یک پیاده‌سازی عملی

Repository

public sealed class ArticleRepository(AppDbContext db) : IArticleRepository
{
    public Task<Article?> GetBySlugAsync(string slug, CancellationToken ct) =>
        db.Articles.Include(a => a.Tags).FirstOrDefaultAsync(a => a.Slug == slug, ct);

    public async Task<IReadOnlyList<ArticleSummary>> GetPublishedAsync(int page, int pageSize, CancellationToken ct) =>
        await db.Articles
            .AsNoTracking()
            .Where(a => a.IsPublished)
            .OrderByDescending(a => a.PublishedAt)
            .Skip((page - 1) * pageSize).Take(pageSize)
            .Select(a => new ArticleSummary(a.Id, a.Slug, a.Title, a.PublishedAt))
            .ToListAsync(ct);

    public Task<bool> SlugExistsAsync(string slug, CancellationToken ct) =>
        db.Articles.AnyAsync(a => a.Slug == slug, ct);

    public void Add(Article article) => db.Articles.Add(article);
}

دقت کنید Repository SaveChanges را صدا نمی‌زند. ذخیره کار Unit of Work است.

Unit of Work

public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

public sealed class AppDbContext(DbContextOptions<AppDbContext> options)
    : DbContext(options), IUnitOfWork
{
    public DbSet<Article> Articles => Set<Article>();
    public DbSet<Author> Authors => Set<Author>();
}

خود DbContext اینترفیس IUnitOfWork را پیاده می‌کند و کلاس اضافه‌ای لازم نیست. چون Context به‌صورت Scoped ثبت شده، همه‌ی Repositoryهای یک درخواست آن را به اشتراک می‌گذارند؛ پس یک SaveChangesAsync همه‌ی تغییراتشان را به‌صورت اتمی ثبت می‌کند.

ثبت در DI

builder.Services.AddDbContext<AppDbContext>(o => o.UseSqlServer(connectionString));
builder.Services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<AppDbContext>());
builder.Services.AddScoped<IArticleRepository, ArticleRepository>();
builder.Services.AddScoped<IAuthorRepository, AuthorRepository>();

استفاده در یک سرویس اپلیکیشن

public sealed class PublishArticleHandler(
    IArticleRepository articles,
    IAuthorRepository authors,
    IUnitOfWork unitOfWork)
{
    public async Task<Result<int>> HandleAsync(PublishArticle cmd, CancellationToken ct)
    {
        if (await articles.SlugExistsAsync(cmd.Slug, ct))
            return Result<int>.Failure("Slug is already taken.");

        var author = await authors.GetAsync(cmd.AuthorId, ct);
        if (author is null) return Result<int>.Failure("Author not found.");

        var article = Article.Create(cmd.Title, cmd.Slug, cmd.Content, author);
        articles.Add(article);
        author.IncrementArticleCount();

        await unitOfWork.SaveChangesAsync(ct);   // یک تراکنش برای هر دو تغییر
        return Result<int>.Success(article.Id);
    }
}

تراکنش صریح

وقتی یک عملیات باید چند فراخوانی SaveChanges (یا SQL خام) را در بر بگیرد، از تراکنش صریح استفاده کنید:

await using var tx = await db.Database.BeginTransactionAsync(ct);
try
{
    // ... چند SaveChangesAsync / ExecuteUpdateAsync
    await tx.CommitAsync(ct);
}
catch
{
    await tx.RollbackAsync(ct);
    throw;
}

با استراتژی‌های تلاش مجدد (EnableRetryOnFailure)، این کد را داخل db.Database.CreateExecutionStrategy().ExecuteAsync(...) قرار دهید.

منطق کوئری قابل استفاده‌ی مجدد بدون Repository جنریک

اگر چند Repository به فیلترهای یکسانی نیاز دارند، به‌جای یک Repository جنریک غول‌پیکر از Extension Method یا الگوی Specification استفاده کنید:

public static class ArticleQueries
{
    public static IQueryable<Article> Published(this IQueryable<Article> q) =>
        q.Where(a => a.IsPublished && a.PublishedAt <= DateTime.UtcNow);

    public static IQueryable<Article> ByTag(this IQueryable<Article> q, string tag) =>
        q.Where(a => a.Tags.Any(t => t.Name == tag));
}

var list = await db.Articles.Published().ByTag("csharp").ToListAsync(ct);

خب، از آن‌ها استفاده کنیم یا نه؟

پروژه پیشنهاد
API کوچک، موجودیت‌های کم، یک توسعه‌دهنده DbContext را مستقیم در سرویس‌ها یا Handlerها استفاده کنید
اپلیکیشن متوسط/بزرگ با Clean Architecture Repositoryهای مشخص + DbContext به‌عنوان Unit of Work
دامنه‌ی پیچیده (DDD) Repository فقط برای هر Aggregate Root
هر پروژه‌ای از IRepository<T> جنریکی که فقط DbSet<T> را می‌پیچد پرهیز کنید

ملاحظات تست

  • منطق اپلیکیشن را با Repositoryهای جعلی تست واحد کنید.
  • خود Repositoryها را با تست یکپارچه روی یک دیتابیس واقعی (SQL Server در Docker یا Testcontainers) تست کنید. Provider درون‌حافظه‌ای EF Core رفتار متفاوتی با دیتابیس‌های رابطه‌ای دارد و می‌تواند باگ‌ها را پنهان کند.

جمع‌بندی

DbContext در EF Core خودش یک Unit of Work و DbSet<T> خودش یک Repository جنریک است؛ پیچیدن آن‌ها در یک لایه‌ی جنریک دیگر معمولاً آسیب می‌زند. اما Repositoryهای مشخص با متدهای گویا همچنان ارزشمندند: کوئری‌ها را متمرکز می‌کنند، به زبان بیزینس حرف می‌زنند و لایه‌ی Application را از EF Core مستقل نگه می‌دارند. DbContext را به‌عنوان Unit of Work استفاده کنید، Repositoryها را متمرکز نگه دارید و بگذارید اندازه‌ی پروژه تعیین کند چقدر انتزاع لازم دارد.

پرسش‌های متداول

آیا Repository باید IQueryable برگرداند؟

معمولاً نه؛ ساخت کوئری را به فراخواننده نشت می‌دهد و مرز را مبهم می‌کند. نتایج مادی‌شده یا DTO برگردانید.

SaveChanges کجا صدا زده شود؟

در لایه‌ی Application (Handler یا سرویس) از طریق IUnitOfWork تا هر Use Case فقط یک بار ثبت کند.

آیا می‌توانم Repository و استفاده‌ی مستقیم از DbContext را ترکیب کنم؟

بله. خیلی از تیم‌ها برای نوشتن و منطق پیچیده‌ی دامنه از Repository استفاده می‌کنند و برای صفحات فقط‌خواندنی مستقیم از DbContext (یا Dapper) کوئری می‌زنند؛ یک نسخه‌ی سبک از CQRS.