Value Objects in C# — Immutability and Structural Equality
Implement DDD value objects in C#: records vs classes, structural equality, factory methods with validation, common value objects (Money, Address, PatientMrn), and persisting value objects with EF Core.
Value Objects vs Entities
Entity: has identity — Patient #3f4a... is a specific patient
Value Object: defined by its values — 5mg Warfarin = 5mg Warfarin regardless of "which one"
Value Object rules:
✓ Immutable — no setters, no state mutation
✓ Structural equality — two objects with same values are equal
✓ No identity — no ID field
✓ Replace, don't mutate — create a new one to "change" a value
Examples:
PatientMrn("MRN-001") — identifies a patient but has no identity of its own
DosageValue(5.0, "mg") — represents a dose; 5mg = 5mg
Money(100.00, "GBP") — £100 = £100
Address("1 Main St", ..) — same address = same value object
DateRange(start, end) — a period of timeUsing C# Records (Preferred)
// C# records provide structural equality automatically
public sealed record PatientMrn
{
public string Value { get; }
private PatientMrn(string value) => Value = value;
public static Result<PatientMrn> Create(string value)
{
if (string.IsNullOrWhiteSpace(value))
return Result.Failure<PatientMrn>(
new Error("PatientMrn.Required", "MRN is required."));
var normalized = value.Trim().ToUpperInvariant();
if (normalized.Length > 20)
return Result.Failure<PatientMrn>(
new Error("PatientMrn.TooLong", "MRN cannot exceed 20 characters."));
return Result.Success(new PatientMrn(normalized));
}
public override string ToString() => Value;
}
// Usage
var mrn1 = PatientMrn.Create("MRN-001").Value;
var mrn2 = PatientMrn.Create("mrn-001").Value; // normalized to "MRN-001"
Console.WriteLine(mrn1 == mrn2); // True — structural equality by recordComplex Value Objects
// DosageValue: amount + unit together define a dose
public sealed record DosageValue
{
public decimal Amount { get; }
public string Unit { get; } // "mg", "mcg", "g", "units/kg"
private static readonly string[] ValidUnits = { "mg", "mcg", "g", "units/kg", "IU" };
private DosageValue(decimal amount, string unit)
{
Amount = amount;
Unit = unit;
}
public static Result<DosageValue> Create(decimal amount, string unit)
{
if (amount <= 0)
return Result.Failure<DosageValue>(
new Error("DosageValue.Invalid", "Dose amount must be greater than zero."));
if (!ValidUnits.Contains(unit?.ToLowerInvariant()))
return Result.Failure<DosageValue>(
new Error("DosageValue.InvalidUnit", $"'{unit}' is not a recognized dose unit."));
return Result.Success(new DosageValue(amount, unit.ToLowerInvariant()));
}
public string Display => $"{Amount} {Unit}";
public override string ToString() => Display;
// Value objects can have behavior
public DosageValue WithAmount(decimal newAmount) =>
Create(newAmount, Unit).Value; // returns new instance — immutable
}
// DateRange value object
public sealed record DateRange
{
public DateTime Start { get; }
public DateTime End { get; }
public int DurationDays => (End - Start).Days;
private DateRange(DateTime start, DateTime end)
{
Start = start;
End = end;
}
public static Result<DateRange> Create(DateTime start, DateTime end)
{
if (end <= start)
return Result.Failure<DateRange>(
new Error("DateRange.Invalid", "End date must be after start date."));
return Result.Success(new DateRange(start, end));
}
public bool Contains(DateTime date) => date >= Start && date <= End;
public bool Overlaps(DateRange other) => Start < other.End && End > other.Start;
}Money Value Object
public sealed record Money
{
public decimal Amount { get; }
public string Currency { get; }
private Money(decimal amount, string currency)
{
Amount = amount;
Currency = currency;
}
public static Result<Money> Create(decimal amount, string currency)
{
if (amount < 0)
return Result.Failure<Money>(new Error("Money.Negative", "Amount cannot be negative."));
if (string.IsNullOrWhiteSpace(currency) || currency.Length != 3)
return Result.Failure<Money>(new Error("Money.InvalidCurrency", "Currency must be a 3-letter ISO code."));
return Result.Success(new Money(Math.Round(amount, 2), currency.ToUpperInvariant()));
}
// Arithmetic preserves the value object pattern
public Result<Money> Add(Money other)
{
if (Currency != other.Currency)
return Result.Failure<Money>(new Error("Money.CurrencyMismatch",
$"Cannot add {Currency} and {other.Currency}."));
return Money.Create(Amount + other.Amount, Currency);
}
public override string ToString() => $"{Amount:F2} {Currency}";
}Class-Based Value Objects (Pre-.NET Record)
// If records aren't available or you need custom equality
public sealed class Address : ValueObject
{
public string Street { get; }
public string City { get; }
public string Country { get; }
public Address(string street, string city, string country)
{
Street = street;
City = city;
Country = country;
}
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Street.ToLowerInvariant();
yield return City.ToLowerInvariant();
yield return Country.ToUpperInvariant();
}
}
// Base class
public abstract class ValueObject
{
protected abstract IEnumerable<object?> GetEqualityComponents();
public override bool Equals(object? obj)
{
if (obj is null || obj.GetType() != GetType()) return false;
return GetEqualityComponents()
.SequenceEqual(((ValueObject)obj).GetEqualityComponents());
}
public override int GetHashCode() =>
GetEqualityComponents()
.Aggregate(0, (hash, c) => HashCode.Combine(hash, c));
public static bool operator ==(ValueObject? a, ValueObject? b) =>
a?.Equals(b) ?? b is null;
public static bool operator !=(ValueObject? a, ValueObject? b) => !(a == b);
}Persisting Value Objects with EF Core
// Option 1: Value converter (single-column simple values)
builder.Property(p => p.Mrn)
.HasConversion(
mrn => mrn.Value,
value => PatientMrn.Create(value).Value)
.HasMaxLength(20);
// Option 2: OwnsOne (multi-column complex values)
builder.OwnsOne(p => p.Dose, dose =>
{
dose.Property(d => d.Amount).HasColumnName("dose_amount").HasColumnType("decimal(10,4)");
dose.Property(d => d.Unit).HasColumnName("dose_unit").HasMaxLength(20);
});
// Option 3: JSON column (.NET 8+)
builder.Property(p => p.PreviousDoses)
.HasColumnType("nvarchar(max)")
.HasConversion(
v => JsonSerializer.Serialize(v, null as JsonSerializerOptions),
v => JsonSerializer.Deserialize<List<DosageValue>>(v, null as JsonSerializerOptions) ?? new());Production issue I've seen: A team stored
DosageValueas a plain string"5mg"in the database — parsing it in application code on every read. Over time, the format varied: "5mg", "5 mg", "5.0mg", "5.0 mg". When a report needed to filter patients by dose amount, the inconsistent format made the query impossible without loading every row and parsing in memory. Using a proper value object with separateamountandunitcolumns from the start would have made the query trivial:WHERE dose_amount = 5 AND dose_unit = 'mg'.
Key Takeaway
Value objects are defined by their values, not their identity. Use C# records for simple value objects — structural equality is built in. Always validate in the factory method and return
Result<T>— never throw from constructors. Value objects are immutable: create new instances to represent changes. Persist multi-column value objects with EF CoreOwnsOne(), single-column with value converters.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.