Learnixo

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 prescription

Aggregate 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

C#
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

C#
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

C#
// 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 object

Production 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.