Repositories provide a collection-like interface for accessing aggregate roots, abstracting away persistence details from the domain model.
Core Characteristics
Collection Abstraction
- Act like in-memory collections
- Hide database implementation
- Query using domain concepts, not SQL
Aggregate Root Access
- One repository per aggregate root
- No repositories for child entities
- Work with complete aggregates
Persistence Ignorance
- Domain model doesn’t know about database
- Repositories handle mapping
- Swap implementations without changing domain
Purpose
Decouple Domain from Infrastructure
// Application layer - orchestrates use case
public class PlaceOrderHandler
{
private readonly IOrderRepository _repository;
public void Handle(PlaceOrderCommand command)
{
var order = _repository.GetById(command.OrderId);
order.Submit(); // Entity method - domain logic
_repository.Save(order); // Repository abstracts persistence
}
}
// Infrastructure layer - knows about database
public class OrderRepository : IOrderRepository
{
private readonly DbContext _context;
public Order GetById(OrderId id)
{
return _context.Orders
.Include(o => o.Lines)
.FirstOrDefault(o => o.Id == id);
}
public void Save(Order order)
{
if (_context.Entry(order).State == EntityState.Detached)
_context.Orders.Add(order);
_context.SaveChanges();
}
}Repository Interface
public interface IOrderRepository
{
Order GetById(OrderId id);
IEnumerable<Order> GetByCustomer(CustomerId customerId);
void Save(Order order);
void Remove(Order order);
}
public interface ICustomerRepository
{
Customer GetById(CustomerId id);
Customer GetByEmail(Email email);
IEnumerable<Customer> GetActive();
void Save(Customer customer);
void Remove(Customer customer);
}Implementation Patterns
Basic Repository
public class OrderRepository : IOrderRepository
{
private readonly ApplicationDbContext _context;
public OrderRepository(ApplicationDbContext context)
{
_context = context;
}
public Order GetById(OrderId id)
{
return _context.Orders
.Include(o => o.Lines) // Load aggregate completely
.FirstOrDefault(o => o.Id == id);
}
public IEnumerable<Order> GetByCustomer(CustomerId customerId)
{
return _context.Orders
.Include(o => o.Lines)
.Where(o => o.CustomerId == customerId)
.ToList();
}
public void Save(Order order)
{
if (order.Id == null)
_context.Orders.Add(order);
else
_context.Orders.Update(order);
_context.SaveChanges();
}
public void Remove(Order order)
{
_context.Orders.Remove(order);
_context.SaveChanges();
}
}Specification Pattern
public interface ISpecification<T>
{
Expression<Func<T, bool>> Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
}
public class OrdersByCustomerSpec : ISpecification<Order>
{
public Expression<Func<Order, bool>> Criteria { get; }
public List<Expression<Func<Order, object>>> Includes { get; }
public OrdersByCustomerSpec(CustomerId customerId)
{
Criteria = order => order.CustomerId == customerId;
Includes = new List<Expression<Func<Order, object>>>
{
order => order.Lines
};
}
}
public interface IRepository<T>
{
T GetById(object id);
IEnumerable<T> Find(ISpecification<T> spec);
void Save(T entity);
}Unit of Work Pattern
public interface IUnitOfWork
{
IOrderRepository Orders { get; }
ICustomerRepository Customers { get; }
IProductRepository Products { get; }
void Commit();
void Rollback();
}
public class UnitOfWork : IUnitOfWork
{
private readonly ApplicationDbContext _context;
public IOrderRepository Orders { get; }
public ICustomerRepository Customers { get; }
public IProductRepository Products { get; }
public UnitOfWork(ApplicationDbContext context)
{
_context = context;
Orders = new OrderRepository(context);
Customers = new CustomerRepository(context);
Products = new ProductRepository(context);
}
public void Commit()
{
_context.SaveChanges();
}
public void Rollback()
{
// Discard changes
_context.ChangeTracker.Clear();
}
}Repository vs. DAO (Data Access Object)
| Aspect | Repository | DAO |
|---|---|---|
| Abstraction | Collection | Database |
| Focus | Domain objects | Data tables |
| Interface | Domain language | CRUD operations |
| Scope | Aggregate roots | Any entity |
| Queries | Domain concepts | SQL/database oriented |
// Repository: Domain-focused
var activeOrders = _orderRepository.GetActive();
var vipOrders = _orderRepository.GetByCustomerType(CustomerType.VIP);
// DAO: Database-focused
var orders = _orderDAO.ExecuteQuery("SELECT * FROM Orders WHERE Status = 1");Query Methods
By Identity
Order GetById(OrderId id);
Customer GetByEmail(Email email);
Product GetBySku(Sku sku);By Criteria
IEnumerable<Order> GetByCustomer(CustomerId customerId);
IEnumerable<Order> GetPending();
IEnumerable<Product> GetByCategory(Category category);Existence Checks
bool Exists(OrderId id);
bool ExistsByEmail(Email email);Best Practices
One Repository Per Aggregate Root
// Good: Repository for aggregate root
public interface IOrderRepository
{
Order GetById(OrderId id);
void Save(Order order);
}
// Bad: Repository for child entity
public interface IOrderLineRepository // DON'T DO THIS
{
OrderLine GetById(int id);
}
// Access OrderLines through Order aggregate insteadReturn Complete Aggregates
// Good: Loads entire aggregate
public Order GetById(OrderId id)
{
return _context.Orders
.Include(o => o.Lines) // Load children
.Include(o => o.Payments) // Load all parts
.FirstOrDefault(o => o.Id == id);
}
// Bad: Partial aggregate
public Order GetById(OrderId id)
{
return _context.Orders.Find(id); // Lines not loaded!
}Domain-Oriented Queries
// Good: Domain language
IEnumerable<Order> GetOverdueOrders();
IEnumerable<Customer> GetVipCustomers();
IEnumerable<Product> GetOutOfStock();
// Bad: Generic queries
IEnumerable<Order> GetWhere(Expression<Func<Order, bool>> predicate);Hide Persistence Details
// Good: Repository handles mapping
public class OrderRepository
{
public Order GetById(OrderId id)
{
var dto = _context.Orders.Find(id);
return MapToOrder(dto); // Hide mapping
}
}
// Bad: Exposing persistence
public class OrderRepository
{
public OrderEntity GetById(OrderId id) // Leaks DB entity!
{
return _context.Orders.Find(id);
}
}Testing with Repositories
In-Memory Implementation
public class InMemoryOrderRepository : IOrderRepository
{
private readonly Dictionary<OrderId, Order> _orders = new();
public Order GetById(OrderId id)
{
return _orders.TryGetValue(id, out var order) ? order : null;
}
public void Save(Order order)
{
_orders[order.Id] = order;
}
public void Remove(Order order)
{
_orders.Remove(order.Id);
}
}
// Usage in tests
[Test]
public void PlacingOrder_ShouldSaveToRepository()
{
var repository = new InMemoryOrderRepository();
var handler = new PlaceOrderHandler(repository);
var order = new Order(...);
handler.Handle(new PlaceOrderCommand(order.Id));
var saved = repository.GetById(order.Id);
Assert.NotNull(saved);
}Common Patterns
Lazy Loading
⚠️ Use with care — it pulls against DDD’s grain. An aggregate is
meant to load as a whole (see Return Complete Aggregates above), and having the
Order entity hold a repository to fetch its own children both leaks
infrastructure into the domain and reaches for child entities directly — the very
thing one repository per aggregate root warns against. Prefer loading the
aggregate completely; reach for lazy loading only when a genuinely large child
collection makes eager loading impractical, and treat it as an ORM-mapping
concern rather than something the domain model is aware of.
public class Order
{
private ICollection<OrderLine> _lines;
public ICollection<OrderLine> Lines
{
get
{
if (_lines == null)
_lines = _repository.GetLinesForOrder(Id);
return _lines;
}
}
}Caching
public class CachedOrderRepository : IOrderRepository
{
private readonly IOrderRepository _innerRepository;
private readonly ICache _cache;
public Order GetById(OrderId id)
{
var cacheKey = $"Order:{id}";
if (_cache.TryGet(cacheKey, out Order order))
return order;
order = _innerRepository.GetById(id);
_cache.Set(cacheKey, order, TimeSpan.FromMinutes(5));
return order;
}
}Event Dispatching
public class OrderRepository : IOrderRepository
{
private readonly IEventDispatcher _eventDispatcher;
public void Save(Order order)
{
_context.SaveChanges();
// Dispatch domain events after save
foreach (var @event in order.DomainEvents)
{
_eventDispatcher.Dispatch(@event);
}
order.ClearDomainEvents();
}
}Anti-Patterns
Generic Repository
// Bad: Too generic, no domain meaning
public interface IRepository<T>
{
T GetById(int id);
IEnumerable<T> GetAll();
void Add(T entity);
void Update(T entity);
void Delete(T entity);
}
// Good: Domain-specific
public interface IOrderRepository
{
Order GetById(OrderId id);
IEnumerable<Order> GetPendingOrders();
void Save(Order order);
}Repositories for Everything
// Bad: Repository for non-aggregate
public interface IOrderLineRepository { } // OrderLine is part of Order aggregate
// Good: Access through aggregate
var order = _orderRepository.GetById(orderId);
var line = order.Lines.First();Related Concepts
- Aggregates - What repositories manage
- Entities - Aggregate roots accessed via repositories
- Domain Driven Design - Overall pattern context
- Unit of Work - Transaction management pattern
- Specification - Query encapsulation pattern