Most order systems we run into share the same symptom: classes like Order, Customer and Product are just bags of getters and setters, while every business rule is scattered across services. Money is a double, a phone number is a String, and any piece of code can call order.getLines().add(...) and break the rule "a confirmed order cannot be edited". Domain-Driven Design (DDD) offers a set of tactical building blocks to end this, and the Value Object, the Entity and the Aggregate are its three foundations. This article explains each concept with working Java 21 code (records, JPA/Hibernate), points out where people get confused, and shows when DDD is overkill.

It follows Specification pattern: dynamic queries with Spring Data JPA and Repository pattern and Unit of Work with JPA: the repository returns an Aggregate and the specification describes the filter, while this article looks at the structure of the stored domain objects themselves.

The problem: an anemic domain model and the role of the Value Object

Martin Fowler calls a design with data but no behavior an "anemic domain model". For an order, it looks like this:

  • Every property has a public setter, so a valid state is guaranteed only by developer habit.
  • Validation is repeated in controllers, services and batch jobs; each place checks differently and some forget.
  • Primitive types (String, double) carry business meaning, but the compiler cannot tell a "phone number" from an "order code".

DDD proposes pulling the rules back into the domain objects, through three blocks: objects with an identity (Entity), objects that are just values (Value Object), and a cluster of objects with a single entry point (Aggregate).

Entity: identity decides, attributes can change

An Entity is an object with an identity that lasts through its lifecycle: order DH001 is still order DH001 after its shipping address changes. Two Entities are equal when they share an identity, regardless of how their other attributes differ. In code, this means equals and hashCode depend on the id only:

@Override public boolean equals(Object o) { return o instanceof Order other && id.equals(other.id); }
@Override public int hashCode() { return id.hashCode(); }

A practical note: if the database generates the id (IDENTITY), the id is null before saving and hashCode changes after saving, which breaks any HashSet. This article assigns the id at creation time with a UUID to avoid that problem. Real run output (code/s6-23, Spring Boot 3.3.5, Hibernate 6.5.3, in-memory H2):

=== 1. Entity: equal by id ===
Same id, different address: equals = true
Different id, same data: equals = false

What a Value Object is and why you should use it more than you think

According to Martin Fowler's article on the Value Object, a Value Object is an object whose identity comes from the values of its properties ("equal due to the value of their properties"), and it should be immutable: to "change" it, you create a new object. Fowler also warns about aliasing bugs, where two variables point at the same mutable object and a change through one breaks the other. A Value Object has three traits:

  • Immutable: every field is final, there are no setters, and operations return a new object.
  • Compared by value: equals/hashCode use all the properties.
  • Validates its invariants on creation: an Address without a city or a Money with a mismatched currency cannot exist.

Since Java 16, a record gives you all three almost for free: final fields, generated equals/hashCode/toString, and a compact constructor for validation. Creating a Value Object then costs only a few lines. This is also where the earlier article on the Builder pattern: from Effective Java to Lombok and records meets DDD.

Value Object example: Money

Money is the classic Value Object. Why not use double? Run it and see (file plain/Main1.java):

0.1 + 0.2 (double) = 0.30000000000000004
0.1 + 0.2 (BigDecimal) = 0.3
new BigDecimal(0.1) = 0.1000000000000000055511151231257827021181583404541015625

BigDecimal fixes the binary rounding error, but there is another trap: BigDecimal.equals compares the scale as well.

2.0 equals 2.00    : false
2.0 compareTo 2.00 : 0
HashSet contains 2.00? : false

This means 150000 and 150000.00 count as two different values in a Set and a Map, even though the amount is the same. The Money Value Object solves it by normalizing the scale in the constructor according to Currency.getDefaultFractionDigits() (0 for VND, 2 for USD):

public record Money(BigDecimal amount, Currency currency) {
    public Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        int scale = Math.max(currency.getDefaultFractionDigits(), 0);
        if (amount.stripTrailingZeros().scale() > scale) {
            throw new IllegalArgumentException("Amount has more than " + scale + " decimal places: " + amount);
        }
        amount = amount.setScale(scale, RoundingMode.UNNECESSARY);
    }

    public static Money vnd(long value) { return new Money(BigDecimal.valueOf(value), Currency.getInstance("VND")); }
    public static Money usd(String value) { return new Money(new BigDecimal(value), Currency.getInstance("USD")); }

    public Money plus(Money other) {
        requireSameCurrency(other);
        return new Money(amount.add(other.amount), currency);
    }
    public Money times(int quantity) {
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }
    private void requireSameCurrency(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("Currency mismatch: " + currency + " and " + other.currency);
    }
    @Override public String toString() { return amount.toPlainString() + " " + currency.getCurrencyCode(); }
}

