⚡ Backend Intermediate ⏱️ 14 min

Value Objects: Immutability and Self-Validation in C#

Learn how to implement value objects with built-in validation, equality semantics, and EF Core persistence for a cleaner, more expressive domain model.

By Victor Robin

Introduction

Value objects are one of the most powerful yet underutilized tactical patterns in Domain-Driven Design. Unlike entities (which have identity), value objects are defined entirely by their attributes and are immutable once created. They are the building blocks of a rich domain model, encapsulating validation rules and behavior for concepts like email addresses, monetary amounts, or custom identifiers.

Why Use Value Objects?

In many applications, we use primitive types (string, int, decimal) to represent domain concepts. This leads to “Primitive Obsession,” where validation logic is scattered across services and controllers.

Value Objects solve this by:

  1. Enforcing Constraints: An invalid Value Object cannot exist (e.g., an email without @ symbol).
  2. Encapsulating Logic: Formatting, conversion, and equality logic live with the data.
  3. Improving Readability: EmailAddress is more expressive than just string.
  4. Ensuring Immutability: Once created, they cannot change, eliminating side effects.
AspectEntityValue Object
IdentityUnique identifierNo identity—defined by attributes
EqualitySame ID = same entitySame attributes = same value
MutabilityCan change over timeImmutable after creation
ExamplesUser, Document, OrderEmail, Money, Address, BlueRobinId

Architecture Overview

The following diagram illustrates how Value Objects are composed within an Entity or Aggregate Root. Note that Value Objects have no identity of their own and are fully owned by the parent Entity.

classDiagram
    direction TB
    
    class AuditLog {
        <<Aggregate Root>>
        +Guid Id
        +BlueRobinId UserId
        +AuditAction Action
        +Timestamp OccurredAt
    }
    
    class BlueRobinId {
        <<Value Object>>
        -string Value
        +static Create(string) Result
    }
    
    class Timestamp {
        <<Value Object>>
        -DateTime Value
        +static Now()
    }
    
    class AuditAction {
        <<Value Object>>
        -string ActionType
        -string Resource
        +static Create(string, string) Result
    }

    AuditLog *-- BlueRobinId : Composes
    AuditLog *-- Timestamp : Composes
    AuditLog *-- AuditAction : Composes

    classDef primary fill:#7c3aed,color:#fff
    classDef secondary fill:#06b6d4,color:#fff
    classDef db fill:#f43f5e,color:#fff
    classDef warning fill:#fbbf24,color:#000

    class AuditLog primary
    class BlueRobinId,Timestamp,AuditAction secondary

Implementation

The Base Value Object Class

To implement structural equality (equality based on values, not references), we use a base class that overrides Equals and GetHashCode.

// Core/Common/ValueObject.cs
namespace Archives.Core.Common;

public abstract class ValueObject : IEquatable<ValueObject>
{
    // Derived classes return their atomic values here
    protected abstract IEnumerable<object?> GetEqualityComponents();
    
    public override bool Equals(object? obj)
    {
        if (obj is null || obj.GetType() != GetType()) return false;
        return Equals((ValueObject)obj);
    }
    
    public bool Equals(ValueObject? other)
    {
        if (other is null) return false;
        // Compare sequences of values
        return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
    }
    
    public override int GetHashCode()
    {
        return GetEqualityComponents()
            .Aggregate(0, (hash, component) => HashCode.Combine(hash, component));
    }
    
    // Operator overloads for clean syntax: if (a == b)
    public static bool operator ==(ValueObject? left, ValueObject? right)
    {
        if (left is null && right is null) return true;
        if (left is null || right is null) return false;
        return left.Equals(right);
    }
    
    public static bool operator !=(ValueObject? left, ValueObject? right) => !(left == right);
}

Implementing a Value Object

Here is a concrete example of a BlueRobinId, a custom identifier format used throughout the system. It enforces length and character set rules at creation.

// Core/Domain/Common/BlueRobinId.cs
public class BlueRobinId : ValueObject
{
    public string Value { get; }

    // Private constructor ensures enforcement of the factory method
    private BlueRobinId(string value) => Value = value;

    public static Result<BlueRobinId> Create(string? value)
    {
        if (string.IsNullOrWhiteSpace(value))
            return Result.Failure<BlueRobinId>(DomainErrors.General.Required("BlueRobinId"));

        var normalized = value.Trim().ToLowerInvariant();

        if (normalized.Length != 8 || !normalized.All(char.IsLetterOrDigit))
            return Result.Failure<BlueRobinId>(DomainErrors.BlueRobinId.InvalidFormat);

        return Result.Success(new BlueRobinId(normalized));
    }

    // Structural equality component
    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Value;
    }

    // Implicit conversion for ease of use (optional)
    public static implicit operator string(BlueRobinId id) => id.Value;
    
    public override string ToString() => Value;
}

Complex Value Objects

Value objects can contain multiple properties. Consider a DocumentHash that stores both the hashing algorithm and the hash value.

public class DocumentHash : ValueObject
{
    public string Hash { get; }
    public string Algorithm { get; }

    private DocumentHash(string hash, string algorithm)
    {
        Hash = hash;
        Algorithm = algorithm;
    }

    public static Result<DocumentHash> Create(string hash, string algorithm)
    {
        if (string.IsNullOrWhiteSpace(hash))
            return Result.Failure<DocumentHash>(DomainErrors.General.Required("Hash"));
            
        // Validate specifically for SHA256 length (64 chars)
        if (algorithm == "SHA256" && hash.Length != 64)
             return Result.Failure<DocumentHash>(DomainErrors.Document.InvalidHash);

        return Result.Success(new DocumentHash(hash, algorithm));
    }

    protected override IEnumerable<object?> GetEqualityComponents()
    {
        yield return Hash;
        yield return Algorithm;
    }
}

EF Core Configuration

To map Value Objects to the database, we use the OwnsOne configuration in EF Core. This maps the value object’s properties to columns in the owning entity’s table (“Table Splitting”).

// Infrastructure/Persistence/Configurations/DocumentConfiguration.cs
public void Configure(EntityTypeBuilder<Document> builder)
{
    // Mapping BlueRobinId
    builder.Property(d => d.OwnerId)
        .HasConversion(
            id => id.Value,                  // To Database
            value => BlueRobinId.Create(value).Value) // From Database
        .HasColumnName("owner_id")
        .IsRequired();

    // Mapping Complex Value Object (Owned Type)
    builder.OwnsOne(d => d.Checksum, hash =>
    {
        hash.Property(h => h.Hash).HasColumnName("hash").IsRequired();
        hash.Property(h => h.Algorithm).HasColumnName("hash_algo").IsRequired();
        
        // Important: Indexing can be applied to owned type properties
        hash.HasIndex(h => h.Hash); 
    });
}

Conclusion

Value Objects are essential for creating a domain model that is expressive, safe, and easy to maintain. By encapsulating validation and behavior, they prevent “garbage data” from ever entering your system and make your code more self-documenting.