Repositories: Persistence Abstraction
Repositories provide a collection-like interface for accessing aggregates. They hide persistence details from the domain layer, keeping your domain model pure and testable.
TL;DR
| Concept | Definition |
|---|---|
| Repository | Collection-like interface for aggregate persistence |
| One per Aggregate | Each aggregate root has its own repository |
| Domain Interface | Interface in domain layer, implementation in infrastructure |
| Encapsulates Queries | Hides database details from domain |
Why Repositories?
Without Repository
// ❌ Domain layer knows about database
public class OrderService
{
private readonly DbContext _context;
public async Task<Order> GetOrder(Guid orderId)
{
return await _context.Orders
.Include(o => o.Lines)
.Include(o => o.ShippingAddress)
.FirstOrDefaultAsync(o => o.Id == orderId);
}
public async Task PlaceOrder(Order order)
{
order.Place();
_context.Orders.Update(order);
await _context.SaveChangesAsync();
}
}
Problems:
- Domain knows about Entity Framework
- Hard to test without database
- Persistence concerns leak into domain
With Repository
// ✅ Domain layer is pure
public class OrderService
{
private readonly IOrderRepository _repository;
public async Task<Order> GetOrder(OrderId orderId)
{
return await _repository.GetById(orderId);
}
public async Task PlaceOrder(Order order)
{
order.Place();
await _repository.Save(order);
}
}
Repository Pattern
Repository Interface
Basic Interface
// Generic repository interface (optional base)
public interface IRepository<TAggregate, TId>
where TAggregate : AggregateRoot<TId>
where TId : notnull
{
Task<TAggregate?> GetById(TId id, CancellationToken ct = default);
Task Save(TAggregate aggregate, CancellationToken ct = default);
Task Delete(TAggregate aggregate, CancellationToken ct = default);
}
// Specific repository with domain-specific queries
public interface IOrderRepository : IRepository<Order, OrderId>
{
Task<Order?> GetById(OrderId id, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetByCustomer(CustomerId customerId, CancellationToken ct = default);
Task<IReadOnlyList<Order>> GetPendingOrders(CancellationToken ct = default);
Task Save(Order order, CancellationToken ct = default);
}
Interface Location
src/
├── Domain/
│ ├── Orders/
│ │ ├── Order.cs # Aggregate
│ │ ├── OrderLine.cs # Entity
│ │ ├── IOrderRepository.cs # Interface HERE
│ │ └── OrderEvents.cs # Domain events
│ └── ...
├── Infrastructure/
│ ├── Persistence/
│ │ ├── OrderRepository.cs # Implementation HERE
│ │ ├── OrderConfiguration.cs # EF mapping
│ │ └── ...
│ └── ...
└── Application/
└── ...
Repository Implementation
Entity Framework Implementation
public class OrderRepository : IOrderRepository
{
private readonly AppDbContext _context;
private readonly IEventPublisher _eventPublisher;
public OrderRepository(AppDbContext context, IEventPublisher eventPublisher)
{
_context = context;
_eventPublisher = eventPublisher;
}
public async Task<Order?> GetById(OrderId id, CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Lines)
.FirstOrDefaultAsync(o => o.Id == id, ct);
}
public async Task<IReadOnlyList<Order>> GetByCustomer(
CustomerId customerId,
CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Lines)
.Where(o => o.CustomerId == customerId)
.OrderByDescending(o => o.CreatedAt)
.ToListAsync(ct);
}
public async Task<IReadOnlyList<Order>> GetPendingOrders(CancellationToken ct = default)
{
return await _context.Orders
.Include(o => o.Lines)
.Where(o => o.Status == OrderStatus.Pending)
.ToListAsync(ct);
}
public async Task Save(Order order, CancellationToken ct = default)
{
var entry = _context.Entry(order);
if (entry.State == EntityState.Detached)
{
_context.Orders.Add(order);
}
else
{
_context.Orders.Update(order);
}
await _context.SaveChangesAsync(ct);
// Publish domain events after successful save
foreach (var @event in order.DomainEvents)
{
await _eventPublisher.Publish(@event);
}
order.ClearDomainEvents();
}
public async Task Delete(Order order, CancellationToken ct = default)
{
_context.Orders.Remove(order);
await _context.SaveChangesAsync(ct);
}
}
With Optimistic Concurrency
public class OrderRepository : IOrderRepository
{
public async Task Save(Order order, CancellationToken ct = default)
{
try
{
// Version check for optimistic concurrency
_context.Entry(order).Property(o => o.Version).OriginalValue = order.Version;
order.Version++;
await _context.SaveChangesAsync(ct);
// Publish events...
}
catch (DbUpdateConcurrencyException)
{
throw new ConcurrencyException($"Order {order.Id} was modified by another user");
}
}
}
Dapper Implementation
public class OrderRepository : IOrderRepository
{
private readonly IDbConnection _connection;
public async Task<Order?> GetById(OrderId id, CancellationToken ct = default)
{
const string sql = @"
SELECT o.*, ol.*
FROM Orders o
LEFT JOIN OrderLines ol ON ol.OrderId = o.Id
WHERE o.Id = @Id";
var orderDict = new Dictionary<Guid, Order>();
await _connection.QueryAsync<Order, OrderLine, Order>(
sql,
(order, line) =>
{
if (!orderDict.TryGetValue(order.Id.Value, out var existingOrder))
{
existingOrder = order;
orderDict.Add(order.Id.Value, existingOrder);
}
if (line != null)
{
existingOrder.AddLineFromDb(line);
}
return existingOrder;
},
new { Id = id.Value },
splitOn: "Id"
);
return orderDict.Values.FirstOrDefault();
}
public async Task Save(Order order, CancellationToken ct = default)
{
using var transaction = _connection.BeginTransaction();
try
{
// Upsert order
const string orderSql = @"
INSERT INTO Orders (Id, CustomerId, Status, Total, Version, CreatedAt)
VALUES (@Id, @CustomerId, @Status, @Total, @Version, @CreatedAt)
ON CONFLICT (Id) DO UPDATE SET
Status = @Status,
Total = @Total,
Version = @Version";
await _connection.ExecuteAsync(orderSql, new
{
Id = order.Id.Value,
CustomerId = order.CustomerId.Value,
Status = order.Status.ToString(),
Total = order.Total.Amount,
Version = order.Version,
CreatedAt = order.CreatedAt
}, transaction);
// Handle order lines...
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
}
}
}
Repository Rules
Rule 1: One Repository Per Aggregate Root
// ✅ Repository for aggregate root
public interface IOrderRepository
{
Task<Order?> GetById(OrderId id);
}
// ❌ No repository for non-root entities
public interface IOrderLineRepository // Wrong!
{
Task<OrderLine?> GetById(OrderLineId id);
}
// Access OrderLine through Order aggregate
var order = await _orderRepository.GetById(orderId);
var line = order.Lines.FirstOrDefault(l => l.Id == lineId);
Rule 2: Return Complete Aggregates
// ✅ Load entire aggregate
public async Task<Order?> GetById(OrderId id)
{
return await _context.Orders
.Include(o => o.Lines) // Include children
.Include(o => o.ShippingAddress) // Include value objects
.FirstOrDefaultAsync(o => o.Id == id);
}
// ❌ Don't return partial aggregates
public async Task<Order?> GetByIdWithoutLines(OrderId id) // Wrong!
{
return await _context.Orders
.FirstOrDefaultAsync(o => o.Id == id);
// Missing lines - aggregate is incomplete
}
Rule 3: Save Entire Aggregates
// ✅ Save entire aggregate
public async Task Save(Order order)
{
_context.Orders.Update(order); // Includes all children
await _context.SaveChangesAsync();
}
// ❌ Don't save parts separately
public async Task SaveLine(OrderLine line) // Wrong!
{
_context.OrderLines.Update(line);
await _context.SaveChangesAsync();
}
Rule 4: Domain Queries Only
// ✅ Domain-meaningful queries
public interface IOrderRepository
{
Task<Order?> GetById(OrderId id);
Task<IReadOnlyList<Order>> GetByCustomer(CustomerId customerId);
Task<IReadOnlyList<Order>> GetPendingOrders();
Task<IReadOnlyList<Order>> GetOrdersToShip();
}
// ❌ Generic queries that bypass domain
public interface IOrderRepository
{
IQueryable<Order> Query(); // Exposes infrastructure
Task<IReadOnlyList<Order>> GetByFilter(Expression<Func<Order, bool>> filter);
}
Specification Pattern (Optional)
For complex queries, use specifications:
public abstract class Specification<T>
{
public abstract Expression<Func<T, bool>> ToExpression();
public bool IsSatisfiedBy(T entity)
{
return ToExpression().Compile()(entity);
}
}
public class PendingOrdersForCustomer : Specification<Order>
{
private readonly CustomerId _customerId;
public PendingOrdersForCustomer(CustomerId customerId)
{
_customerId = customerId;
}
public override Expression<Func<Order, bool>> ToExpression()
{
return order =>
order.CustomerId == _customerId &&
order.Status == OrderStatus.Pending;
}
}
// Repository method
public interface IOrderRepository
{
Task<IReadOnlyList<Order>> Find(Specification<Order> spec);
}
// Usage
var spec = new PendingOrdersForCustomer(customerId);
var orders = await _orderRepository.Find(spec);
Testing Repositories
In-Memory Implementation for Tests
public class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<OrderId, Order> _orders = new();
public Task<Order?> GetById(OrderId id, CancellationToken ct = default)
{
_orders.TryGetValue(id, out var order);
return Task.FromResult(order);
}
public Task<IReadOnlyList<Order>> GetByCustomer(
CustomerId customerId,
CancellationToken ct = default)
{
var orders = _orders.Values
.Where(o => o.CustomerId == customerId)
.ToList();
return Task.FromResult<IReadOnlyList<Order>>(orders);
}
public Task Save(Order order, CancellationToken ct = default)
{
_orders[order.Id] = order;
return Task.CompletedTask;
}
public Task Delete(Order order, CancellationToken ct = default)
{
_orders.Remove(order.Id);
return Task.CompletedTask;
}
}
Unit Test Example
public class OrderServiceTests
{
[Fact]
public async Task PlaceOrder_Should_UpdateStatus_And_Save()
{
// Arrange
var repository = new InMemoryOrderRepository();
var service = new OrderService(repository);
var order = Order.Create(
CustomerId.New(),
new Address("123 Main St", "City", "State", "12345", "US")
);
order.AddLine(ProductId.New(), 2, new Money(50, Currency.USD));
await repository.Save(order);
// Act
await service.PlaceOrder(order.Id);
// Assert
var savedOrder = await repository.GetById(order.Id);
Assert.Equal(OrderStatus.Placed, savedOrder.Status);
}
}
Common Mistakes
Mistake 1: Generic Repository Anti-Pattern
// ❌ Generic repository - loses domain meaning
public interface IRepository<T>
{
Task<T> GetById(Guid id);
Task<IEnumerable<T>> GetAll();
Task Add(T entity);
Task Update(T entity);
Task Delete(T entity);
IQueryable<T> Query();
}
// ✅ Domain-specific repository
public interface IOrderRepository
{
Task<Order?> GetById(OrderId id);
Task<IReadOnlyList<Order>> GetPendingOrders();
Task Save(Order order);
}
Mistake 2: Exposing IQueryable
// ❌ Leaks infrastructure
public interface IOrderRepository
{
IQueryable<Order> Query();
}
// Service can write any query - no encapsulation
var orders = _repository.Query()
.Include(o => o.Lines)
.Where(o => o.Total > 100)
.OrderBy(o => o.CreatedAt)
.ToList();
// ✅ Encapsulated queries
public interface IOrderRepository
{
Task<IReadOnlyList<Order>> GetHighValueOrders(Money threshold);
}
Mistake 3: Repository for Read Models
// ❌ Repository for read model
public interface IOrderSummaryRepository
{
Task<OrderSummaryDto> GetSummary(OrderId id);
}
// ✅ Read models use separate query services
public interface IOrderQueries
{
Task<OrderSummaryDto> GetSummary(OrderId id);
Task<IReadOnlyList<OrderListItemDto>> GetOrderList(OrderListFilter filter);
}
Staff+ Interview Questions
Q: When would you not use the repository pattern?
A: Skip repositories when:
- Simple CRUD apps - Over-engineering for basic operations
- Read-heavy systems - Use CQRS with direct queries for reads
- Event-sourced systems - Event store replaces traditional repository
- Microservices with simple persistence - Sometimes direct data access is fine
The pattern adds value when you need to abstract persistence and have complex domain logic.
Q: How do you handle queries that span multiple aggregates?
A: Options:
- Separate query service - CQRS approach, read models
- Domain service - If it's a domain operation
- Specification pattern - For complex criteria
- Never - Join aggregates in repository (violates boundaries)
// ✅ Query service for cross-aggregate reads
public class OrderReportingService
{
public async Task<CustomerOrderReport> GetReport(CustomerId customerId)
{
// Can query multiple tables, join data
// Returns DTO, not aggregates
}
}
Q: Should repositories handle transactions?
A: Generally no - transactions are application concern:
// ✅ Unit of Work pattern
public class OrderApplicationService
{
public async Task ProcessOrder(OrderId orderId)
{
using var unitOfWork = _unitOfWorkFactory.Create();
var order = await _orderRepository.GetById(orderId);
order.Process();
await _orderRepository.Save(order);
await unitOfWork.Commit(); // Transaction here
}
}
Quick Reference Card
┌─────────────────────────────────────────────────────────┐
│ REPOSITORY QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ PURPOSE │
│ Collection-like interface for aggregate persistence │
│ │
│ RULES │
│ 1. One repository per aggregate root │
│ 2. Return complete aggregates │
│ 3. Save entire aggregates atomically │
│ 4. Domain-meaningful queries only │
│ 5. Interface in domain, implementation in infra │
│ │
│ INTERFACE │
│ • GetById(id) │
│ • Save(aggregate) │
│ • Delete(aggregate) │
│ • Domain-specific queries │
│ │
│ AVOID │
│ • Generic repositories │
│ • Exposing IQueryable │
│ • Repositories for non-root entities │
│ • Repositories for read models │
│ │
└─────────────────────────────────────────────────────────┘
Next Steps
- Domain Services - Operations that don't fit in aggregates
- Application Services - Orchestrating repositories
- Testing DDD Code - Testing with repository mocks