Skip to main content

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

ConceptDefinition
RepositoryCollection-like interface for aggregate persistence
One per AggregateEach aggregate root has its own repository
Domain InterfaceInterface in domain layer, implementation in infrastructure
Encapsulates QueriesHides 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:

  1. Simple CRUD apps - Over-engineering for basic operations
  2. Read-heavy systems - Use CQRS with direct queries for reads
  3. Event-sourced systems - Event store replaces traditional repository
  4. 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:

  1. Separate query service - CQRS approach, read models
  2. Domain service - If it's a domain operation
  3. Specification pattern - For complex criteria
  4. 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