Generic Repository — IRepository and Specification Pattern

A generic repository provides a reusable base implementation for standard CRUD operations across all entity types — eliminating boilerplate code in every concrete repository. The Specification pattern extends the generic repository: instead of adding a new method for every query variation (GetPublishedByCategory, GetPublishedByAuthor, GetDraftsByAuthor…), specifications encapsulate query criteria as objects. The repository takes a specification and applies it — keeping the repository interface stable while allowing unlimited query variations.

Generic Repository Base

// ── Generic interface ─────────────────────────────────────────────────────
public interface IRepository<T> where T : class
{
    Task<T?>                GetByIdAsync(int id, CancellationToken ct = default);
    Task<IReadOnlyList<T>>  GetAllAsync(CancellationToken ct = default);
    Task<T>                 AddAsync(T entity, CancellationToken ct = default);
    Task                    UpdateAsync(T entity, CancellationToken ct = default);
    Task<bool>              DeleteAsync(int id, CancellationToken ct = default);
    Task<bool>              ExistsAsync(int id, CancellationToken ct = default);
}

// ── Generic implementation ────────────────────────────────────────────────
public class Repository<T>(AppDbContext db) : IRepository<T> where T : class
{
    protected readonly DbSet<T> DbSet = db.Set<T>();

    public async Task<T?> GetByIdAsync(int id, CancellationToken ct = default)
        => await DbSet.FindAsync(new object[] { id }, ct);

    public async Task<IReadOnlyList<T>> GetAllAsync(CancellationToken ct = default)
        => await DbSet.AsNoTracking().ToListAsync(ct);

    public async Task<T> AddAsync(T entity, CancellationToken ct = default)
    {
        DbSet.Add(entity);
        await db.SaveChangesAsync(ct);
        return entity;
    }

    public async Task UpdateAsync(T entity, CancellationToken ct = default)
    {
        DbSet.Update(entity);
        await db.SaveChangesAsync(ct);
    }

    public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
    {
        var entity = await DbSet.FindAsync(new object[] { id }, ct);
        if (entity is null) return false;
        DbSet.Remove(entity);
        await db.SaveChangesAsync(ct);
        return true;
    }

    public async Task<bool> ExistsAsync(int id, CancellationToken ct = default)
        => await DbSet.FindAsync(new object[] { id }, ct) is not null;
}

// ── Specialised repository extending generic base ─────────────────────────
public class PostRepository(AppDbContext db) : Repository<Post>(db), IPostRepository
{
    // Inherits all CRUD from Repository<Post>
    // Adds domain-specific methods:
    public async Task<Post?> GetBySlugAsync(string slug, CancellationToken ct = default)
        => await DbSet.AsNoTracking()
            .Include(p => p.Author).Include(p => p.Tags)
            .FirstOrDefaultAsync(p => p.Slug == slug, ct);

    public async Task<bool> SlugExistsAsync(string slug, CancellationToken ct = default)
        => await DbSet.AnyAsync(p => p.Slug == slug, ct);
}
Note: The generic repository is a base for specialised repositories, not a replacement for them. Every entity that needs custom query methods still has its own interface and implementation. The generic base just eliminates repeating the same FindAsync, Add, Remove, SaveChangesAsync boilerplate across all repositories. Keep the specialised interface (IPostRepository : IRepository<Post>) so service classes depend on a typed interface, not the raw generic one.
Tip: The Specification pattern becomes valuable when you have many query variations on the same entity. A PostsByAuthorSpec, PublishedPostsSpec, and PostsByCategorySpec are composable: new PostsByAuthorSpec(authorId).And(new PublishedPostsSpec()). The repository method GetAsync(ISpecification<Post> spec) applies the specification’s expression. This is more flexible than adding a method for every combination: GetPublishedByAuthor, GetPublishedByCategory, GetPublishedByAuthorAndCategory
Warning: The generic repository pattern is a design choice with real trade-offs. Many experienced .NET developers recommend against it — it adds indirection without always adding value, and the “testability” argument is partially undermined by EF Core’s InMemory provider. For small teams or simple CRUD applications, injecting AppDbContext directly into service classes is often simpler and just as maintainable. Use the repository pattern when you genuinely need the abstraction (swapping databases, strict layering requirements, large teams with clear boundaries).

Specification Pattern

// ── Specification base ─────────────────────────────────────────────────────
public abstract class Specification<T>
{
    public abstract Expression<Func<T, bool>> Criteria { get; }
    public List<Expression<Func<T, object>>> Includes { get; } = [];
    public Expression<Func<T, object>>? OrderBy   { get; protected set; }
    public bool IsDescending { get; protected set; }
}

// ── Concrete specification ────────────────────────────────────────────────
public class PublishedPostsByAuthorSpec : Specification<Post>
{
    public PublishedPostsByAuthorSpec(string authorId)
    {
        Criteria = p => p.IsPublished && p.AuthorId == authorId;
        Includes.Add(p => p.Author);
        Includes.Add(p => p.Tags);
        OrderBy = p => p.PublishedAt!;
        IsDescending = true;
    }
    public override Expression<Func<Post, bool>> Criteria { get; }
}

// ── Repository method that applies specification ───────────────────────────
public async Task<IReadOnlyList<Post>> GetAsync(
    Specification<Post> spec, CancellationToken ct = default)
{
    var query = DbSet.AsNoTracking().Where(spec.Criteria);
    foreach (var include in spec.Includes)
        query = query.Include(include);
    if (spec.OrderBy is not null)
        query = spec.IsDescending ? query.OrderByDescending(spec.OrderBy) : query.OrderBy(spec.OrderBy);
    return await query.ToListAsync(ct);
}

Common Mistakes

Mistake 1 — Using the generic repository directly (bypasses domain-specific methods)

❌ Wrong — service injects IRepository<Post> and misses domain-specific methods like SlugExistsAsync.

✅ Correct — inject specialised IPostRepository (which extends the generic base) for full API access.

Mistake 2 — Overcomplicating specifications (simpler LINQ in repository is often clearer)

❌ Wrong — specification for every query including simple one-liners that are clearer as methods.

✅ Correct — use specifications for complex, composable, or reused query criteria; use direct LINQ for simple queries.

🧠 Test Yourself

What problem does the Specification pattern solve that adding specific repository methods does not?