What the Repository Pattern Provides
The repository pattern wraps data-access logic behind an interface like ICourseRepository, exposing intention-revealing methods such as GetByIdAsync, GetActiveCoursesAsync, or AddAsync instead of exposing IQueryable<Course> or DbSet<Course> directly to controllers and services. The concrete implementation, CourseRepository, takes a dependency on AppDbContext and translates each interface method into the appropriate EF Core query. Consumers depend only on the interface, which is registered in the DI container as Scoped so its lifetime matches the DbContext it wraps.
Cricket analogy: A repository is like a team's designated wicketkeeper being the single point of contact for stumping appeals — instead of every fielder shouting raw opinions (raw SQL) at the umpire, the keeper (repository) presents a clean, consistent request the umpire (caller) can trust.
In an ASP.NET Core application already built on EF Core, DbContext itself already implements the Unit of Work and Repository patterns to a significant degree — DbSet<T> is effectively a repository, and DbContext.SaveChangesAsync() is the unit of work. This is why many pragmatic teams skip a custom repository layer entirely and inject AppDbContext (or an interface abstraction over it) straight into services, reserving hand-rolled repositories for cases with genuinely complex, reusable query logic or a real need to swap persistence technology, since an extra abstraction layer that just proxies EF Core one-to-one adds indirection without real benefit.
Cricket analogy: Skipping a redundant repository is like a captain not appointing a second vice-captain when the current one already handles toss decisions perfectly well — adding a role that duplicates existing responsibility just adds friction without improving outcomes.
Where the Pattern Genuinely Earns Its Keep
The repository pattern pays for itself when the query logic is complex and reused across multiple call sites — for example, a GetEnrollmentSummaryAsync method that joins Enrollments, Courses, and Payments with specific filtering rules belongs in one place rather than being copy-pasted as raw LINQ across several controllers. It also earns its keep when unit tests need to exercise business logic without touching a real database: mocking ICourseRepository with a library like Moq or NSubstitute is straightforward, whereas mocking DbContext and DbSet directly is notoriously awkward because of how EF Core's internals are structured. A generic IRepository<T> base combined with specific repositories for complex aggregates is a common middle ground.
Cricket analogy: A repository earning its keep here is like a specialist data analyst who compiles a player's combined batting-and-bowling economy across formats in one report instead of every commentator recalculating it live from scratch — reused, complex logic belongs in one trusted place.
public interface ICourseRepository
{
Task<Course?> GetByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<Course>> GetActiveCoursesAsync(CancellationToken ct = default);
Task<EnrollmentSummary> GetEnrollmentSummaryAsync(int courseId, CancellationToken ct = default);
Task AddAsync(Course course, CancellationToken ct = default);
}
public class CourseRepository : ICourseRepository
{
private readonly AppDbContext _db;
public CourseRepository(AppDbContext db) => _db = db;
public Task<Course?> GetByIdAsync(int id, CancellationToken ct = default) =>
_db.Courses.FirstOrDefaultAsync(c => c.Id == id, ct);
public Task<IReadOnlyList<Course>> GetActiveCoursesAsync(CancellationToken ct = default) =>
_db.Courses.Where(c => c.IsActive)
.OrderBy(c => c.Title)
.ToListAsync(ct)
.ContinueWith(t => (IReadOnlyList<Course>)t.Result, ct);
public async Task<EnrollmentSummary> GetEnrollmentSummaryAsync(int courseId, CancellationToken ct = default) =>
await _db.Enrollments
.Where(e => e.CourseId == courseId)
.GroupBy(e => e.CourseId)
.Select(g => new EnrollmentSummary(courseId, g.Count(), g.Sum(e => e.AmountPaid)))
.SingleAsync(ct);
public Task AddAsync(Course course, CancellationToken ct = default)
{
_db.Courses.Add(course);
return Task.CompletedTask; // SaveChangesAsync called by caller/unit of work
}
}
// Program.cs
builder.Services.AddScoped<ICourseRepository, CourseRepository>();
Notice AddAsync only calls _db.Courses.Add(course) and doesn't call SaveChangesAsync itself. Leaving the actual commit to the caller (or a separate IUnitOfWork.SaveChangesAsync()) lets one HTTP request batch multiple repository operations into a single atomic transaction.
Wrapping every DbSet method one-to-one behind a generic IRepository<T> with no additional logic is a common anti-pattern — it hides EF Core's genuinely useful features like Include(), AsNoTracking(), and compiled queries behind a leaky abstraction that usually ends up needing an IQueryable escape hatch anyway.
- The repository pattern hides EF Core query details behind intention-revealing interface methods like GetActiveCoursesAsync.
- DbContext already implements much of Repository and Unit of Work, so a redundant one-to-one wrapper adds indirection without benefit.
- Repositories earn their keep for complex, reused query logic and for unit testing business logic without a real database.
- Register repository interfaces as Scoped so their lifetime matches the DbContext instance they wrap.
- Leave SaveChangesAsync to the caller or a Unit of Work abstraction so multiple repository calls can commit atomically.
- Avoid generic IRepository<T> wrappers that just proxy DbSet without adding real query logic or testability value.
Practice what you learned
1. What is the main purpose of the repository pattern in an ASP.NET Core app?
2. Why do EF Core's DbContext and DbSet already provide much of what a repository offers?
3. Why is mocking a repository interface generally easier than mocking DbContext directly for unit tests?
4. What lifetime should a repository implementation typically be registered with?
5. What is a common criticism of generic IRepository<T> wrappers that proxy every DbSet method one-to-one?
Was this page helpful?
You May Also Like
Entity Framework Core Integration
How to wire Entity Framework Core into an ASP.NET Core application, from DbContext registration to migrations and query patterns.
Service Lifetimes: Singleton, Scoped, Transient
How ASP.NET Core's built-in dependency injection container manages object lifetime through Singleton, Scoped, and Transient registrations.
Background Services with IHostedService
How to run long-lived and scheduled background work in ASP.NET Core using IHostedService and the BackgroundService base class.
Related Reading
Related Study Notes in Microsoft Technologies
Browse all study notesWindows 10 / UWP Development Study Notes
.NET · 30 topics
Microsoft TechnologiesWindows Batch Scripting Study Notes
Batch · 30 topics
Microsoft TechnologiesMFC (Microsoft Foundation Classes) Study Notes
C++ · 30 topics
Microsoft TechnologiesSilverlight Study Notes
.NET · 30 topics
Microsoft TechnologiesXAML Study Notes
.NET · 30 topics
Microsoft TechnologiesWPF (Windows Presentation Foundation) Study Notes
.NET · 30 topics