Entities and Aggregate Roots — Design, Identity, and Invariants
How to design entities in Clean Architecture: strongly-typed IDs, private setters, factory methods, aggregate roots, invariant enforcement, and the patterns that make domain models trustworthy.
Entity vs Value Object
Entity: has identity — two patients named "John Smith" are different people
Value object: has no identity — two Dosage("500mg") instances are interchangeable
Entities are tracked over time by their ID.
Value objects are fully defined by their values.Strongly-Typed IDs
Primitive IDs allow mixing up patientId and prescriptionId at compile time with no error:
// ✗ PRIMITIVE ID — compiles but is wrong
void AssignPrescription(Guid patientId, Guid prescriptionId) { ... }
// At call site — accidentally swapped, compiles fine:
AssignPrescription(prescriptionId, patientId); // silent bug
// ✓ STRONGLY-TYPED ID — swapped args are a compile error
void AssignPrescription(PatientId patientId, PrescriptionId prescriptionId) { ... }
AssignPrescription(prescriptionId, patientId); // compile error: wrong types// Domain/ValueObjects/PatientId.cs
namespace SystemForge.Domain.ValueObjects;
public sealed record PatientId(Guid Value)
{
public static PatientId New() => new(Guid.NewGuid());
public static PatientId Empty => new(Guid.Empty);
public override string ToString() => Value.ToString();
}
// Domain/ValueObjects/PrescriptionId.cs
public sealed record PrescriptionId(Guid Value)
{
public static PrescriptionId New() => new(Guid.NewGuid());
}
// Domain/ValueObjects/DrugOrderId.cs
public sealed record DrugOrderId(Guid Value)
{
public static DrugOrderId New() => new(Guid.NewGuid());
}Private Setters and Factory Methods
// Domain/Entities/Patient.cs
public sealed class Patient : Entity<PatientId>
{
// Private setters — cannot be mutated externally
public string Name { get; private set; } = string.Empty;
public DateOnly DateOfBirth { get; private set; }
public string MRN { get; private set; } = string.Empty;
public BloodType? BloodType { get; private set; }
public bool IsActive { get; private set; } = true;
private readonly List<Prescription> _prescriptions = [];
public IReadOnlyList<Prescription> Prescriptions => _prescriptions.AsReadOnly();
private Patient() { } // EF Core
// Factory method — validates invariants before creating
public static Result<Patient> Create(
string name,
DateOnly dateOfBirth,
string mrn)
{
if (string.IsNullOrWhiteSpace(name))
return Result.Failure<Patient>(PatientErrors.NameRequired);
if (string.IsNullOrWhiteSpace(mrn))
return Result.Failure<Patient>(PatientErrors.MRNRequired);
if (dateOfBirth > DateOnly.FromDateTime(DateTime.UtcNow))
return Result.Failure<Patient>(PatientErrors.FutureDateOfBirth);
var patient = new Patient
{
Id = PatientId.New(),
Name = name.Trim(),
DateOfBirth = dateOfBirth,
MRN = mrn.Trim().ToUpperInvariant(),
};
patient.RaiseDomainEvent(new PatientRegisteredDomainEvent(patient.Id));
return Result.Success(patient);
}
// Mutation via intention-revealing methods
public Result UpdateName(string newName)
{
if (string.IsNullOrWhiteSpace(newName))
return Result.Failure(PatientErrors.NameRequired);
Name = newName.Trim();
return Result.Success();
}
public void RecordBloodType(BloodType bloodType)
{
BloodType = bloodType;
RaiseDomainEvent(new BloodTypeRecordedDomainEvent(Id, bloodType));
}
}Aggregate Roots
An Aggregate Root is an entity that owns a cluster of related entities and enforces invariants across all of them. External code only interacts with the root, never directly with children.
// Domain/Entities/DrugOrder.cs (Aggregate Root)
public sealed class DrugOrder : Entity<DrugOrderId>
{
public PatientId PatientId { get; private set; }
public OrderStatus Status { get; private set; }
public DateTime OrderedAt { get; private set; }
public string OrderedBy { get; private set; } = string.Empty; // prescribing clinician
private readonly List<DrugOrderLine> _lines = [];
public IReadOnlyList<DrugOrderLine> Lines => _lines.AsReadOnly();
private DrugOrder() { }
public static Result<DrugOrder> Create(
PatientId patientId,
string orderedBy,
IEnumerable<CreateOrderLineRequest> lineRequests)
{
if (string.IsNullOrWhiteSpace(orderedBy))
return Result.Failure<DrugOrder>(DrugOrderErrors.PrescriberRequired);
var lines = lineRequests.ToList();
if (lines.Count == 0)
return Result.Failure<DrugOrder>(DrugOrderErrors.EmptyOrder);
var order = new DrugOrder
{
Id = DrugOrderId.New(),
PatientId = patientId,
Status = OrderStatus.Pending,
OrderedAt = DateTime.UtcNow,
OrderedBy = orderedBy,
};
foreach (var req in lines)
{
var lineResult = DrugOrderLine.Create(order.Id, req.MedicationCode, req.Dosage, req.Frequency);
if (lineResult.IsFailure)
return Result.Failure<DrugOrder>(lineResult.Error);
order._lines.Add(lineResult.Value);
}
order.RaiseDomainEvent(new DrugOrderCreatedDomainEvent(order.Id, patientId));
return Result.Success(order);
}
// Aggregate-level invariant: cannot approve an empty order
public Result Approve(string approvedBy)
{
if (Status != OrderStatus.Pending)
return Result.Failure(DrugOrderErrors.AlreadyProcessed);
if (_lines.Count == 0)
return Result.Failure(DrugOrderErrors.EmptyOrder);
Status = OrderStatus.Approved;
RaiseDomainEvent(new DrugOrderApprovedDomainEvent(Id, approvedBy));
return Result.Success();
}
public Result Cancel(string reason)
{
if (Status == OrderStatus.Dispensed)
return Result.Failure(DrugOrderErrors.CannotCancelDispensed);
Status = OrderStatus.Cancelled;
RaiseDomainEvent(new DrugOrderCancelledDomainEvent(Id, reason));
return Result.Success();
}
}
// Domain/Entities/DrugOrderLine.cs (child entity — accessed only via DrugOrder)
public sealed class DrugOrderLine : Entity<DrugOrderLineId>
{
public DrugOrderId OrderId { get; private set; }
public string MedicationCode { get; private set; } = string.Empty;
public Dosage Dosage { get; private set; }
public string Frequency { get; private set; } = string.Empty;
private DrugOrderLine() { }
internal static Result<DrugOrderLine> Create(
DrugOrderId orderId,
string medicationCode,
Dosage dosage,
string frequency)
{
if (string.IsNullOrWhiteSpace(medicationCode))
return Result.Failure<DrugOrderLine>(DrugOrderLineErrors.MedicationRequired);
return Result.Success(new DrugOrderLine
{
Id = DrugOrderLineId.New(),
OrderId = orderId,
MedicationCode = medicationCode,
Dosage = dosage,
Frequency = frequency,
});
}
}Invariant Enforcement
// Three layers of invariant enforcement:
// Layer 1: type system (compile time)
// A DrugOrder cannot be created without a PatientId — type system enforces it
public static Result<DrugOrder> Create(PatientId patientId, ...)
// Layer 2: factory method (runtime, before object is valid)
if (lines.Count == 0)
return Result.Failure<DrugOrder>(DrugOrderErrors.EmptyOrder);
// Layer 3: state machine methods (runtime, after object is created)
public Result Approve(string approvedBy)
{
if (Status != OrderStatus.Pending) // cannot approve already-processed order
return Result.Failure(DrugOrderErrors.AlreadyProcessed);
}
// What you do NOT use: exceptions for expected business rule violations
// Exceptions are for unexpected failures (DB down, null ref), not business logicEnumerations as Domain Concepts
// Domain/Enums/OrderStatus.cs
public enum OrderStatus
{
Pending = 0,
Approved = 1,
Dispensed = 2,
Cancelled = 3,
}
// Domain/Enums/BloodType.cs
public enum BloodType
{
APositive = 1,
ANegative = 2,
BPositive = 3,
BNegative = 4,
ABPositive = 5,
ABNegative = 6,
OPositive = 7,
ONegative = 8,
}Testing Entities Without a Database
// tests/Application.UnitTests/Patients/PatientTests.cs
public class PatientTests
{
[Fact]
public void Create_with_valid_data_should_succeed()
{
var result = Patient.Create("John Smith", new DateOnly(1985, 3, 15), "MRN-001");
Assert.True(result.IsSuccess);
Assert.Equal("JOHN SMITH", result.Value.Name); // normalized to uppercase? Adjust per your impl
Assert.Single(result.Value.PopDomainEvents());
}
[Fact]
public void Create_with_empty_name_should_fail()
{
var result = Patient.Create("", new DateOnly(1985, 3, 15), "MRN-001");
Assert.True(result.IsFailure);
Assert.Equal(PatientErrors.NameRequired, result.Error);
}
[Fact]
public void Create_with_future_dob_should_fail()
{
var futureDob = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(1));
var result = Patient.Create("John Smith", futureDob, "MRN-002");
Assert.True(result.IsFailure);
Assert.Equal(PatientErrors.FutureDateOfBirth, result.Error);
}
}
// No database. No DI container. No mocks. Pure instantiation — fast and reliable.Key Takeaway
An entity's job is to protect its own invariants. Private setters mean no external code can corrupt the state. Factory methods validate before the object exists. State-machine methods enforce transitions. The Result pattern returns errors without throwing exceptions. The result is a domain model that is impossible to misuse without effort — and that is exactly what you want in a system that manages patient data and drug orders.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.