Learnixo
Back to blog
AI Systemsintermediate

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.

Asma Hafeez KhanMay 16, 20265 min read
Clean Architecture.NETValue ObjectsDomain DesignC# Records
Share:𝕏

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:

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

Strongly-Typed ID Value Objects

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

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

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

C#
// 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, and Guid everywhere. When your method signature says void Prescribe(string medicationCode, decimal amount, string unit), nothing stops a caller from passing the arguments in the wrong order. When it says void Prescribe(MedicationCode code, Dosage dosage), the type system does. C# record types give you structural equality for free, making value objects almost zero overhead to implement.

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.