Learnixo

Domain-Driven Design in .NET · Lesson 5 of 7

Repositories in DDD — Contracts and Implementations

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

C#
// 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

C#
// 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

C#
// 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)

C#
// 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 language

When 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

C#
// 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, called patientRepository.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-specific GetByWardAsync(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 — not FindAll, 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.