Skip to main content

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