Whetstone.
Value objectsValueObject, SingleValueObject, and value equality
Module 3 · Lesson 212 min

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.

SingleValueObject gives Value + equality free (SingleValueObject.cs)
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.

A multi-field VO's equality (FlightNoteVo.cs)
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.

Practice

Try it yourself

Do

Write a SingleValueObject

Feel how little a single-value VO needs. Model it on FlightNumberVo above.

  • Write sealed class PaxCountVo : SingleValueObject<PaxCountVo, int> with a private PaxCountVo(int value) : base(value) { }
  • Add public static Result<PaxCountVo> Create(int value) that fails if value < 0, else OrSuccess
  • Notice you wrote NO GetEqualityComponents, no Value, no ToString — the base gives all three
  • Confirm two PaxCountVos built from the same int are equal by value
Done whenYour VO extends SingleValueObject, has only a private ctor and a Create, and inherits Value, value-equality, and ToString for free.
Quiz

Pick the base class

You're modelling a Money value object with an amount (decimal) AND a currency (string). Which base?

  1. AValueObject, yielding both amount and currency from GetEqualityComponents
  2. BSingleValueObject<Money, decimal>, ignoring the currency entirely
  3. CA plain record with public fields
  4. DEntity, because it has two fields
Show answer

Correct answer: A — ValueObject, yielding both amount and currency from GetEqualityComponents

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.

Recall

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.

Sign in to track your progress →