Repositories & Specifications
Repository pattern abstracts data access and enables querying aggregates by specification.
Repository Overview
A repository provides a collection-like interface for accessing aggregates:
// Querying by specification
var noteMaybe = await repository.FindOne(
new ByIdSpecification<NoteAggregate>(noteId),
cancellationToken);
// Finding multiple items
var activeNotes = await repository.FindMany(
new ActiveNotesSpecification(),
cancellationToken);
// Adding to repository
repository.Add(newNote);
// Persisting changes
await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
Specifications
Specifications encapsulate query logic and are reusable across handlers:
public sealed class ByIdSpecification<T> : SingleResultSpecification<T>
where T : Entity, IAggregateRoot
{
public ByIdSpecification(Guid id)
{
Query.Where(x => x.Id == id);
}
}
public sealed class ByIdsSpecification<T> : Specification<T>
where T : Entity, IAggregateRoot
{
public ByIdsSpecification(IEnumerable<Guid> ids)
{
Query.Where(x => ids.Contains(x.Id));
}
}
public sealed class ActiveNotesSpecification : Specification<NoteAggregate>
{
public ActiveNotesSpecification()
{
Query.Where(n => !n.IsArchived);
Query.OrderByDescending(n => n.CreatedAt);
}
}
Best Practices
Handling Maybe Results
Always check HasNoValue before accessing the result:
✅ GOOD
var noteMaybe = await repository.FindOne(
new ByIdSpecification<NoteAggregate>(noteId),
cancellationToken);
if (noteMaybe.HasNoValue)
return CommandResult.Failed(ErrorCodes.NotFound, "Note not found");
var note = noteMaybe.Value;
note.Update(title, content);
❌ BAD
var note = await repository.FindOne(spec).Value; // Could throw NullReferenceException!
Single SaveEntitiesAsync Call
Execute SaveEntitiesAsync() only once per handler:
✅ GOOD
var note = NoteAggregate.Create(title, content, userId);
repository.Add(note);
var result = await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
if (result.IsFailure)
{
return CommandResult.Failed(result.Error!);
}
❌ BAD
await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); // First save
note.Update(title, content);
await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken); // Second save (inefficient)
Secondary Repositories
When working with multiple aggregates, dispose secondary repositories:
public class ComplexCommandHandler : AuditableCommandHandler<ComplexCommand, PrimaryAggregate>
{
private readonly IRepositoryFactory<SecondaryAggregate> secondaryRepositoryFactory;
protected override async Task<CommandResult> HandleInternalWithRepository(
ComplexCommand request,
CancellationToken cancellationToken)
{
var secondaryRepository = secondaryRepositoryFactory.Create();
try
{
// Use secondary repository
var secondaryMaybe = await secondaryRepository.FindOne(
new ByIdSpecification<SecondaryAggregate>(request.SecondaryId),
cancellationToken);
if (secondaryMaybe.HasNoValue)
return CommandResult.Failed(ErrorCodes.NotFound);
var secondary = secondaryMaybe.Value;
// ... domain logic ...
// Single save handles both repositories
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return result.IsSuccess
? CommandResult.Succeeded()
: CommandResult.Failed(result.Error!);
}
finally
{
secondaryRepository.Dispose(); // ✅ ALWAYS dispose secondary
}
}
}
Load Only What You Need
Specifications should be focused—don't load unrelated data:
✅ Focused specification
public sealed class UserNotesSpecification : Specification<NoteAggregate>
{
public UserNotesSpecification(Guid userId)
{
Query
.Where(n => n.UserId == userId && !n.IsArchived)
.OrderByDescending(n => n.CreatedAt)
.Take(50);
}
}
❌ Over-loading specification
public sealed class AllDataSpecification : Specification<NoteAggregate>
{
public AllDataSpecification()
{
Query.Include(n => n.Tags)
.Include(n => n.Comments)
.Include(n => n.Attachments)
.Include(n => n.SharedWith)
.Include(n => n.AuditTrail); // Loading everything
}
}
Common Patterns
Check and Update Pattern
Verify existence, update, save:
var noteMaybe = await this.Repository.FindOne(
new ByIdSpecification<NoteAggregate>(noteId),
cancellationToken);
if (noteMaybe.HasNoValue)
{
return CommandResult.Failed(
new BluQubeErrorData(ErrorCodes.NotFound, "Note not found"));
}
var note = noteMaybe.Value;
note.Update(request.Title, request.Content);
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return result.IsSuccess
? CommandResult.Succeeded()
: CommandResult.Failed(result.Error!);
Batch Operations
Load multiple aggregates efficiently:
var noteIds = new[] { id1, id2, id3 };
var notesMaybe = await this.Repository.FindMany(
new ByIdsSpecification<NoteAggregate>(noteIds),
cancellationToken);
var notes = notesMaybe.Value; // IReadOnlyCollection<NoteAggregate>
foreach (var note in notes)
{
note.Archive();
}
await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
Transactional Consistency
All changes within SaveEntitiesAsync() are in a single database transaction:
var order = Order.Create(customerId, items);
repository.Add(order);
var inventory = await inventoryRepository.FindOne(
new ByIdSpecification<InventoryAggregate>(inventoryId),
cancellationToken);
if (inventory.HasNoValue)
return CommandResult.Failed("Inventory not found");
inventory.Value.ReserveStock(items);
// Both changes in same transaction
var result = await repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
// If inventory.ReserveStock fails:
// - Order is NOT added
// - No database changes
// - Exception is caught and can be handled
See Also
- Aggregates - Aggregate design and domain logic
- Complete CRUD Feature - Repository patterns in action
- CQRS Operations - Command/query workflows