The code above has every method needed to run the example below (file code/s6-23/plain/Money.java, plus the usual import lines for java.math, java.util.Currency and java.util.Objects). When used with JPA in the next section, the mapped version adds @Embeddable to this record.

Real run output:

150000 VND equals 150000 VND : true, same hashCode: true
150000 VND == 150000.00 VND : true
plus : 200000 VND
times: 450000 VND
Error: Currency mismatch: VND and USD
Error: Amount has more than 0 decimal places: 10.5
USD 12.5 -> 12.50 USD

The benefits are immediate: you cannot add VND to USD, you cannot create a VND amount with a fraction, and 150000 equals 150000.00. These rules live in exactly one place instead of being scattered across every service. For real money, consider adding an explicit rounding rule for division (for example, splitting a bill), because RoundingMode.UNNECESSARY only suits addition and multiplication by integers.

Two more Value Objects: Address and OrderLine

A shipping address is a Value Object with no identity: changing the street changes the whole value. An order line is also a Value Object, because we do not care about "line number 3 of the order" as a separate thing, only about the SKU, the quantity and the unit price:

@Embeddable
public record Address(String street, String city, String phone) {
    public Address {
        if (street == null || street.isBlank()) throw new IllegalArgumentException("Street is required");
        if (city == null || city.isBlank()) throw new IllegalArgumentException("City is required");
        if (phone == null || !phone.matches("0\\d{9}"))
            throw new IllegalArgumentException("Invalid phone number: " + phone);
    }
}

@Embeddable
public record OrderLine(String sku, int quantity, Money unitPrice) {
    public OrderLine {
        if (sku == null || sku.isBlank()) throw new IllegalArgumentException("SKU is required");
        if (quantity <= 0) throw new IllegalArgumentException("Quantity must be > 0");
    }
    public Money subtotal() { return unitPrice.times(quantity); }
}

The expression 0\d{9} is only an illustration: real phone numbers have many prefixes and international formats, so use a dedicated library when you need accuracy.

Aggregate: one entry point, one consistency boundary

According to the Aggregate definition on martinfowler.com, an Aggregate is a cluster of domain objects treated as a single unit, for example an order together with its line items. One object is the root; every reference from the outside may point only at the root, and a transaction should not span several Aggregates. Four practical design rules follow:

  • Protect invariants inside the root: every change goes through a root method (addLine, confirm), with no public setters.
  • Never expose a mutable collection: getLines() returns a read-only view.
  • Reference other Aggregates by id: Order holds a customerId rather than a Customer object, so loading and transaction boundaries stay clear.
  • Keep Aggregates small: include only what must be consistent together immediately.
@Entity @Table(name = "orders")
public class Order {
    static final int MAX_LINES = 5;
    @Id private UUID id;
    private Long customerId;
    @Enumerated(EnumType.STRING) private OrderStatus status;
    @Embedded private Address shipTo;

    @ElementCollection(fetch = FetchType.EAGER)
    @CollectionTable(name = "order_lines", joinColumns = @JoinColumn(name = "order_id"))
    private List<OrderLine> lines = new ArrayList<>();

    public void addLine(String sku, int quantity, Money unitPrice) {
        requireStatus(OrderStatus.DRAFT, "add a line");
        if (lines.size() >= MAX_LINES) throw new IllegalStateException("An order has at most " + MAX_LINES + " lines");
        lines.add(new OrderLine(sku, quantity, unitPrice));
    }

    public void confirm() {
        requireStatus(OrderStatus.DRAFT, "confirm");
        if (lines.isEmpty()) throw new IllegalStateException("Cannot confirm an empty order");
        status = OrderStatus.CONFIRMED;
    }
    public List<OrderLine> getLines() { return Collections.unmodifiableList(lines); }
}

Here is a real run of a scenario that deliberately tries to break the invariants; each [blocked] line is a violation the root rejected:

[blocked] Confirm an empty order -> IllegalStateException: Cannot confirm an empty order
Total: 620000 VND
[blocked] Modify the line list from outside -> UnsupportedOperationException: null
[blocked] Quantity 0 -> IllegalArgumentException: Quantity must be > 0
[blocked] Wrong phone number -> IllegalArgumentException: Invalid phone number: 123
[blocked] Add a line after confirmation -> IllegalStateException: Cannot add a line when the order is in status CONFIRMED
[blocked] 6th line -> IllegalStateException: An order has at most 5 lines

The lifecycle DRAFT → CONFIRMED → SHIPPED is really a small state machine; as the number of states and transitions grows, split it out as described in State pattern and an order state machine.

Mapping a Value Object to JPA with @Embeddable

According to the Hibernate 6.5 documentation, an embeddable maps its state into several columns of the owning table, has no identity of its own, and shares the lifecycle of the entity that contains it; and a Java record can be an embeddable without needing an empty constructor. In a real run, Hibernate generates this DDL:

