The generic repository pattern combines generics and interfaces to eliminate repetitive CRUD code. Instead of writing a PostRepository, UserRepository, and CommentRepository that all implement the same Add/Update/Delete/GetById logic, a single generic Repository<T> handles all of that for any entity type. Entity-specific repositories then extend the generic base with domain-specific queries. This is the standard pattern used in enterprise ASP.NET Core applications and the foundation of the data access layer built in Part 4.
Generic Base Entity and Interface
// ── Base entity — all entities have these common properties ───────────────
public abstract class BaseEntity
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public bool IsDeleted { get; set; }
}
// ── Generic repository interface ───────────────────────────────────────────
public interface IRepository<T> where T : BaseEntity
{
Task<T?> GetByIdAsync(int id);
Task<IReadOnlyList<T>> GetAllAsync();
Task<IReadOnlyList<T>> FindAsync(Expression<Func<T, bool>> predicate);
Task<T> CreateAsync(T entity);
Task<T> UpdateAsync(T entity);
Task SoftDeleteAsync(int id);
Task<bool> ExistsAsync(int id);
Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null);
}
where T : BaseEntity means the repository can use entity.Id, entity.IsDeleted, entity.UpdatedAt, and other base class members inside the generic implementation — without needing to cast. This is the key benefit of constrained generics in the repository pattern: the generic implementation can do real work with T because it knows the minimum API that T provides through the constraint.builder.Services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>)). ASP.NET Core’s DI container will automatically create EfRepository<Post> when IRepository<Post> is requested, and EfRepository<User> when IRepository<User> is requested — no individual registration needed per entity type.IQueryable<T> from the generic base. Exposing IQueryable creates a leaky abstraction — callers can add arbitrary LINQ operations that translate into complex SQL queries outside the repository’s control. Keep the repository as the exclusive owner of all data access logic, adding methods like GetPublishedPostsByAuthorAsync(string authorId) on the derived repository.Generic EF Core Implementation
// ── Generic EF Core repository implementation ─────────────────────────────
public class EfRepository<T> : IRepository<T> where T : BaseEntity
{
protected readonly AppDbContext _db;
protected readonly DbSet<T> _set;
public EfRepository(AppDbContext db)
{
_db = db;
_set = db.Set<T>(); // EF Core: get the DbSet for type T
}
public async Task<T?> GetByIdAsync(int id)
=> await _set.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted);
public async Task<IReadOnlyList<T>> GetAllAsync()
=> await _set.Where(e => !e.IsDeleted).ToListAsync();
public async Task<IReadOnlyList<T>> FindAsync(Expression<Func<T, bool>> predicate)
=> await _set.Where(predicate).Where(e => !e.IsDeleted).ToListAsync();
public async Task<T> CreateAsync(T entity)
{
_set.Add(entity);
await _db.SaveChangesAsync();
return entity;
}
public async Task<T> UpdateAsync(T entity)
{
entity.UpdatedAt = DateTime.UtcNow;
_set.Update(entity);
await _db.SaveChangesAsync();
return entity;
}
public async Task SoftDeleteAsync(int id)
{
var entity = await GetByIdAsync(id);
if (entity is not null)
{
entity.IsDeleted = true;
entity.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
}
}
public async Task<bool> ExistsAsync(int id)
=> await _set.AnyAsync(e => e.Id == id && !e.IsDeleted);
public async Task<int> CountAsync(Expression<Func<T, bool>>? predicate = null)
=> predicate is null
? await _set.CountAsync(e => !e.IsDeleted)
: await _set.Where(predicate).CountAsync(e => !e.IsDeleted);
}
// ── Entity-specific repository — extends the generic base ─────────────────
public interface IPostRepository : IRepository<Post>
{
Task<IReadOnlyList<Post>> GetPublishedAsync(int page, int pageSize);
Task<IReadOnlyList<Post>> GetByAuthorAsync(string authorId);
Task<IReadOnlyList<Post>> SearchAsync(string query);
}
public class PostRepository : EfRepository<Post>, IPostRepository
{
public PostRepository(AppDbContext db) : base(db) { }
public async Task<IReadOnlyList<Post>> GetPublishedAsync(int page, int pageSize)
=> await _set
.Where(p => p.IsPublished && !p.IsDeleted)
.OrderByDescending(p => p.PublishedAt)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
public async Task<IReadOnlyList<Post>> GetByAuthorAsync(string authorId)
=> await _set.Where(p => p.AuthorId == authorId && !p.IsDeleted).ToListAsync();
public async Task<IReadOnlyList<Post>> SearchAsync(string query)
=> await _set
.Where(p => !p.IsDeleted &&
(p.Title.Contains(query) || p.Body.Contains(query)))
.ToListAsync();
}
Common Mistakes
Mistake 1 — Exposing IQueryable from the repository (leaky abstraction)
❌ Wrong — callers add arbitrary queries outside the repository:
IQueryable<Post> GetQuery() => _set.Where(p => !p.IsDeleted);
// Caller: repo.GetQuery().Where(p => p.Title.Contains("x")).Take(5).ToList()
// No control over what SQL is generated — LINQ leaks everywhere
✅ Correct — add specific methods for specific queries on the derived repository.
Mistake 2 — Not using open generic registration (one line covers all entities)
❌ Wrong — one line per entity:
services.AddScoped<IRepository<Post>, EfRepository<Post>>();
services.AddScoped<IRepository<User>, EfRepository<User>>();
// ... one per entity
✅ Correct — open generic registration covers all entities at once:
services.AddScoped(typeof(IRepository<>), typeof(EfRepository<>));