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.
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:
- Enforcing Constraints: An invalid Value Object cannot exist (e.g., an email without
@symbol). - Encapsulating Logic: Formatting, conversion, and equality logic live with the data.
- Improving Readability:
EmailAddressis more expressive than juststring. - Ensuring Immutability: Once created, they cannot change, eliminating side effects.
| Aspect | Entity | Value Object |
|---|---|---|
| Identity | Unique identifier | No identity—defined by attributes |
| Equality | Same ID = same entity | Same attributes = same value |
| Mutability | Can change over time | Immutable after creation |
| Examples | User, Document, Order | Email, 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.