ValueObject, SingleValueObject, and value equality
There are two base classes, and picking the right one is a small but real convention. SingleValueObject<TSelf, TValue> is for a value object wrapping ONE primitive: a flight number (string), a bounded percentage (decimal). It gives you Value, equality, and ToString for free, so a subclass only needs a private constructor and a Create.
public abstract class SingleValueObject<TSelf, TValue> : ValueObject<TSelf>
where TSelf : SingleValueObject<TSelf, TValue>
{
public TValue Value { get; }
protected SingleValueObject(TValue value) => Value = value;
protected sealed override IEnumerable<object?> GetEqualityComponents()
{
yield return Value;
}
public override string ToString() => Value?.ToString() ?? string.Empty;
}For a value object with MORE than one field, extend ValueObject<T> directly and override GetEqualityComponents, yielding each field. Equality is then a SequenceEqual over those components: two value objects are equal when all their components match. FlightNoteVo (flight key + body) is the two-field case.
protected override IEnumerable<object?> GetEqualityComponents()
{
yield return EjdpUniqueFlightKey;
yield return Body;
}One more real detail worth copying. Some single-value VOs expose a second factory: public static T From(value) => Create(value).Value;. It’s a shorthand for domain-internal code and unit tests that throws if the value is invalid. The real XML doc is blunt: “Do NOT use at application boundaries, prefer Create and inspect the Result.” Two factories, two trust levels: Create at the edge, From only where you already know the value is good.
Try it yourself
Write a SingleValueObject
Feel how little a single-value VO needs. Model it on FlightNumberVo above.
- Write
sealed class PaxCountVo : SingleValueObject<PaxCountVo, int>with aprivate PaxCountVo(int value) : base(value) { } - Add
public static Result<PaxCountVo> Create(int value)that fails ifvalue < 0, elseOrSuccess - Notice you wrote NO
GetEqualityComponents, noValue, noToString— the base gives all three - Confirm two
PaxCountVos built from the same int are equal by value
Pick the base class
You're modelling a Money value object with an amount (decimal) AND a currency (string). Which base?
Show answer
Correct answer: A — ValueObject
Two fields that both define the value means ValueObject<Money> with both yielded from GetEqualityComponents, so two Moneys are equal only when amount AND currency match. SingleValueObject is for exactly one wrapped primitive.
Create versus From
Know which factory goes where.
A VO has both a Create and a From factory. Which do you call when parsing a value from an incoming API request, and why?
Reveal answer
Create, because it returns a Result you inspect. From unwraps the Result and throws on invalid input, which is fine for tests and known-good domain literals but wrong at an application boundary where invalid input is expected and should be a returned failure, not an exception.