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);
}
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.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…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.