create table order_lines (amount numeric(38,2), currency varchar(3), quantity integer, order_id uuid not null, sku varchar(255))
create table orders (customer_id bigint, id uuid not null, city varchar(255), phone varchar(255), street varchar(255), status enum ('CONFIRMED','DRAFT','SHIPPED'), primary key (id))

The Address Value Object is "flattened" into three columns, street, city and phone, in the orders table. Money, nested inside OrderLine, becomes two columns, amount and currency. The list of OrderLine Value Objects, stored through @ElementCollection, sits in the order_lines table with no primary key of its own, which reflects the "no identity" nature exactly. Save and load it back:

insert into orders (customer_id,street,city,phone,status,id) values (?,?,?,?,?,?)
insert into order_lines (order_id,sku,quantity,amount,currency) values (?,?,?,?,?)
Status: CONFIRMED, total: 620000 VND, address: Address[street=12 Tran Duy Hung, city=Ha Noi, phone=0901234567]
Lines : [OrderLine[sku=AO-01, quantity=2, unitPrice=250000 VND], OrderLine[sku=NON-05, quantity=1, unitPrice=120000 VND]]

Two points to weigh when you store a Value Object with @ElementCollection. First, when the list changes, Hibernate usually deletes all of the Aggregate's rows and inserts them again; that is acceptable for a small Aggregate like an order but should not be applied to a collection of thousands of elements. Second, because the amount field is mapped to numeric(38,2) by default, declare @Column(precision, scale) explicitly if you need to match an existing schema.

How the JDK and frameworks use Value Objects

You already use Value Objects every day without naming them: String, Integer, BigDecimal, LocalDate and Duration in java.time are all immutable and compared by value. The java.time Javadoc describes these classes as value-based, meaning you should compare them with equals and should not use them as synchronization locks. Operations that return a new instance (date.plusDays(1)) are exactly the style that Money.plus follows. In Spring, JPA's @Embeddable, Lombok's @Value and Java's record all serve the same idea.

When not to use a Value Object and tactical DDD

DDD is not free. Situations to reconsider:

  • Plain CRUD: lookup tables and configuration with no business rules are fine with an anemic model.
  • Reporting and read queries: loading a whole Aggregate just to display a list is wasteful; use a projection or a separate read query.
  • Oversized Aggregates: putting Customer, every Order and every Payment into one Aggregate causes concurrent write conflicts and heavy loads. Split along real consistency boundaries. For an Aggregate shared among many users, add a @Version field on the Aggregate Root so JPA enables optimistic locking: when two transactions edit the same order, the later one gets an OptimisticLockException instead of silently overwriting (this is a standard JPA mechanism, not run in this article's demo).
  • Aggregate boundaries depend on context: the same concept can be an Entity in one context and a Value Object in another. Evans notes that Address is a Value Object for a sales system but an Entity for a power company that must track each address. Do not apply the rules mechanically; ask the business.
  • A team without a shared language with the business: the biggest value of DDD is the ubiquitous language, not the annotations.

Common mistakes

  • A mutable Value Object: adding a setter to Address brings back the aliasing bug Fowler describes.
  • An Entity using equals on every field: two copies with the same id but different attributes are treated as different, which breaks Set and caches.
  • A database-generated id that changes hashCode: assign the id early (UUID, ULID) or use a dedicated convention.
  • Exposing a mutable collection: returning the original List lets outside code bypass every check. Use Collections.unmodifiableList or List.copyOf.
  • An Aggregate holding an object of another Aggregate: it drags in hard-to-control lazy loading and overlapping transactions; hold the id instead.
  • Validating only at the API layer: the invariants of a Value Object belong in the domain so batch jobs and message consumers are protected too.

Value Object and Aggregate design checklist

  • A concept that needs only a value (money, address, date range) becomes a Value Object built with a record; each Value Object validates itself.
  • Validate in the compact constructor so an invalid value cannot exist.
  • Normalize so equals reflects the intended meaning (for example, the scale of a BigDecimal).
  • Compare Entities by id, and assign the id at creation time.
  • Every Aggregate change goes through the root; no public setters; returned collections are read-only.
  • Keep Aggregates small, reference each other by id, and use one transaction per Aggregate.
  • Use @Embeddable and @ElementCollection for Value Objects in JPA mapping, and inspect the generated DDL.

In short, a Value Object turns vague primitive types into self-protecting domain concepts, and an Aggregate groups those concepts into a consistent unit with a single entry point. Neither needs a special framework: Java 21 and JPA are enough.

Next article

Once the domain protects itself, the next question is where to place it in the application architecture so that the framework and the database do not pull it off course. Continue with Hexagonal Architecture with Spring Boot: ports and adapters.