Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Equal by Structure, Immutable by Construction

A value object is the tactical primitive that turns primitive obsession into a typed alternative. Two Money values are equal because they have the same value and the same currency, not because they are the same reference. Two EmailAddress values are interchangeable as long as their normalised string is identical. @ValueObject from @frenchexdev/ddd-value-object reifies that discipline — equal by structure, immutable by readonly, validated at construction.


What @ValueObject Reifies

Evans's Domain-Driven Design chapter 5 introduces value objects as the antidote to the primitive obsession anti-pattern. A function signature function notify(email: string, message: string) accepts any string in either position — passing the message where the email belongs is a runtime bug, not a type error. Replace both with value objects — function notify(email: EmailAddress, message: NotificationMessage) — and the compiler refuses the swap, the constructor enforces the format, and the receiver never has to re-validate.

Three properties define a value object. Immutability: every field is readonly, no mutators, no late-bound state. Structural equality: two instances are equal if their fields are equal, regardless of reference. Validity by construction: invariants are enforced in the constructor, so a value object that exists is by definition valid — no need to re-check inside every consumer.

The corpus's @ValueObject decorator takes one optional argument: equality: 'structural' | 'reference'. Default is structural — same fields, same value. Reference equality is the escape hatch for cases where structural comparison is too expensive (a value object wrapping a large opaque payload) or semantically wrong (a transient session token where two distinct instances should never compare equal).


The Runtime: ddd-value-object

decorator.ts declares the surface. ValueObjectOptions accepts an optional equality: 'structural' | 'reference'. The decorator stamps __valueObject and __valueObjectMetadata onto the class. The runtime also exports a structurallyEqual<T>(a, b) helper that the codegen-emitted Base classes use:

export function structurallyEqual<T extends object>(a: T, b: T): boolean {
  const ka = Object.keys(a) as Array<keyof T>;
  const kb = Object.keys(b) as Array<keyof T>;
  if (ka.length !== kb.length) return false;
  for (const k of ka) if (a[k] !== b[k]) return false;
  return true;
}

The implementation is intentionally shallow — same-reference comparison per field. Nested value objects use their own equals methods (which the codegen will emit), so depth is the consumer's choice, not the helper's policy.

In our invented Subscription context, a Money value object:

import { ValueObject } from '@frenchexdev/ddd-value-object';
import { Guard } from '@frenchexdev/ddd-core';

export type Currency = 'EUR' | 'USD' | 'GBP';

@ValueObject({ equality: 'structural' })
export class Money {
  constructor(
    public readonly value:    number,
    public readonly currency: Currency,
  ) {
    Guard.inRange(value, 0, Number.MAX_SAFE_INTEGER, 'Money.value');
  }
}

Three things at once. The decorator names the class as a value object so downstream tools find it. Both fields are readonly (the analyzer will refuse otherwise). The constructor calls Guard.inRange from the kernel — invalid input throws GuardError, a Money that exists is by definition valid.


The Analyzer: ddd-value-object-analyzer

The analyzer is hand-written, like Domain Event and Event Bus. codes.ts exports two diagnostics in the DDD0NNN namespace.

DDD0110_VO_MUTABLE_FIELD is the immutability invariant at error severity. A value object with a mutable field cannot have sound structural equality — mutate one field after the equality check and the answer changes. The diagnostic message names the field so the fix is mechanical.

DDD0111_VO_MISSING_FIELDS is the no value warning. A value object with no fields carries no value; it is either a misuse of the decorator (the class is actually a domain service or a marker) or an empty placeholder. The analyzer surfaces it at warning severity so the team makes the call deliberately.

voMutableField('Money', 'value', 'value-objects/money.ts', 14);
// DDD0110 [error] @ValueObject class "Money" exposes mutable field "value".
// All fields must be `readonly` for structural equality to be sound.

voMissingFields('EmptyTagVO', 'value-objects/empty.ts');
// DDD0111 [warning] @ValueObject class "EmptyTagVO" exposes no fields.
// A ValueObject with no attributes carries no value and is likely a misuse —
// consider removing the decoration.

The hand-written shape places this analyzer in the same migration cohort as domain-event-analyzer — a future milestone will rewrite it on top of defineAnalyzerSpec, and the two diagnostic codes are part of the contract that must survive.


The Codegen: ddd-value-object-codegen

generator.ts emits a typed Base class per value object. The Base carries the constructor signature with public readonly parameters, and an equals(other) method implementing the chosen equality kind. The leaf class extends the Base, adds the domain methods, and inherits the equality discipline for free.

// AUTO-GENERATED by ddd-value-object-codegen@0.0.1 — do not edit.
// Base class for ValueObject "Money". Equality kind: structural.

export abstract class MoneyBase {
  protected constructor(
    public readonly value: number,
    public readonly currency: Currency,
  ) {}

  equals(other: MoneyBase): boolean {
    if (this.value !== other.value) return false;
    if (this.currency !== other.currency) return false;
    return true;
  }
}

The leaf class then becomes:

@ValueObject({ equality: 'structural' })
export class Money extends MoneyBase {
  constructor(value: number, currency: Currency) {
    super(value, currency);
    Guard.inRange(value, 0, Number.MAX_SAFE_INTEGER, 'Money.value');
  }

  // Domain methods on top — the Base only carries structure + equality.
  plus(other: Money): Money {
    if (this.currency !== other.currency) throw new Error('currency mismatch');
    return new Money(this.value + other.value, this.currency);
  }
}

The generated equals is field-by-field at strict equality (!==). For value objects whose fields are themselves value objects, the codegen could be extended to recurse via the field type's own equals; at the current milestone the implementation is shallow, and consumers compose comparisons manually if depth is required.


@ValueObject is the smallest tactical pattern and the one every aggregate relies on.

  • Builds on ddd-coreGuard validates fields at construction, Id<T> brand types appear as value-object fields.
  • Composed into @Entity and @AggregateRoot — entities and aggregates hold value objects as typed fields.
  • Used by @Specification — predicates over value objects are the cleanest specifications.
  • Returned by @QueryHandlers as part of @ReadModel shapes.

Back to the series index.

⬇ Download