Learnixo

Domain-Driven Design in .NET · Lesson 3 of 7

Value Objects in C# — Immutability and Equality

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 time

Using C# Records (Preferred)

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

Complex Value Objects

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

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

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

C#
// 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 DosageValue as 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 separate amount and unit columns 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 Core OwnsOne(), single-column with value converters.