"A value object is an object whose equality is based on value rather than identity."
Entities are identified by an ID (a Person is still the same person if their name changes). Value objects have no identity — two value objects are equal when their values are equal. They are immutable and typically wrap primitive types to give them domain meaning.
A concrete motivation: using bare string for IDs, names, and emails allows any string to be assigned to any slot — the compiler cannot catch the error:
// Bad composition — primitive strings carry no semantic meaning
class Person
{
public string Id { get; set; }
public string Name { get; set; }
}
static void Main()
{
var person = new Person();
person.Id = Guid.NewGuid().ToString();
person.Name = Guid.NewGuid().ToString(); // no type error, but semantically wrong
}Value Object Base Class
The base class implements IComparable, IEquatable, and ISerializable. It overloads comparison and equality operators so value objects behave naturally. An implicit conversion operator lets you use a value object anywhere the underlying primitive is expected, keeping call sites clean:
// Value object base class — equality by value, not identity
public abstract class ValueObject<TValue>
: IComparable<ValueObject<TValue>>, IEquatable<ValueObject<TValue>>, ISerializable
{
[ExpandoKey(nameof(Value))]
public TValue Value { get; protected set; }
protected Type Type => typeof(TValue);
[BsonIgnore]
public ValidationResult<string> Validation
{
get
{
if (_validator == null)
{
_validationBuilder = new ValidationBuilder<TValue, string>();
Validators(_validationBuilder);
_validator = _validationBuilder.GetValidation();
}
return _validator.Validate(Value);
}
}
private IValidation<TValue, string> _validator;
private ValidationBuilder<TValue, string> _validationBuilder;
protected ValueObject() { }
protected abstract void Validators(ValidationBuilder<TValue, string> validators);
protected abstract bool EqualsCore(object obj);
protected abstract int GetHashCodeCore();
public abstract int CompareTo(ValueObject<TValue> other);
public bool Equals(ValueObject<TValue> other) => EqualsCore(other);
public override bool Equals(object obj) => EqualsCore(obj);
public override int GetHashCode() => GetHashCodeCore();
public void GetObjectData(SerializationInfo info, StreamingContext context)
{
Value = (TValue)info.GetValue(nameof(Value), Type);
}
// Operator overloads delegate to EqualsCore / CompareTo
public static bool operator ==(ValueObject<TValue> first, ValueObject<TValue> second)
{
if (ReferenceEquals(first, null) && ReferenceEquals(second, null)) return true;
if (ReferenceEquals(first, null) || ReferenceEquals(second, null)) return false;
return first.Equals(second);
}
public static bool operator !=(ValueObject<TValue> first, ValueObject<TValue> second) => !(first == second);
public static bool operator > (ValueObject<TValue> first, ValueObject<TValue> second) => first?.CompareTo(second) > 0;
public static bool operator < (ValueObject<TValue> first, ValueObject<TValue> second) => first?.CompareTo(second) < 0;
public static bool operator >=(ValueObject<TValue> first, ValueObject<TValue> second) => first?.CompareTo(second) >= 0;
public static bool operator <=(ValueObject<TValue> first, ValueObject<TValue> second) => first?.CompareTo(second) <= 0;
// Implicit conversion to the underlying primitive
public static implicit operator TValue(ValueObject<TValue> vo) => vo is null ? default : vo.Value;
}Concrete Implementation
Creating a value object is straightforward — implement the abstract methods and provide a static factory:
// Id value object — wraps a GUID string with validation and equality
public class Id : ValueObject<string>
{
public Id() { } // for expando deserializer
public Id(SerializationInfo info, StreamingContext context)
{
GetObjectData(info, context);
}
private Id(string id) => Value = id;
public static Id Create(string id) => new Id(id);
public override int CompareTo(ValueObject<string> other)
=> other is null ? 1 : Value.CompareTo(other.Value);
protected override bool EqualsCore(object obj)
{
if (obj is Id id) return Value == id.Value;
return false;
}
protected override int GetHashCodeCore() => Value.GetHashCode();
protected override void Validators(ValidationBuilder<string, string> validators)
{
validators.Add(
x => !string.IsNullOrEmpty(x)
? ValidationResult<string>.Success()
: ValidationResult<string>.Fail("Id cannot be empty or null"),
priority: 0);
}
}
// Usage
Id id = Id.Create(Guid.NewGuid().ToString());
string raw = id.Value; // explicit
string raw2 = (string)id; // implicit operator
bool isValid = id.Validation.IsValid;