The Repository pattern places an interface between the application’s business logic and the data access layer. Service classes depend on IPostRepository (an interface), not AppDbContext (a concrete EF Core class). This decoupling allows unit tests to substitute a mock or in-memory repository for the real database, makes swapping the data access technology possible without touching service logic, and creates a clear boundary between business rules and persistence concerns. The cost is more code; the benefit is testability and clean architecture.
Repository Interface and Implementation
// ── Interface — what the service layer depends on ──────────────────────────
public interface IPostRepository
{
Task<Post?> GetByIdAsync(int id, CancellationToken ct = default);
Task<Post?> GetBySlugAsync(string slug, CancellationToken ct = default);
Task<(IReadOnlyList<Post> Items, int Total)> GetPageAsync(
int page, int size, CancellationToken ct = default);
Task<bool> SlugExistsAsync(string slug, CancellationToken ct = default);
Task<Post> AddAsync(Post post, CancellationToken ct = default);
Task UpdateAsync(Post post, CancellationToken ct = default);
Task<bool> DeleteAsync(int id, CancellationToken ct = default);
}
// ── EF Core implementation ─────────────────────────────────────────────────
public class PostRepository(AppDbContext db) : IPostRepository
{
public async Task<Post?> GetByIdAsync(int id, CancellationToken ct = default)
=> await db.Posts.AsNoTracking()
.Include(p => p.Author).Include(p => p.Tags)
.FirstOrDefaultAsync(p => p.Id == id, ct);
public async Task<Post?> GetBySlugAsync(string slug, CancellationToken ct = default)
=> await db.Posts.AsNoTracking()
.Include(p => p.Author).Include(p => p.Tags)
.FirstOrDefaultAsync(p => p.Slug == slug, ct);
public async Task<(IReadOnlyList<Post> Items, int Total)> GetPageAsync(
int page, int size, CancellationToken ct = default)
{
var query = db.Posts.AsNoTracking()
.Where(p => p.IsPublished)
.OrderByDescending(p => p.PublishedAt);
var total = await query.CountAsync(ct);
var items = await query.Skip((page - 1) * size).Take(size)
.Include(p => p.Author).AsSplitQuery().ToListAsync(ct);
return (items, total);
}
public async Task<bool> SlugExistsAsync(string slug, CancellationToken ct = default)
=> await db.Posts.AnyAsync(p => p.Slug == slug, ct);
public async Task<Post> AddAsync(Post post, CancellationToken ct = default)
{
db.Posts.Add(post);
await db.SaveChangesAsync(ct);
return post;
}
public async Task UpdateAsync(Post post, CancellationToken ct = default)
{
db.Posts.Update(post);
await db.SaveChangesAsync(ct);
}
public async Task<bool> DeleteAsync(int id, CancellationToken ct = default)
{
var post = await db.Posts.FindAsync(new object[] { id }, ct);
if (post is null) return false;
db.Posts.Remove(post);
await db.SaveChangesAsync(ct);
return true;
}
}
// ── Registration ──────────────────────────────────────────────────────────
builder.Services.AddScoped<IPostRepository, PostRepository>();
IQueryable<T> in the interface — this is deliberate. Exposing IQueryable from a repository leaks the ORM abstraction to callers (they can add Where() clauses that only work with EF Core), making the repository effectively untestable with mocks. The interface uses concrete return types (IReadOnlyList<T>, specific task results) that any implementation can satisfy.DbContext directly. Cross-repository coordination belongs in the service/application layer, which can inject multiple repositories. If two repositories need to participate in one transaction, use a Unit of Work (Lesson 3) to share the same DbContext (and therefore the same implicit transaction) across both.Service Layer Using Repository
// ── PostService — depends on interface, not EF Core ───────────────────────
public class PostService(
IPostRepository repository,
ILogger<PostService> logger) : IPostService
{
public async Task<PostDto?> GetByIdAsync(int id, CancellationToken ct)
{
var post = await repository.GetByIdAsync(id, ct);
return post?.ToDto();
}
public async Task<PostDto> CreateAsync(CreatePostRequest request,
string authorId, CancellationToken ct)
{
if (await repository.SlugExistsAsync(request.Slug, ct))
throw new ConflictException("Slug is already in use.");
var post = Post.Create(request.Title, request.Slug, request.Body, authorId);
await repository.AddAsync(post, ct);
logger.LogInformation("Post created: {Id} {Slug}", post.Id, post.Slug);
return post.ToDto();
}
}
Common Mistakes
Mistake 1 — Exposing IQueryable from repository (leaks ORM, untestable)
❌ Wrong — IQueryable<Post> GetAll() in the interface; callers chain EF-specific operators.
✅ Correct — return concrete collections; put query criteria in specific method names or specifications.
Mistake 2 — Repository calling other repositories (coordination belongs in service layer)
❌ Wrong — PostRepository injects TagRepository to look up tags; tight coupling between repositories.
✅ Correct — service layer injects IPostRepository and ITagRepository and coordinates them.