Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
DDDValue ObjectsDomain-Driven DesignC#.NETArchitecture
Share:𝕏

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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.