Value Objects
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
Strategic Design for Microservices draws the bounded-context
boundary; Entities covers the first tactical building block inside
it. This page covers the second, and in most domain models the more numerous one: the value object. A value
object is defined by what it doesn’t have, identity, and by what it is: an immutable, self-validating
measurement or description, compared by its attributes rather than by which object it happens to be. Money,
Address, the typed identifiers ProductId/CustomerId/OrderId, and OrderLine from the running domain model
are all value objects — none of them carries a surrogate key or a lifecycle of its own, and every one of them is
implemented, as the code below shows, as a plain Java 21 record. The persistence half of this page targets
Spring Boot 4.1.x and Spring Data JPA on the current Hibernate baseline that ships with it, which is where a value
object’s lack of identity has a direct, practical consequence: it never gets its own table and its own repository
the way an entity does.
No identity, equality by attributes
Two Order instances with the same OrderId are the same order, even if one is a stale in-memory copy loaded
five minutes before the other and their status fields have since diverged — Entities covers exactly that identity-based equality, keyed on
OrderId alone, ignoring every other field. A value object inverts that rule completely: two Money(BigDecimal.
TEN, USD) instances are not "the same object representing ten dollars," they are simply the same $10, full
stop, regardless of Java reference identity (==) or which line of code constructed which instance. Nothing
distinguishes them, because nothing about a value object is meant to be distinguished beyond what its fields say — there is no MoneyId, no database row backing one specific Money instance, no notion of "this particular ten
dollars" as opposed to "that particular ten dollars."
A Java record’s generated `equals() and hashCode() are exactly this kind of comparison for free: both are
generated component-wise, so new Money(BigDecimal.TEN, USD).equals(new Money(BigDecimal.TEN, USD)) is true
without a line of hand-written comparison code, and the two instances hash identically, which is precisely why a
record is the natural implementation for a value object and not merely a syntactic convenience — the language
feature and the DDD concept want the same equality semantics. Contrast this with an Order entity, which
deliberately does not use a record for exactly this reason: an entity’s equals() must compare only the
identifier field, not every field, which is the opposite of what a record’s generated `equals() gives you.
Immutability, self-validation, and side-effect-free operations
Money from the running domain model shows the three properties that distinguish a well-designed value object
from a bag of fields with getters:
-
Immutability.
Money.addandMoney.multiplyeach return a newMoneyrather than mutating the receiver — there is nosetAmountanywhere in this type, and there never should be. Once constructed, aMoneyvalue cannot change out from under any code holding a reference to it. -
Self-validation, eagerly.
requireSameCurrencyruns insideadd, at the point the two amounts are actually combined, not deferred to some later step that assumes the currencies already matched. A currency mismatch fails loudly and immediately, exactly where the invalid combination was attempted, rather than silently producing aMoneywhose amount is nonsense in whichever currency happened to win. -
Side-effect-free operations. Every operation a value object exposes is a pure function of its own fields and its arguments —
addandmultiplyread no external state, touch no database, and have no effect beyond computing and returning a new value. This is what makes value objects trivially safe to reason about and to test in isolation, as the equality test further down does.
Value objects should also be kept thin: a value object’s job is to measure one amount or hold one description — Money combines and compares amounts, Address holds a postal address’s fields — and it should not
accumulate unrelated behavior just because it is a convenient place to put a method. A Money type is not the
right place for, say, currency-conversion logic against a live exchange rate service; that belongs to an
application service or domain service that can depend on infrastructure, something a value object deliberately
never does.
Immutability also buys back something the persistence and concurrency sections of this book series otherwise have
to work hard for: safe sharing. Because a Money instance can never change after construction, the exact
same instance can be passed to multiple OrderLine`s, cached, returned from a getter without a defensive copy, or
used as a `HashMap key, with no risk that some other part of the system mutates it out from under a caller that
did not expect that. An entity, whose fields are meant to change over its lifecycle, offers none of these
guarantees for free — another reason entities and value objects are modeled, and used, so differently.
When to promote a primitive to a value object
Primitive obsession is the smell a value object exists to cure: representing a domain concept with a raw
language primitive loses exactly the properties the previous section described. A raw BigDecimal amount for a
price loses currency-safety — nothing stops adding a USD amount to a EUR amount by mistake — and loses
unit-consistency, since two BigDecimal values scaled differently compare unequal even when they represent the
same amount. A raw String email loses validation — any string compiles, whether or not it is a well-formed
address. A raw UUID customerId loses type-safety: nothing at the type level stops passing a ProductId’s
underlying `UUID into a parameter typed UUID customerId, because to the compiler they are the same type. Every
value object in the running domain model exists to close one of these gaps: Money promotes a BigDecimal
Currency pair into something that refuses to combine mismatched currencies; Address promotes four loose
strings into one cohesive, named concept; CustomerId/ProductId/OrderId each promote a UUID into a distinct
type the compiler will not let you swap for another id type by accident; a hypothetical Quantity would promote a
raw int into something that could refuse to be negative.
Not every value deserves this treatment, though, and wrapping everything is its own anti-pattern. A value with no
real invariant to enforce and no realistic risk of type confusion — an internal loop counter, a page-size
parameter passed straight through to a single query method — gains nothing from a wrapper type beyond ceremony,
and in a hot path, boxing a primitive into an object can cost real allocation overhead for no corresponding
safety benefit. The question worth asking before introducing a new value object is the same one that justifies
Money and the typed ids: does this concept have an invariant worth enforcing, or a type-confusion risk worth
preventing, that a bare primitive cannot express?
Java’s record is the natural implementation once that question is answered "yes": final fields, a generated
equals()/hashCode()/toString(), and, as Money.requireSameCurrency shows, a compact constructor (or, as
here, a plain method called eagerly from the operations that need it) is exactly the hook self-validation needs.
This is a marked improvement over the pre-record idiom the same value object would have needed a decade ago — a final-field class with a manually written equals()/hashCode() pair (easy to get subtly wrong, especially
under inheritance), an all-args constructor or a builder to work around the lack of named parameters, and a
hand-rolled toString() for anything to be readable in a log or a debugger. None of that boilerplate carries any
domain meaning; a record declaration states the value object’s shape once and gets all of it for free.
Sealed interfaces for closed sets. A value object does not have to be a single record — when a domain
concept is a fixed, known set of variants rather than one shape, a sealed interface with record
implementations is a value object too, and the closed set gives the compiler something to check exhaustively. A
hypothetical payment method on the running domain model illustrates the shape without needing to be elaborated
further:
package com.example.payment.domain;
public sealed interface PaymentMethod permits CreditCard, BankTransfer {
}
public record CreditCard(String maskedNumber, String expiryMonthYear) implements PaymentMethod {
}
public record BankTransfer(String ibanLast4) implements PaymentMethod {
}
A pattern-matching switch over PaymentMethod (switch (method) { case CreditCard cc → …; case
BankTransfer bt → …; }) is exhaustive by construction: adding a third permits type without adding a matching
case is a compile error, not a runtime surprise discovered the first time that new variant reaches an
unprepared switch.
Persistence: @Embeddable, @ElementCollection, JSON columns, and converters
A value object never gets its own table or its own repository — that would give it exactly the identity and independent lifecycle it is defined by not having. Spring Data JPA offers four ways to persist one instead, each fitting a different shape of value object:
-
@Embeddable/@Embeddedmaps a value object’s fields onto columns on the owning entity’s own row, with no separate table at all.Address, embedded directly on a hypotheticalCustomerorShipmententity, is the textbook case:street,city,postalCode, andcountrybecome four ordinary columns on that entity’s table, and Hibernate reads and writes them as a unit whenever the owning entity is loaded or saved. -
@ElementCollectionmaps a collection of value objects to a child table of its own, still with no separate aggregate root and no separate repository for that table. The running domain model’sOrder.lines(aList<OrderLine>) is exactly this case: eachOrderLinebecomes one row in anorder_linetable carrying anorder_idforeign key back to its owningOrderrow, butorder_linerows are only ever read, inserted, or deleted as part of loading or saving theOrderaggregate — never queried or modified independently, sinceOrderLinehas no identity of its own to query by. -
JSON columns serialize a value object (or a collection of them) into a single JSON-typed column, an alternative worth reaching for when the value object’s internal shape is weakly structured or still evolving and no query ever needs to filter by one of its internal fields directly. It trades queryability of the embedded fields for schema flexibility — a fair trade for something like a loosely structured metadata blob, a poor one for
Address, which a real query (find shipments to this postal code) may well need to filter by. -
Attribute converters (
AttributeConverter<X, Y>) handle the case of a typed id used as a value object embedded in another entity’s own column, rather than as that entity’s own primary key.entities.adocestablishedcom.example.order.infrastructure.OrderIdConverter implements AttributeConverter<OrderId, UUID>for exactly this purpose (see Entities) — the same converter applies wherever anOrderId,ProductId, orCustomerIdneeds to be stored as a plainUUIDcolumn on some other entity’s table, without redefining the conversion logic at each call site.
Code: Money, Address, and the @Embeddable mapping for OrderLine
Money and Address are the two value objects from the running domain model referenced throughout this page,
shown here exactly as they are used everywhere else in this section:
package com.example.shared;
import java.math.BigDecimal;
import java.util.Currency;
public record Money(BigDecimal amount, Currency currency) {
public Money add(Money other) {
requireSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
public Money multiply(int factor) {
return new Money(amount.multiply(BigDecimal.valueOf(factor)), currency);
}
private void requireSameCurrency(Money other) {
if (!currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Cannot combine amounts in different currencies: " + currency + " and " + other.currency);
}
}
}
package com.example.shared;
public record Address(String street, String city, String postalCode, String country) {
}
Address maps directly as @Embeddable/@Embedded on whichever entity owns a postal address; OrderLine
(com.example.shared.OrderLine, defined once and reused throughout this section) needs the @ElementCollection
shape instead, since Order owns a List<OrderLine> rather than a single instance. Current Hibernate versions
(6.2 and later, which is what Spring Boot 4.1.x’s Spring Data JPA baseline builds on) do support Java records as
@Embeddable types directly, but a record embedded inside an @ElementCollection’s generic type argument is
still a newer, less battle-tested combination than a plain class — the safer, more predictable choice for a
production mapping today is a small dedicated embeddable class in the infrastructure layer, converting to and
from the domain’s own `OrderLine record at the boundary. Its ProductId field is @Convert-mapped through a
ProductIdConverter built the same way as entities.adoc’s `OrderIdConverter (an AttributeConverter<ProductId,
UUID>), not repeated here:
package com.example.order.infrastructure;
import com.example.shared.Money;
import com.example.order.domain.OrderLine;
import com.example.shared.ProductId;
import jakarta.persistence.AttributeOverride;
import jakarta.persistence.Column;
import jakarta.persistence.Convert;
import jakarta.persistence.Embeddable;
import java.math.BigDecimal;
import java.util.Currency;
@Embeddable
public class OrderLineEmbeddable {
@Convert(converter = ProductIdConverter.class) // AttributeConverter<ProductId, UUID>: field type must be ProductId
@Column(name = "product_id", nullable = false)
private ProductId productId;
@Column(name = "quantity", nullable = false)
private int quantity;
@Column(name = "amount", nullable = false)
private BigDecimal amount;
@Column(name = "currency_code", nullable = false)
private String currencyCode;
protected OrderLineEmbeddable() {
// required by Hibernate
}
private OrderLineEmbeddable(ProductId productId, int quantity, BigDecimal amount, String currencyCode) {
this.productId = productId;
this.quantity = quantity;
this.amount = amount;
this.currencyCode = currencyCode;
}
public static OrderLineEmbeddable fromDomain(OrderLine line) {
return new OrderLineEmbeddable(
line.productId(),
line.quantity(),
line.unitPrice().amount(),
line.unitPrice().currency().getCurrencyCode());
}
public OrderLine toDomain() {
Currency currency = Currency.getInstance(currencyCode);
return new OrderLine(productId, quantity, new Money(amount, currency));
}
}
Derived from Jakarta Persistence’s embeddable and element-collection mapping (@Embeddable, @ElementCollection, @Convert) as Spring Data JPA persists it — see Spring Data JPA reference — Persisting entities, and Basic & Embeddable Types in this site’s Hibernate reference for the full mapping options with their official Hibernate and Jakarta Persistence links.
Order’s own JPA entity, fully assembled later on
Repositories, Factories, and
Domain Services, maps its `List<OrderLineEmbeddable> field with @ElementCollection plus
@CollectionTable(name = "order_line", joinColumns = @JoinColumn(name = "order_id")), which is what actually
produces the child table described above, with Hibernate deleting and reinserting the whole collection’s rows on
every save rather than diffing individual lines — expected and harmless for a collection this small, owned
entirely by one aggregate.
Code: a test asserting value equality
The equality behavior the first section described is exactly what a unit test should pin down for any value
object — not to test the JDK’s record implementation, but to document, in an executable form, that two
independently constructed instances with the same attributes are interchangeable, and that Money.add still
enforces its currency invariant:
package com.example.shared;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.util.Currency;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class MoneyEqualityTest {
private static final Currency USD = Currency.getInstance("USD");
private static final Currency EUR = Currency.getInstance("EUR");
@Test
void equalAmountsInTheSameCurrencyAreEqualAndInterchangeable() {
Money first = new Money(BigDecimal.TEN, USD);
Money second = new Money(BigDecimal.TEN, USD);
assertThat(first).isEqualTo(second);
assertThat(first).isNotSameAs(second);
assertThat(first.hashCode()).isEqualTo(second.hashCode());
}
@Test
void addingMismatchedCurrenciesThrows() {
Money tenDollars = new Money(BigDecimal.TEN, USD);
Money tenEuros = new Money(BigDecimal.TEN, EUR);
assertThatThrownBy(() -> tenDollars.add(tenEuros))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Cannot combine amounts in different currencies");
}
}
The test uses JUnit Jupiter — see the JUnit 5 User Guide.
isNotSameAs is the point of the first assertion: it explicitly checks that first and second are two
different object references (== would be false) while isEqualTo — backed by the record’s generated
`equals() — is still true. That is the entire distinction this page has been building toward, expressed as
one assertion pair.
The running domain: entity identity vs. value equality
The figure below places the running domain model’s Order entity and Money value object side by side to make
the contrast concrete: two Order snapshots taken at different points in its lifecycle, with different status
values, are still considered equal because their OrderId matches; two separately constructed Money(10, USD)
instances are considered equal, and freely interchangeable, purely because their attributes match, with no
identifier involved at all.