Skip to main content

Aggregates

Aggregates are the fundamental building blocks for modeling your domain in PearDrop. They define consistency boundaries and transactional boundaries for your business logic.

What is an Aggregate?

An aggregate is a cluster of domain objects (entities and value objects) treated as a single unit for data changes:

  • Aggregate Root: The top-level entity that controls access to the aggregate
  • Consistency Boundary: All changes within the aggregate are atomic
  • Transactional Boundary: Changes to the aggregate succeed or fail as a single unit
  • Repository Access: Only aggregate roots are accessed through repositories

In PearDrop, aggregates are classes that:

  1. Inherit from Entity base class
  2. Implement IAggregateRoot marker interface
  3. Enforce invariants through methods (not public setters)

Entity Base Class

All domain entities in PearDrop inherit from the Entity base class:

public class Equipment : Entity, IAggregateRoot
{
// Properties with private setters
public string Name { get; private set; }
public string Category { get; private set; }
public EquipmentStatus Status { get; private set; }
public DateTime CreatedAt { get; private set; }

// EF Core constructor
private Equipment() { }

// Factory method for creation
public static Equipment Create(
string name,
string category,
DateTime createdAt)
{
var equipment = new Equipment
{
Id = Guid.NewGuid(),
Name = name,
Category = category,
Status = EquipmentStatus.Available,
CreatedAt = createdAt
};

// Raise domain event
equipment.AddDomainEvent(new EquipmentCreatedDomainEvent(
equipment.Id,
equipment.Name,
equipment.Category,
equipment.CreatedAt));

return equipment;
}

// Business logic methods
public ResultWithError<BluQubeErrorData> UpdateStatus(EquipmentStatus newStatus, DateTime timestamp)
{
if (this.Status == EquipmentStatus.Retired)
{
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData(ErrorCodes.CoreValidation, "Cannot update retired equipment"));
}

if (this.Status == newStatus)
{
return ResultWithError.Ok<BluQubeErrorData>();
}

this.Status = newStatus;
this.LastModifiedAt = timestamp;

this.AddDomainEvent(new EquipmentStatusChangedDomainEvent(
this.Id,
this.Status,
timestamp));

return ResultWithError.Ok<BluQubeErrorData>();
}
}

Entity Capabilities

The Entity base class provides:

1. Identity Management

public virtual Guid Id { get; protected set; }

public bool IsTransient()
{
return this.Id == Guid.Empty;
}
  • All entities have a unique Guid Id
  • IsTransient() checks if entity has been persisted

2. Domain Event Support

public IReadOnlyCollection<IDomainEvent> DomainEvents { get; }

public void AddDomainEvent(IDomainEvent eventItem);
public void RemoveDomainEvent(IDomainEvent eventItem);
public void ClearDomainEvents();
  • Aggregates raise events via AddDomainEvent()
  • Events dispatched before SaveChanges() (transactional)
  • Events cleared after successful dispatch

3. Value-Based Equality

public override bool Equals(object? obj)
{
if (obj is not Entity item) return false;
if (ReferenceEquals(this, item)) return true;
if (this.GetType() != item.GetType()) return false;
if (item.IsTransient() || this.IsTransient()) return false;

return item.Id == this.Id;
}

