The Repository Pattern: Abstracting Persistence
Decouple your domain logic from EF Core. Learn how to implement Repositories for DDD Aggregates with strict interface separation.
Introduction
The Repository pattern is one of the most debated patterns in the .NET ecosystem—some argue it’s unnecessary with EF Core since the DbContext already acts as a Unit of Work. I was in that camp initially. But after watching BlueRobin’s application services grow fat with LINQ queries and EF Core–specific syntax, I changed my mind. Extracting repositories gave me testable domain logic and the flexibility to swap persistence strategies for specific aggregates without rewriting business rules. [Repository] — Martin Fowler , 2002
Your Domain Layer should not know what a DbContext is. It shouldn’t know about SQL connections or transaction scopes.
Why the Repository Pattern?
- Abstraction: Swapping EF Core for Dapper (or CosmosDB) becomes possible without changing domain logic.
- Testing: Mocking
IDocumentRepositoryis trivial; mockingDbSet<T>is a nightmare. [Implementing Domain-Driven Design] — Vaughn Vernon , 2013 - Intent:
repo.GetOverdueLoans()is clearer thanctx.Loans.Where(l => l.DueDate < Now && !l.Returned).
What We’ll Build
- Generic Interface: A standard contract for Get/Add/Update/Delete.
- Specific Repository: Extending the contract for complex entity-specific queries.
- EF Core Implementation: The infrastructure layer adapter.
Architecture Overview
[Clean Architecture in .NET] — Microsoft , 2024flowchart TB
subgraph Core["🎯 Core Layer (Pure C#)"]
Interface["📜 IDocumentRepository"]
Agg["📄 Document Aggregate"]
end
subgraph Infra["⚙️ Infrastructure Layer"]
Impl["💾 DocumentRepository"]
EF["🗄️ EF Core DbContext"]
DB[("PostgreSQL")]
end
Impl -- implements --> Interface
Impl -- uses --> EF
EF -- reads/writes --> DB
classDef primary fill:#7c3aed,color:#fff
classDef secondary fill:#06b6d4,color:#fff
classDef db fill:#f43f5e,color:#fff
classDef warning fill:#fbbf24,color:#000
class Core primary
class Infra db
Implementation
The Contract
In DDD, the repository interface lives in the Domain/Core layer. [Domain-Driven Design: Tackling Complexity in the Heart of Software] — Eric Evans , 2003
// Core/Interfaces/IRepository.cs
public interface IRepository<TEntity, TId> where TEntity : AggregateRoot<TId>
{
Task<TEntity?> GetByIdAsync(TId id, CancellationToken ct = default);
Task AddAsync(TEntity entity, CancellationToken ct = default);
Task DeleteAsync(TEntity entity, CancellationToken ct = default);
}
// Core/Interfaces/IDocumentRepository.cs
public interface IDocumentRepository : IRepository<Document, CustomId>
{
// Specific queries needed by the domain
Task<bool> ExistsWithFingerprintAsync(string hash, CancellationToken ct);
Task<IReadOnlyList<Document>> GetByOwnerAsync(CustomId ownerId, int take, CancellationToken ct);
}
The Implementation
The implementation lives in the Infrastructure layer. This is where EF Core resides. [EF Core Documentation] — Microsoft , 2024
// Infrastructure/Repositories/DocumentRepository.cs
public sealed class DocumentRepository : IDocumentRepository
{
private readonly AppDbContext _context;
public DocumentRepository(AppDbContext context)
{
_context = context;
}
public async Task<Document?> GetByIdAsync(CustomId id, CancellationToken ct)
{
return await _context.Documents
.Include(d => d.Metadata) // Eager load Value Objects
.FirstOrDefaultAsync(d => d.Id == id, ct);
}
public Task AddAsync(Document entity, CancellationToken ct)
{
_context.Documents.Add(entity);
return Task.CompletedTask;
}
// Specialized Logic
public async Task<bool> ExistsWithFingerprintAsync(string hash, CancellationToken ct)
{
return await _context.Documents
.AnyAsync(d => d.Fingerprint.Value == hash, ct);
}
}
Section 3: The Unit of Work
We don’t call SaveChangesAsync in the repository. We use a Unit of Work to commit changes atomically.
[Unit of Work]
— Martin Fowler , 2002
// Application/Services/DocumentService.cs
public async Task UploadAsync(...)
{
var doc = Document.Create(...);
// 1. Add to Repository (In Memory)
await _repository.AddAsync(doc);
// 2. Commit Transaction (Database Write + Domain Events Dispatch)
await _unitOfWork.SaveChangesAsync(ct);
}
Conclusion
The Repository pattern keeps your domain model clean. It acts as an in-memory collection of objects, hiding the ugly details of SQL mapping and database connections behind a semantic interface.
After implementing repositories across all of BlueRobin’s aggregates, the biggest benefit wasn’t testability or abstraction—it was discoverability. When a new feature needs to query documents, the developer looks at IDocumentRepository and sees exactly what queries are available. There’s no temptation to write ad-hoc DbContext queries scattered across services. The repository becomes the single source of truth for how an aggregate is persisted and retrieved.
Next Steps:
- Learn how Domain Events integrate with the Unit of Work for transactional event dispatch.
- See how FastEndpoints consumes repositories through the REPR pattern.
Further Reading
- [Repository] — Martin Fowler , 2002 — The original definition of the Repository pattern from Patterns of Enterprise Application Architecture.
- [Implementing Domain-Driven Design] — Vaughn Vernon , 2013 — In-depth coverage of repository implementation in a DDD context with practical examples.
- [EF Core Documentation] — Microsoft , 2024 — Official Entity Framework Core documentation covering change tracking, queries, and persistence.