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
When I first started building BlueRobin, I used raw strings everywhere—for user IDs, document hashes, email addresses, even file paths. The codebase was riddled with bugs where a user ID string was accidentally passed where a document ID was expected, and the compiler was perfectly happy about it. Introducing value objects was the turning point where the type system started working for me instead of against me.
Value objects are one of the most powerful yet underutilized tactical patterns in Domain-Driven Design. [Domain-Driven Design: Tackling Complexity in the Heart of Software] — Eric Evans , 2003 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,”
[Primitive Obsession]
— Refactoring Guru , 2024 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. [C# Records] — Microsoft , 2024
| 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, CustomId |
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
+CustomId UserId
+AuditAction Action
+Timestamp OccurredAt
}
class CustomId {
<<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 *-- CustomId : 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 CustomId,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.
[Value Object]
— Martin Fowler , 2016
// Core/Common/ValueObject.cs
namespace MyApp.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 CustomId, a custom identifier format used throughout the system. It enforces length and character set rules at creation.
// Core/Domain/Common/CustomId.cs
public class CustomId : ValueObject
{
public string Value { get; }
// Private constructor ensures enforcement of the factory method
private CustomId(string value) => Value = value;
public static Result<CustomId> Create(string? value)
{
if (string.IsNullOrWhiteSpace(value))
return Result.Failure<CustomId>(DomainErrors.General.Required("CustomId"));
var normalized = value.Trim().ToLowerInvariant();
if (normalized.Length != 8 || !normalized.All(char.IsLetterOrDigit))
return Result.Failure<CustomId>(DomainErrors.CustomId.InvalidFormat);
return Result.Success(new CustomId(normalized));
}
// Structural equality component
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return Value;
}
// Implicit conversion for ease of use (optional)
public static implicit operator string(CustomId 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.
[EF Core Owned Entity Types]
— Microsoft , 2024 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 CustomId
builder.Property(d => d.OwnerId)
.HasConversion(
id => id.Value, // To Database
value => CustomId.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);
});
}
[Implementing Domain-Driven Design]
— Vaughn Vernon , 2013
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.
The shift from primitive types to value objects was one of the most impactful refactoring exercises in BlueRobin. It caught bugs at compile time that previously required integration tests to detect. If you take away one thing from this article, let it be this: every time you reach for a string or int to represent a domain concept, consider whether a value object would make your intent clearer and your system safer.
Next Steps:
- See how Aggregates compose value objects into rich domain models.
- Learn about Factory Methods that ensure value objects are always created correctly.
Further Reading
- [Value Object] — Martin Fowler , 2016 — Martin Fowler’s definitive explanation of value objects and their role in domain modeling.
- [Domain-Driven Design: Tackling Complexity in the Heart of Software] — Eric Evans , 2003 — The original text introducing value objects as a tactical DDD pattern.
- [EF Core Owned Entity Types] — Microsoft , 2024 — Official documentation on persisting value objects with Entity Framework Core.