public override int GetHashCode()
{
return this.Id.GetHashCode();
}
  • Entities with same ID are considered equal
  • Transient entities never equal (haven't been persisted yet)
  • Proper GetHashCode() implementation for collections

IAggregateRoot Interface

The IAggregateRoot marker interface designates aggregate roots:

public interface IAggregateRoot { }

This interface:

  • Has no methods (marker interface)
  • Designates which entities are aggregate roots
  • Used by generic repository constraint: Repository<TAggregateRoot> where TAggregateRoot : class, IAggregateRoot
  • Signals soft-delete support in PearDropDbContext

Aggregate Root Responsibilities

An aggregate root:

  1. Controls access to child entities

    public class Order : Entity, IAggregateRoot
    {
    // Child entities are private
    private readonly List<OrderLine> _orderLines = new();

    // Public read-only access
    public IReadOnlyCollection<OrderLine> OrderLines => _orderLines.AsReadOnly();

    // Controlled mutation through root
    public ResultWithError<BluQubeErrorData> AddOrderLine(string productId, int quantity, decimal price)
    {
    // Validate business rules
    if (this.Status != OrderStatus.Draft)
    {
    return ResultWithError.Fail<BluQubeErrorData>(
    new BluQubeErrorData(ErrorCodes.CoreValidation, "Cannot modify completed order"));
    }

    var line = new OrderLine(productId, quantity, price);
    this._orderLines.Add(line);

    // Raise event
    this.AddDomainEvent(new OrderLineAddedDomainEvent(this.Id, line.Id));

    return ResultWithError.Ok<BluQubeErrorData>();
    }
    }
  2. Enforces invariants

    public class Equipment : Entity, IAggregateRoot
    {
    public ResultWithError<BluQubeErrorData> CheckOut(Guid userId, DateTime checkoutDate)
    {
    // Business rule: can only checkout available equipment
    if (this.Status != EquipmentStatus.Available)
    {
    return ResultWithError.Fail<BluQubeErrorData>(
    new BluQubeErrorData(ErrorCodes.CoreValidation, "Equipment is not available"));
    }

    this.Status = EquipmentStatus.CheckedOut;
    this.CheckedOutBy = userId;
    this.CheckedOutAt = checkoutDate;

    this.AddDomainEvent(new EquipmentCheckedOutDomainEvent(
    this.Id, userId, checkoutDate));

    return ResultWithError.Ok<BluQubeErrorData>();
    }
    }
  3. Provides factory methods

    public static Equipment Create(string name, string category)
    {
    // Validate inputs
    if (string.IsNullOrWhiteSpace(name))
    throw new ArgumentException("Name is required", nameof(name));

    var equipment = new Equipment
    {
    Id = Guid.NewGuid(),
    Name = name,
    Category = category,
    Status = EquipmentStatus.Available,
    CreatedAt = DateTime.UtcNow
    };

    equipment.AddDomainEvent(new EquipmentCreatedDomainEvent(...));
    return equipment;
    }

Repository Pattern

PearDrop provides a generic repository per aggregate root:

Repository Interface

public interface IRepository<TAggregateRoot> : IDisposable 
where TAggregateRoot : class, IAggregateRoot
{
IUnitOfWork UnitOfWork { get; }

TAggregateRoot Add(TAggregateRoot entity);
void Update(TAggregateRoot entity);
void Delete(TAggregateRoot entity);

Task<Maybe<TAggregateRoot>> FindOne(
Specification<TAggregateRoot> specification,
CancellationToken cancellationToken = default);

Task<IList<TAggregateRoot>> FindMany(
Specification<TAggregateRoot> specification,
CancellationToken cancellationToken = default);

Task Refresh(
TAggregateRoot entity,
CancellationToken cancellationToken = default);
}

Using Repositories in Command Handlers

public class UpdateEquipmentStatusCommandHandler : 
AuditableCommandHandler<UpdateEquipmentStatusCommand, UpdateEquipmentStatusCommandResult>
{
public UpdateEquipmentStatusCommandHandler(
IEnumerable<IValidator<UpdateEquipmentStatusCommand>> validators,
ILogger<UpdateEquipmentStatusCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<Equipment> repositoryFactory)
: base(validators, logger, commandStore, repositoryFactory)
{
}

protected override async Task<CommandResult<UpdateEquipmentStatusCommandResult>>
HandleInternalWithRepository(
UpdateEquipmentStatusCommand request,
CancellationToken cancellationToken)
{
// Load aggregate using specification
var equipmentMaybe = await this.Repository.FindOne(
new ByIdSpecification<Equipment>(request.EquipmentId),
cancellationToken);

if (equipmentMaybe.HasNoValue)
{
return CommandResult<UpdateEquipmentStatusCommandResult>.Failed(
new BluQubeErrorData(ErrorCodes.NotFound, "Equipment not found"));
}

var equipment = equipmentMaybe.Value;

// Call business logic method (may raise domain events)
var updateResult = equipment.UpdateStatus(request.NewStatus, timeProvider.GetUtcNow());
if (updateResult.IsFailure)
{
return CommandResult<UpdateEquipmentStatusCommandResult>.Failed(updateResult.Error!);
}

// Save changes (dispatches domain events, then commits)
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);

if (result.IsSuccess)
{
return CommandResult<UpdateEquipmentStatusCommandResult>.Succeeded(
new UpdateEquipmentStatusCommandResult(equipment.Id, equipment.Status));
}

return CommandResult<UpdateEquipmentStatusCommandResult>.Failed(result.Error!);
}
}

Specification Pattern

PearDrop uses specifications for repository queries:

// Find by ID
var equipmentMaybe = await repository.FindOne(
new ByIdSpecification<Equipment>(equipmentId),
cancellationToken);

// Find by IDs
var equipmentList = await repository.FindMany(
new ByIdsSpecification<Equipment>(equipmentIds),
cancellationToken);

// Custom specification
public class ByStatusSpecification : Specification<Equipment>
{
public ByStatusSpecification(EquipmentStatus status)
: base(e => e.Status == status)
{
}
}

var availableEquipment = await repository.FindMany(
new ByStatusSpecification(EquipmentStatus.Available),
cancellationToken);

