The value object library, five years on

The domain has forty-one value objects, up from thirty-two in 2021, and the decision to adopt the pattern has never been reviewed. There is no symptom prompting this — it is a look at a pattern that is easy to apply and hard to stop applying.

The symptom

$ ./bin/value-objects --list --with-invariants
  Money              validates: currency, non-negative
  EmailAddress       validates: format
  Percentage         validates: 0-100
  Sku                validates: pattern
  ...
  SupplierReference  validates: nothing
  CustomerName       validates: nothing
  OrderNote          validates: nothing
  ...

  41 types, of which 8 wrap a single string and
  enforce nothing.

Eight types that take a string, hold a string and return a string. The type safety is real — you cannot pass a supplier reference where a customer reference is expected — and it costs four files each once the normalizer, the cast and the tests are counted.

Why it happens

A pattern that is right thirty times becomes the default, and the default is applied to the thirty-first case without the question being asked again. Nothing in a review process prompts “should this be a type at all”.

The fix

The ones that were obviously right

public function __construct(
    public int $minorUnits,
    public Currency $currency,
) {
    if ($minorUnits < 0) {
        throw InvalidMoney::negative($minorUnits);
    }
}

public function plus(self $other): self
{
    if (! $this->currency->equals($other->currency)) {
        throw InvalidMoney::currencyMismatch($this->currency, $other->currency);
    }

    return new self($this->minorUnits + $other->minorUnits, $this->currency);
}
what Money has prevented, from the git history:

  four attempts to add amounts in different currencies
  two float conversions caught at review
  a negative total from a discount larger than the
    order, which threw in a test rather than on an
    invoice

three invariants, five years, seven prevented bugs that
left a trace. probably more that did not.

The eight that were ceremony

final readonly class SupplierReference
{
    public function __construct(public string $value) {}
}

// and the four files: the class, an API normalizer, a
// database cast, and a test asserting a string
// round-trips.

Deleting eight of these removed thirty-two files and produced a churn of about four hundred lines across the application, which is not free. The argument for keeping them is a type system that PHP does not have — a type alias would give the same distinction for no files, and there is no such thing.

The one that was wrong in an interesting way

// added 2022, and correct until 2025
final readonly class VatRate
{
    public function __construct(public int $basisPoints)
    {
        if (! in_array($basisPoints, [0, 500, 2000], true)) {
            throw InvalidVatRate::notAUkRate($basisPoints);
        }
    }
}

// the business started selling into a second
// jurisdiction. the invariant was a UK business rule
// encoded as a type constraint.

A value object that encodes a business rule is right until the rule changes, and the failure was loud and expensive — every historical rate object was still valid and every new one threw. The distinction that matters is between an invariant of the concept (a percentage is between zero and a hundred) and a rule of the business (a VAT rate is one of three values), and only the first belongs in a constructor.

The serialization registry, three years on

three contexts, decided in 2023:
  api  41 normalizers, all used
  csv  14, of which 11 used
  messages  22, all used

a fourth was proposed in 2024 for a partner feed and
turned out to be the api format with two fields
removed — a sparse fieldset rather than a context.

three has held. the registry is 77 small classes, which
is a lot of files and each one is six lines.

Seventy-seven normalizers is the cost that would have been avoided by putting jsonSerialize on the types, and the thing it bought is that the CSV format changed twice without touching the API. That is the trade and after three years it still looks correct — the counterfactual is a serialization change that could not be made without a version.

The two where equality was still ambiguous

public function isSameLocation(self $other): bool
{
    return $this->line1 === $other->line1
        && $this->postcode === $other->postcode;
}

public function equals(self $other): bool
{
    return $this == $other;   // includes the delivery note
}

Two addresses with the same lines and different delivery notes are the same location and not the same value, and both questions get asked — deduplication wants the first and a change audit wants the second. Naming them separately is what made the question answerable, and it had been sitting unnamed since 2021 with callers using whichever method they found.

What the analyser gave that runtime types did not

the runtime enforces that you cannot pass an
EmailAddress where a Money is expected. the analyser
additionally caught, over five years:

  a Money in a numeric comparison, 4 times
  an array of Money where a Money was expected
  a nullable value object passed to something that
    assumed presence, 22 times

the third is the useful category, and it is about
nullability rather than about value objects — the
types made it visible.

Verifying it worked

$ ./bin/value-objects --count
33          # was 41

$ ./bin/value-objects --without-invariants
0

$ git diff --stat HEAD~6 | tail -1
 148 files changed, 302 insertions(+), 688 deletions(-)

$ vendor/bin/phpstan analyse --level=9
 [OK] No errors

$ vendor/bin/phpunit
  Tests: 1,688 passed

Thirty-three types, all of which enforce something, is the outcome and the rule that produced it — a value object must have an invariant of the concept rather than of the business. Whether the eight deletions were worth four hundred lines of churn is genuinely arguable and the rule is worth more than the deletions.

What this costs

A pattern that is easy to apply and hard to stop applying, now with a rule that is a review question rather than a mechanism. Nothing prevents the thirty-fourth type, and the person adding it will have a good reason — the eight that were deleted each had one.

The VAT rate case is the one worth carrying forward. A constructor that throws is a claim about what is possible, and encoding a business rule there means a change to the business is a change that invalidates historical objects. The rule now says invariants of the concept only, which is a distinction that requires judgement at exactly the moment nobody wants to exercise it.