Domain-Driven Design in .NET · Lesson 2 of 7
Aggregates and Aggregate Roots — Design Rules
What an Aggregate Is
Aggregate: a cluster of domain objects treated as a single unit
for data changes.
Aggregate Root: the single entry point to the cluster.
All external access goes through the root.
Only the root has a repository.
The root enforces all invariants for the cluster.
Patient aggregate:
Root: Patient
Children: Address, PatientStatus, EmergencyContact
External access to Address: always through patient.HomeAddress
Never: repository.GetAddress(addressId) — no direct access
Prescription aggregate:
Root: Prescription
Children: PrescriptionLine, DosageHistory
Invariants enforced by Prescription root:
- dosage must be within safe range for the medication
- prescription cannot be dispensed after expiry
- cannot modify a cancelled prescriptionAggregate Boundary Rules
Rule 1: Protect the invariant boundary
The aggregate boundary = the boundary of an invariant.
If Patient and Prescription share no invariants, they are separate aggregates.
Rule 2: Aggregates reference other aggregates by ID only
Prescription stores PatientId (Guid), NOT a Patient object reference.
This prevents loading Patient whenever Prescription is loaded.
Rule 3: One transaction = one aggregate (usually)
Updating Patient and Prescription in the same transaction is a code smell.
If it is required, reconsider the aggregate boundary.
Rule 4: Small aggregates are better
Large aggregates create lock contention and stale data conflicts.
Prescription is an aggregate — not part of the Patient aggregate.Patient Aggregate Root
public sealed class Patient : Entity<PatientId>
{
private Patient() { } // EF Core constructor
public PatientMrn Mrn { get; private set; } = default!;
public string FirstName { get; private set; } = default!;
public string LastName { get; private set; } = default!;
public DateTime DateOfBirth { get; private set; }
public Address HomeAddress { get; private set; } = default!;
public bool IsDeleted { get; private set; }
public WardId? WardId { get; private set; }
// Factory: enforce creation invariants
public static Result<Patient> Create(
string mrn, string firstName, string lastName,
DateTime dateOfBirth, Address address)
{
if (string.IsNullOrWhiteSpace(mrn))
return Result.Failure<Patient>(DomainErrors.Patient.MrnRequired);
if (dateOfBirth > DateTime.Today.AddYears(-18))
return Result.Failure<Patient>(DomainErrors.Patient.MustBeAdult);
var patient = new Patient
{
Id = PatientId.New(),
Mrn = new PatientMrn(mrn),
FirstName = firstName.Trim(),
LastName = lastName.Trim(),
DateOfBirth = dateOfBirth,
HomeAddress = address,
};
patient.RaiseDomainEvent(new PatientCreatedDomainEvent(patient.Id));
return Result.Success(patient);
}
// Behavior: enforce admission invariant
public Result AdmitToWard(WardId wardId)
{
if (WardId.HasValue)
return Result.Failure(DomainErrors.Patient.AlreadyAdmitted);
WardId = wardId;
RaiseDomainEvent(new PatientAdmittedDomainEvent(Id, wardId));
return Result.Success();
}
public void SoftDelete()
{
IsDeleted = true;
RaiseDomainEvent(new PatientDeletedDomainEvent(Id));
}
}Prescription Aggregate Root
public sealed class Prescription : Entity<PrescriptionId>
{
private Prescription() { }
public PatientId PatientId { get; private set; } = default!; // ID reference only
public ClinicianId PrescriberId { get; private set; } = default!;
public string MedicationName { get; private set; } = default!;
public DosageValue Dose { get; private set; } = default!;
public DateTime PrescribedAt { get; private set; }
public DateTime? ExpiryDate { get; private set; }
public bool IsActive { get; private set; }
public bool IsDispensed { get; private set; }
public static Result<Prescription> Create(
PatientId patientId, string medicationName,
DosageValue dose, ClinicianId prescriberId)
{
// Aggregate enforces its own invariants
if (dose.Amount <= 0 || dose.Amount > 1000)
return Result.Failure<Prescription>(DomainErrors.Prescription.InvalidDose);
if (string.IsNullOrWhiteSpace(medicationName))
return Result.Failure<Prescription>(DomainErrors.Prescription.MedicationRequired);
var prescription = new Prescription
{
Id = PrescriptionId.New(),
PatientId = patientId,
MedicationName = medicationName,
Dose = dose,
PrescriberId = prescriberId,
PrescribedAt = DateTime.UtcNow,
ExpiryDate = DateTime.UtcNow.AddDays(30),
IsActive = true,
};
prescription.RaiseDomainEvent(new PrescriptionCreatedDomainEvent(
prescription.Id, patientId, medicationName));
return Result.Success(prescription);
}
// Behavior: enforce dispensing invariant
public Result Dispense()
{
if (!IsActive)
return Result.Failure(DomainErrors.Prescription.NotActive);
if (ExpiryDate.HasValue && ExpiryDate < DateTime.UtcNow)
return Result.Failure(DomainErrors.Prescription.Expired);
if (IsDispensed)
return Result.Failure(DomainErrors.Prescription.AlreadyDispensed);
IsDispensed = true;
RaiseDomainEvent(new PrescriptionDispensedDomainEvent(Id, PatientId));
return Result.Success();
}
}Repository Per Aggregate Root
// One repository per aggregate root — not per entity
public interface IPatientRepository
{
Task<Patient?> GetByIdAsync(PatientId id, CancellationToken ct);
Task<Patient?> GetByMrnAsync(PatientMrn mrn, CancellationToken ct);
Task AddAsync(Patient patient, CancellationToken ct);
}
public interface IPrescriptionRepository
{
Task<Prescription?> GetByIdAsync(PrescriptionId id, CancellationToken ct);
Task AddAsync(Prescription prescription, CancellationToken ct);
Task<IReadOnlyList<Prescription>> GetActiveByPatientAsync(PatientId patientId, CancellationToken ct);
}
// NO: IAddressRepository — Address is not an aggregate root
// NO: IDosageRepository — DosageValue is a value objectProduction issue I've seen: A team made Patient a large aggregate that included Prescriptions, LabResults, Observations, and AuditEntries as child collections. Loading a patient loaded all their data. For a patient with 5 years of records, a simple GET /patients/ loaded 4,000 rows. Every ward-level query locked on the Patient aggregate. Splitting Prescription, LabResult, and Observation into their own aggregate roots with their own repositories reduced the most common queries from 800ms to 40ms.
Key Takeaway
Aggregate boundaries = invariant boundaries. The aggregate root is the single entry point — all external access goes through the root. Aggregates reference other aggregates by ID only — never by object reference. One transaction per aggregate. Keep aggregates small — a large aggregate is a performance and locking bottleneck. Only aggregate roots have repositories.