Multiple Repositories in Command Handler

When coordinating multiple aggregates:

public class CompleteTaskCommandHandler : 
AuditableCommandHandler<CompleteTaskCommand, CompleteTaskCommandResult>
{
private readonly IRepositoryFactory<Project> projectRepoFactory;

public CompleteTaskCommandHandler(
IEnumerable<IValidator<CompleteTaskCommand>> validators,
ILogger<CompleteTaskCommandHandler> logger,
ICommandStore commandStore,
IRepositoryFactory<Task> taskRepositoryFactory,
IRepositoryFactory<Project> projectRepositoryFactory)
: base(validators, logger, commandStore, taskRepositoryFactory)
{
this.projectRepoFactory = projectRepositoryFactory;
}

protected override async Task<CommandResult<CompleteTaskCommandResult>>
HandleInternalWithRepository(
CompleteTaskCommand request,
CancellationToken cancellationToken)
{
// Load primary aggregate (Task)
var taskMaybe = await this.Repository.FindOne(
new ByIdSpecification<Task>(request.TaskId),
cancellationToken);

if (taskMaybe.HasNoValue)
return CommandResult<CompleteTaskCommandResult>.Failed(...);

var task = taskMaybe.Value;
var completeResult = task.Complete(timeProvider.GetUtcNow());
if (completeResult.IsFailure)
return CommandResult<CompleteTaskCommandResult>.Failed(completeResult.Error!);

// Load related aggregate (Project) via separate repository
var projectRepo = this.projectRepoFactory.Create();
try
{
var projectMaybe = await projectRepo.FindOne(
new ByIdSpecification<Project>(task.ProjectId),
cancellationToken);

if (projectMaybe.HasValue)
{
var project = projectMaybe.Value;
project.IncrementCompletedTaskCount();
}

// Single SaveChanges commits all changes atomically
var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(cancellationToken);
return result.IsSuccess
? CommandResult<CompleteTaskCommandResult>.Succeeded(...)
: CommandResult<CompleteTaskCommandResult>.Failed(result.Error!);
}
finally
{
projectRepo.Dispose(); // CRITICAL: Dispose secondary repositories
}
}
}

Important: Always dispose secondary repositories in finally block.

Aggregate Design Rules

1. Reference Other Aggregates by ID

// ✅ GOOD: Reference by ID
public class Task : Entity, IAggregateRoot
{
public Guid ProjectId { get; private set; } // Foreign key

public ResultWithError<BluQubeErrorData> AssignToProject(Guid projectId)
{
this.ProjectId = projectId;
return ResultWithError.Ok<BluQubeErrorData>();
}
}

// ❌ BAD: Navigation property to other aggregate
public class Task : Entity, IAggregateRoot
{
public Project Project { get; private set; } // Don't do this
}

2. Keep Aggregates Small

// ✅ GOOD: Focused aggregate
public class Order : Entity, IAggregateRoot
{
private readonly List<OrderLine> _orderLines = new();
public decimal TotalAmount => _orderLines.Sum(l => l.LineTotal);
}

// ❌ BAD: Bloated aggregate
public class Order : Entity, IAggregateRoot
{
public List<OrderLine> Lines { get; set; }
public Customer Customer { get; set; } // Should be ID only
public List<Payment> Payments { get; set; } // Separate aggregate
public List<Shipment> Shipments { get; set; } // Separate aggregate
}

3. Enforce Invariants in the Root

public class Order : Entity, IAggregateRoot
{
private readonly List<OrderLine> _orderLines = new();

// Invariant: Order total cannot exceed customer's credit limit
public ResultWithError<BluQubeErrorData> AddOrderLine(string productId, int quantity, decimal price, decimal creditLimit)
{
var line = new OrderLine(productId, quantity, price);
var newTotal = this.TotalAmount + line.LineTotal;

if (newTotal > creditLimit)
{
return ResultWithError.Fail<BluQubeErrorData>(
new BluQubeErrorData(ErrorCodes.CoreValidation, "Order exceeds credit limit"));
}

this._orderLines.Add(line);

return ResultWithError.Ok<BluQubeErrorData>();
}
}

4. Use Domain Events for Coordination

public class Equipment : Entity, IAggregateRoot
{
public ResultWithError<BluQubeErrorData> CheckOut(Guid userId, DateTime checkoutDate)
{
this.Status = EquipmentStatus.CheckedOut;

// Raise event for other aggregates to react
this.AddDomainEvent(new EquipmentCheckedOutDomainEvent(
this.Id, userId, checkoutDate));

return ResultWithError.Ok<BluQubeErrorData>();
}
}

