Value Objects — Specification¶
This document defines a Value Object formally. If a candidate class fails any invariant below, it is not a Value Object and code reviews must reject it.
1. The five formal invariants¶
A class V is a Value Object if and only if it satisfies all of the following invariants. The wording follows Eric Evans, Domain-Driven Design (2003, ch. 5) and Vaughn Vernon, Implementing Domain-Driven Design (2013, ch. 6).
Invariant 1 — Immutability¶
For every instance v of V and every accessible field f:
fis declaredfinal(orVis a record, which makes its componentsfinalby the JLS).- No method of
Vassigns to any field after construction. - No referenced object held by
vis mutated by any method ofV.
If V holds a reference to a mutable type (Date, List<T>, byte[]), V must defensively copy on construction and on read, or wrap it in an unmodifiable view. The reference is final; the referent must be unobservably stable.
Invariant 2 — Equality by value¶
For every two instances a, b of V whose corresponding attribute values are equal under the natural equality of each attribute's type:
a.equals(b) == truea.hashCode() == b.hashCode()aandbare mutually substitutable in any method that accepts aV.
equals must be reflexive, symmetric, transitive, consistent, and a.equals(null) == false. hashCode must be consistent with equals for the lifetime of the JVM (the JLS Object.hashCode contract). Records satisfy both clauses automatically based on all canonical components.
Invariant 3 — No identity¶
V defines no field that exists solely to disambiguate two otherwise-equal instances. Specifically:
- No
id,uuid,key,surrogateKeyfield. - No JPA
@Idor@GeneratedValueannotation. - No reliance on
System.identityHashCodeor reference equality (==). V'sequalsdoes not consult any field that is not part of its attribute set.
A "transient" or "derived" field is permissible only if it is a deterministic function of the attribute set and is excluded from equals/hashCode.
Invariant 4 — Side-effect-free behaviour¶
For every public method m of V:
mdoes not modify the state ofthisor of any reachable object.mdoes not perform I/O (no disk, network, console, clock read, random read).mdoes not throw on inputs that the type system claims to accept (totality of operations).m's return value depends only onthisand the explicit parameters.
A method that appears to modify this (v.plus(other)) returns a new instance of V with the derived attribute values.
Invariant 5 — Conceptual whole¶
The attribute set of V forms a single domain concept that travels together:
- Removing any attribute would change the meaning of
V. - Splitting
Vinto smaller VOs would lose a domain invariant that ties the attributes. - Callers do not routinely consume one attribute in isolation.
Money(amount, currency) is a whole: amount without currency is meaningless. PersonName(first, last) is a whole: last without first is half a name. Person(name, age) is not a whole: callers consume age and name independently — it should be Entity with Name (VO) and an age derived from a birth-date VO.
2. Validation strategy¶
Two enforcement points, used together:
A — Compact constructor¶
For records (JEP 395), validation lives in the compact constructor:
public record Money(long cents, String currency) {
public Money {
if (cents < 0)
throw new IllegalArgumentException("Money.cents must be >= 0");
if (currency == null || currency.length() != 3)
throw new IllegalArgumentException("Money.currency must be ISO-4217: " + currency);
currency = currency.toUpperCase(java.util.Locale.ROOT);
}
}
Contract:
- Throws
IllegalArgumentException(unchecked) on any invalid input. - Normalizes inputs (trim, lowercase, intern, scale-set) before assignment.
- Runs unconditionally for every construction path, including reflection-based deserialization frameworks that respect canonical constructors.
B — Factory method¶
When construction has multiple valid input shapes (parse a string vs. supply components) or when a Result/Optional API is preferred over exceptions, expose static factories:
public record Email(String value) {
public Email { /* throws on invalid */ }
public static Email of(String raw) { return new Email(raw); }
public static java.util.Optional<Email> tryOf(String raw) {
try { return java.util.Optional.of(new Email(raw)); }
catch (IllegalArgumentException e) { return java.util.Optional.empty(); }
}
}
The constructor remains the single point of validation; factories are thin wrappers that select an error-handling style.
Forbidden patterns¶
- No
init()method that callers must remember to invoke. A VO is valid the instant the constructor returns or it ceases to exist. - No
validboolean field on a partially-constructed VO. There is no half-valid VO. - No setter that calls validate at the end. Setters are forbidden outright.
- No reflective field assignment by mappers, deserializers, or ORMs that bypasses the constructor. Configure each tool to use constructor-based instantiation.
3. Sample ArchUnit rules¶
These rules, run in CI, enforce invariants 1, 3, and (structurally) 4 at the package level. Place them in a dedicated ArchitectureTest class.
import com.tngtech.archunit.core.domain.JavaModifier;
import com.tngtech.archunit.junit.AnalyzeClasses;
import com.tngtech.archunit.junit.ArchTest;
import com.tngtech.archunit.lang.ArchRule;
import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.*;
@AnalyzeClasses(packages = "com.acme")
class ValueObjectSpecification {
@ArchTest
static final ArchRule classes_are_final =
classes().that().resideInAPackage("..domain.value..")
.should().haveModifier(JavaModifier.FINAL);
@ArchTest
static final ArchRule fields_are_final =
fields().that().areDeclaredInClassesThat().resideInAPackage("..domain.value..")
.should().beFinal();
@ArchTest
static final ArchRule no_setters =
noMethods().that().areDeclaredInClassesThat().resideInAPackage("..domain.value..")
.should().haveNameMatching("set[A-Z].*");
@ArchTest
static final ArchRule no_id_field =
noFields().that().areDeclaredInClassesThat().resideInAPackage("..domain.value..")
.should().haveName("id");
@ArchTest
static final ArchRule no_jpa_id_annotation =
noFields().that().areDeclaredInClassesThat().resideInAPackage("..domain.value..")
.should().beAnnotatedWith(jakarta.persistence.Id.class);
@ArchTest
static final ArchRule no_logger_in_vo =
noFields().that().areDeclaredInClassesThat().resideInAPackage("..domain.value..")
.should().haveRawType("org.slf4j.Logger");
@ArchTest
static final ArchRule no_inheritance_from_VO =
classes().that().resideInAPackage("..domain.value..")
.and().areNotInterfaces()
.should().haveModifier(JavaModifier.FINAL);
}
Rules to add as your codebase grows:
- Forbid
@Setter(Lombok) anywhere in the VO package. - Forbid
@Data(Lombok) — it includes setters. - Forbid implementation of
java.io.Externalizableor customwriteObject/readObjectthat bypass the constructor. - Forbid public mutable static fields in VOs.
- Require every VO class name to be a noun (regex
^[A-Z][a-zA-Z]+$, not ending inService,Manager,Helper,Util).
4. Conformance test template¶
For every VO V, the project must include a conformance test that exercises invariants 1, 2, 3, and 4:
class MoneyConformanceTest {
@Test void equality_is_attribute_based() {
assertThat(new Money(100, "USD")).isEqualTo(new Money(100, "USD"));
assertThat(new Money(100, "USD")).isNotEqualTo(new Money(100, "EUR"));
assertThat(new Money(100, "USD")).isNotEqualTo(new Money(101, "USD"));
}
@Test void hashCode_matches_equals() {
assertThat(new Money(100, "USD").hashCode())
.isEqualTo(new Money(100, "USD").hashCode());
}
@Test void rejects_invalid_input() {
assertThatThrownBy(() -> new Money(-1, "USD")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new Money(1, "US")).isInstanceOf(IllegalArgumentException.class);
assertThatThrownBy(() -> new Money(1, null)).isInstanceOf(IllegalArgumentException.class);
}
@Test void operations_return_new_instances() {
var a = new Money(100, "USD");
var b = a.plus(new Money(50, "USD"));
assertThat(a.cents()).isEqualTo(100); // unchanged
assertThat(b.cents()).isEqualTo(150);
assertThat(a).isNotSameAs(b);
}
@Test void cross_currency_is_rejected() {
assertThatThrownBy(() -> new Money(1, "USD").plus(new Money(1, "EUR")))
.isInstanceOf(IllegalArgumentException.class);
}
}
This template, replicated for each VO, gives a structural guarantee that the invariants survive refactors.
Memorize this¶
- A VO satisfies five invariants: immutable, equality-by-value, no identity, side-effect-free, conceptual whole. Drop one and you've built something else.
- Validation lives in the compact constructor. Nowhere else. No
init, novalidate(), no half-valid state. - ArchUnit rules — no setters, all fields final, no
idfield, no@Id, classes final — convert the spec from a code-review opinion into a build failure. - Every VO ships with a conformance test that asserts equality, hash-equality, rejection of invalid input, and immutability under operations.
- A VO with an id is an Entity. A VO with a setter is a JavaBean. A VO with a side effect is a service. None of those belong in the VO package.
- Reference: Evans ch. 5; Vernon ch. 6; JLS §11 (
Object.equals/hashCodecontracts); JEP 395 (records).