Repositories in DDD — Contracts and Implementations
Implement the Repository pattern in DDD: interface contracts in the domain layer, EF Core implementations in infrastructure, generic vs specific repositories, and when to skip the repository pattern.
The Repository Contract
Repository: an abstraction over the persistence layer.
The domain defines the contract (interface).
Infrastructure provides the implementation.
Key rules:
✓ One repository per aggregate root
✓ Repository interface lives in Domain or Application layer
✓ Implementation lives in Infrastructure layer
✓ Methods speak the domain language (GetByMrn, GetActiveForWard)
✓ No DbSet or SQL in the domain layer
What repositories are NOT:
✗ A thin wrapper around DbSet.ToListAsync()
✗ A place for generic CRUD (save, delete, find, findAll)
✗ One repository per table Domain Layer: Interface Contract
// Domain/Repositories/IPatientRepository.cs
// This interface lives in Domain — no EF Core or SQL references
public interface IPatientRepository
{
Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct = default);
Task<Patient?> GetByMrnAsync(PatientMrn mrn, CancellationToken ct = default);
Task<IReadOnlyList<Patient>> GetByWardAsync(WardId wardId, CancellationToken ct = default);
Task AddAsync(Patient patient, CancellationToken ct = default);
void Remove(Patient patient);
}
// Domain/Repositories/IPrescriptionRepository.cs
public interface IPrescriptionRepository
{
Task<Prescription?> GetByIdAsync(PrescriptionId id, CancellationToken ct = default);
Task<IReadOnlyList<Prescription>> GetActiveByPatientAsync(
PatientId patientId, CancellationToken ct = default);
Task AddAsync(Prescription prescription, CancellationToken ct = default);
}Infrastructure Layer: EF Core Implementation
// Infrastructure/Repositories/PatientRepository.cs
public sealed class PatientRepository : IPatientRepository
{
private readonly ApplicationDbContext _db;
public PatientRepository(ApplicationDbContext db) => _db = db;
public async Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct = default)
=> await _db.Patients
.FirstOrDefaultAsync(p => p.Id == id, ct);
public async Task<Patient?> GetByMrnAsync(PatientMrn mrn, CancellationToken ct = default)
=> await _db.Patients
.FirstOrDefaultAsync(p => p.Mrn == mrn, ct);
public async Task<IReadOnlyList<Patient>> GetByWardAsync(
WardId wardId, CancellationToken ct = default)
=> await _db.Patients
.Where(p => p.WardId == wardId)
.ToListAsync(ct);
public async Task AddAsync(Patient patient, CancellationToken ct = default)
=> await _db.Patients.AddAsync(patient, ct);
public void Remove(Patient patient)
=> _db.Patients.Remove(patient);
}Specification Pattern for Reusable Filters
// Specifications encapsulate query logic — reusable, composable
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public bool IsSatisfiedBy(T entity)
=> ToExpression().Compile()(entity);
}
public sealed class ActivePrescriptionsForPatientSpec : Specification<Prescription>
{
private readonly PatientId _patientId;
public ActivePrescriptionsForPatientSpec(PatientId patientId)
=> _patientId = patientId;
public override Expression<Func<Prescription, bool>> ToExpression()
=> p => p.PatientId == _patientId && p.IsActive && !p.IsDeleted;
}
// Usage in repository:
public async Task<IReadOnlyList<Prescription>> GetAsync(
Specification<Prescription> spec, CancellationToken ct)
=> await _db.Prescriptions
.Where(spec.ToExpression())
.ToListAsync(ct);
// Usage in application code:
var spec = new ActivePrescriptionsForPatientSpec(patientId);
var results = await _prescriptionRepo.GetAsync(spec, ct);Generic Repository (Avoid)
// Generic repository anti-pattern
public interface IRepository<T>
{
Task<T?> GetByIdAsync(Guid id, CancellationToken ct);
Task<IEnumerable<T>> GetAllAsync(CancellationToken ct);
Task AddAsync(T entity, CancellationToken ct);
void Remove(T entity);
Task SaveChangesAsync(CancellationToken ct);
}
// Problems with the generic repository:
// 1. GetAllAsync() on a large table is almost always wrong
// 2. Methods don't express domain concepts (GetByMrn, GetActiveForWard)
// 3. Forces every entity to have the same interface, even if they don't need it
// 4. Leaks persistence concepts into the domain
// 5. Callers must cast T — type safety is illusory
// Prefer: specific repository interfaces per aggregate root
// with methods that speak the domain's languageWhen to Skip the Repository Pattern
Skip the repository pattern when:
→ Using Vertical Slice Architecture with small, independent features
→ The handler only reads data and uses EF Core directly (projection)
→ Testing directly against Testcontainers (no mocking needed)
→ The codebase is small and the abstraction adds more ceremony than value
Repository is valuable when:
→ You want to swap the persistence technology (SQL → NoSQL, EF Core → Dapper)
→ You write unit tests with in-memory fakes (not integration tests)
→ Domain logic depends on reading aggregates before making decisions
→ Multiple handlers use the same query logic — centralizing it avoids duplication
Pragmatic rule: use repositories for write operations (domain aggregates).
Use EF Core projections directly in handlers for read operations.Registration
// Program.cs
builder.Services.AddScoped<IPatientRepository, PatientRepository>();
builder.Services.AddScoped<IPrescriptionRepository, PrescriptionRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();Production issue I've seen: A team used a generic repository with
GetAllAsync()available on every entity. A new developer, unfamiliar with the data volume, calledpatientRepository.GetAllAsync()in a report endpoint. The database had 800,000 patients. The method loaded all 800,000 into memory and timed out after 30 seconds. The endpoint caused an out-of-memory exception on the server. A domain-specificGetByWardAsync(wardId)method would have made loading all patients impossible — forcing the correct query from the start.
Key Takeaway
Repository interfaces live in the domain layer with no persistence references. Implementations live in infrastructure. One repository per aggregate root. Methods use domain language:
GetByMrnAsync,GetActiveByPatientAsync— notFindAll,SaveEntity. Use specifications for reusable query logic. Skip repositories for read-only handlers that use EF Core projections directly — the abstraction cost is only justified when you need unit testability or persistence swapping.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.