// Event handler coordinates with Inventory aggregate
public class EquipmentCheckedOutEventHandler :
INotificationHandler<EquipmentCheckedOutDomainEvent>
{
public async Task Handle(
EquipmentCheckedOutDomainEvent evt,
CancellationToken ct)
{
var inventory = await inventoryRepo.FindOne(...);
inventory.Value.DecrementAvailableCount(evt.EquipmentId);
// SaveChanges called by command handler
}
}

Entity Configuration (EF Core)

Configure aggregates using IEntityTypeConfiguration<T>:

public class EquipmentConfiguration : IEntityTypeConfiguration<Equipment>
{
public void Configure(EntityTypeBuilder<Equipment> builder)
{
// Table mapping
builder.ToTable("Equipment", "EquipmentHub");

// Primary key
builder.HasKey(e => e.Id);

// Properties
builder.Property(e => e.Name)
.IsRequired()
.HasMaxLength(200);

builder.Property(e => e.Category)
.IsRequired()
.HasMaxLength(100);

builder.Property(e => e.Status)
.IsRequired()
.HasConversion<string>(); // Enum stored as string

// CRITICAL: Ignore domain events (not persisted)
builder.Ignore(e => e.DomainEvents);

// Indexes
builder.HasIndex(e => e.Category);
builder.HasIndex(e => e.Status);
}
}

Register configuration in DbContext:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);

modelBuilder.ApplyConfiguration(new EquipmentConfiguration());
// Other configurations...
}

Generating Aggregates with CLI

The PearDrop CLI generates completeaggregate structure:

# Generate aggregate with properties
peardrop add aggregate Equipment \
--properties "Name:string,Category:string,Status:EquipmentStatus"

# Generated files:
# - Domain/EquipmentAggregate/AggregateRoot/Equipment.cs
# - Domain/EquipmentAggregate/Configuration/EquipmentConfiguration.cs
# - Updates DbContext with DbSet<Equipment>
# - Updates Module.cs with repository registration
CLI Scaffolding

Use peardrop add aggregate to generate:

  • Entity class inheriting from Entity and implementing IAggregateRoot
  • EF Core IEntityTypeConfiguration<T> with proper Ignore(x => x.DomainEvents)
  • DbSet registration in DbContext
  • Repository registration in Module.cs

Common Patterns

Value Objects as Properties

public class User : Entity, IAggregateRoot
{
// Value objects stored as owned entities
public Email Email { get; private set; }
public PhoneNumber Phone { get; private set; }
}

// Configuration
builder.OwnsOne(u => u.Email, email =>
{
email.Property(e => e.Value).HasColumnName("Email").IsRequired();
});

Soft Delete Support

PearDrop automatically handles soft delete for aggregate roots:

public class Equipment : Entity, IAggregateRoot
{
public bool IsDeleted { get; private set; }
public DateTime? DeletedAt { get; private set; }

public ResultWithError<BluQubeErrorData> Delete(DateTime deletedAt)
{
this.IsDeleted = true;
this.DeletedAt = deletedAt;

this.AddDomainEvent(new EquipmentDeletedDomainEvent(this.Id));

return ResultWithError.Ok<BluQubeErrorData>();
}
}

PearDropDbContext automatically filters out soft-deleted entities:

// In OnModelCreating
if (typeof(IAggregateRoot).IsAssignableFrom(entityType.ClrType))
{
builder.Entity(entityType.ClrType).HasQueryFilter(/* IsDeleted == false */);
}

Best Practices

✅ DO

  • Inherit from Entity and implement IAggregateRoot for all aggregate roots
  • Use private setters on aggregate properties
  • Provide factory methods for aggregate creation (not public constructors)
  • Enforce invariants through methods, not property setters
  • Raise domain events for significant business occurrences
  • Reference other aggregates by ID, not navigation properties
  • Keep aggregates focused on a single consistency boundary
  • Ignore domain events in EF configuration: builder.Ignore(x => x.DomainEvents)

❌ DON'T

  • Don't expose public setters on business-critical properties
  • Don't create circular aggregate references (use IDs instead)
  • Don't load multiple aggregate graphs in one query (use specifications)
  • Don't create god aggregates with too many responsibilities
  • Don't forget to dispose secondary repositories in command handlers
  • Don't skip domain events for significant state changes

Summary

Entity Base Class: Provides identity, domain events, equality
IAggregateRoot: Marks consistency boundaries, enables repository pattern
Repository Per Root: One repository per aggregate root
Specification Pattern: Type-safe queries via FindOne/FindMany
Factory Methods: Control aggregate creation with validation
Invariant Enforcement: Business rules enforced through aggregate methods
Domain Events: Coordination within bounded context
CLI Support: Generate complete aggregate structure

Aggregates in PearDrop provide a robust foundation for modeling your business domain with clear consistency boundaries and transactional guarantees.

Further Reading