Value Objects — Immutability, Equality, and When to Use Them
Value objects in Clean Architecture: definition, implementation with C# records, equality semantics, validation inside value objects, strongly-typed IDs, and when an entity is the better choice.
What Makes a Value Object
A value object has no identity. Two instances with the same data are interchangeable. It is immutable — once created, it cannot change.
Entity: same ID → same thing, even if data differs
Patient(id=1, name="John") != Patient(id=2, name="John")
Value object: same data → same thing
Dosage("500mg") == Dosage("500mg") ← structurally equal
Address("1 Main St") == Address("1 Main St")C# Records as Value Objects
C# 9 record types give structural equality for free:
// Domain/ValueObjects/Dosage.cs
namespace SystemForge.Domain.ValueObjects;
public sealed record Dosage
{
public decimal Amount { get; }
public string Unit { get; }
private Dosage(decimal amount, string unit)
{
Amount = amount;
Unit = unit;
}
public static Result<Dosage> Create(decimal amount, string unit)
{
if (amount <= 0)
return Result.Failure<Dosage>(DosageErrors.AmountMustBePositive);
if (string.IsNullOrWhiteSpace(unit))
return Result.Failure<Dosage>(DosageErrors.UnitRequired);
var allowedUnits = new[] { "mg", "mcg", "g", "mL", "IU", "units" };
if (!allowedUnits.Contains(unit.ToLowerInvariant()))
return Result.Failure<Dosage>(DosageErrors.InvalidUnit);
return Result.Success(new Dosage(amount, unit.ToLowerInvariant()));
}
public override string ToString() => $"{Amount} {Unit}";
}
// Usage
var d1 = Dosage.Create(500, "mg").Value;
var d2 = Dosage.Create(500, "mg").Value;
Console.WriteLine(d1 == d2); // True — structural equality from recordStrongly-Typed ID Value Objects
// Domain/ValueObjects/PatientId.cs
public sealed record PatientId(Guid Value)
{
public static PatientId New() => new(Guid.NewGuid());
public static PatientId Empty => new(Guid.Empty);
public static PatientId From(Guid value) => new(value);
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/MedicationCode.cs
public sealed record MedicationCode
{
public string Value { get; }
private MedicationCode(string value) => Value = value;
// SNOMED CT or local formulary code
public static Result<MedicationCode> Create(string code)
{
if (string.IsNullOrWhiteSpace(code))
return Result.Failure<MedicationCode>(MedicationErrors.CodeRequired);
var normalized = code.Trim().ToUpperInvariant();
if (normalized.Length > 20)
return Result.Failure<MedicationCode>(MedicationErrors.CodeTooLong);
return Result.Success(new MedicationCode(normalized));
}
public override string ToString() => Value;
}Rich Value Objects with Business Logic
// Domain/ValueObjects/INRReading.cs
// INR = International Normalized Ratio (anticoagulation measure for warfarin patients)
public sealed record INRReading
{
public decimal Value { get; }
public DateTime MeasuredAt { get; }
private INRReading(decimal value, DateTime measuredAt)
{
Value = value;
MeasuredAt = measuredAt;
}
public static Result<INRReading> Create(decimal value, DateTime measuredAt)
{
if (value < 0.5m || value > 10.0m)
return Result.Failure<INRReading>(INRErrors.OutOfRange);
if (measuredAt > DateTime.UtcNow)
return Result.Failure<INRReading>(INRErrors.FutureMeasurement);
return Result.Success(new INRReading(value, measuredAt));
}
// Domain-meaningful interpretation
public INRStatus GetStatus() => Value switch
{
< 1.5m => INRStatus.SubTherapeutic,
>= 1.5m and < 2.0m => INRStatus.LowTherapeutic,
>= 2.0m and <= 3.0m => INRStatus.Therapeutic,
> 3.0m and <= 4.0m => INRStatus.HighTherapeutic,
_ => INRStatus.Supratherapeutic, // over 4.0 — bleeding risk
};
public bool RequiresUrgentReview => Value > 4.0m;
}
public enum INRStatus
{
SubTherapeutic,
LowTherapeutic,
Therapeutic,
HighTherapeutic,
Supratherapeutic,
}EF Core Configuration for Value Objects
EF Core requires configuration to persist value objects — it doesn't know how to map a record type by default:
// Infrastructure/Persistence/Configurations/PatientConfiguration.cs
public sealed class PatientConfiguration : IEntityTypeConfiguration<Patient>
{
public void Configure(EntityTypeBuilder<Patient> builder)
{
// Strongly-typed ID: store as Guid in DB
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.HasConversion(id => id.Value, value => new PatientId(value));
builder.Property(p => p.Name).HasMaxLength(200).IsRequired();
builder.Property(p => p.MRN).HasMaxLength(50).IsRequired();
// Enum stored as string for readability in DB
builder.Property(p => p.BloodType)
.HasConversion<string>()
.HasMaxLength(10);
}
}
// Infrastructure/Persistence/Configurations/PrescriptionConfiguration.cs
public sealed class PrescriptionConfiguration : IEntityTypeConfiguration<Prescription>
{
public void Configure(EntityTypeBuilder<Prescription> builder)
{
builder.HasKey(p => p.Id);
builder.Property(p => p.Id)
.HasConversion(id => id.Value, value => new PrescriptionId(value));
// Dosage as owned entity (stored as columns in the same table)
builder.OwnsOne(p => p.Dosage, dosage =>
{
dosage.Property(d => d.Amount).HasColumnName("DosageAmount").HasPrecision(10, 3);
dosage.Property(d => d.Unit).HasColumnName("DosageUnit").HasMaxLength(10);
});
// MedicationCode as converted string
builder.Property(p => p.MedicationCode)
.HasConversion(mc => mc.Value, v => MedicationCode.Create(v).Value)
.HasMaxLength(20);
}
}When to Use a Value Object vs an Entity
Use a value object when:
✓ Two instances with the same data are interchangeable (Dosage, Address, Money)
✓ The concept has no independent lifecycle
✓ Immutability is natural — you don't "update" a dosage, you replace it
✓ The concept adds validation and meaning to a primitive (MedicationCode vs string)
Use an entity when:
✓ You need to track it by identity over time (a patient is a patient even if they change their name)
✓ It has its own independent lifecycle (create, update, delete)
✓ Two instances with the same data are still different things (two separate prescriptions
for the same drug at the same dose for the same patient)Testing Value Objects
// tests/Application.UnitTests/ValueObjects/DosageTests.cs
public class DosageTests
{
[Theory]
[InlineData(500, "mg")]
[InlineData(0.5, "mcg")]
[InlineData(10, "mL")]
public void Create_with_valid_values_should_succeed(decimal amount, string unit)
{
var result = Dosage.Create(amount, unit);
Assert.True(result.IsSuccess);
}
[Fact]
public void Create_with_negative_amount_should_fail()
{
var result = Dosage.Create(-100, "mg");
Assert.True(result.IsFailure);
Assert.Equal(DosageErrors.AmountMustBePositive, result.Error);
}
[Fact]
public void Two_dosages_with_same_values_should_be_equal()
{
var d1 = Dosage.Create(500, "mg").Value;
var d2 = Dosage.Create(500, "mg").Value;
Assert.Equal(d1, d2);
}
[Fact]
public void INR_above_4_should_require_urgent_review()
{
var reading = INRReading.Create(4.5m, DateTime.UtcNow.AddHours(-1)).Value;
Assert.True(reading.RequiresUrgentReview);
Assert.Equal(INRStatus.Supratherapeutic, reading.GetStatus());
}
}Key Takeaway
Value objects replace primitive obsession — using raw
string,decimal, andGuideverywhere. When your method signature saysvoid Prescribe(string medicationCode, decimal amount, string unit), nothing stops a caller from passing the arguments in the wrong order. When it saysvoid Prescribe(MedicationCode code, Dosage dosage), the type system does. C#recordtypes give you structural equality for free, making value objects almost zero overhead to implement.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.