Value Objects, Entities, and Aggregates

Learn how identity, value equality, and aggregate boundaries shape a reliable order model, with Java code, trade-offs, failure cases, and design checks.

published: reading time: 14 min read author: GeekWorkBench
Quick Summary

Value objects represent concepts defined by their data, while entities retain identity as their state changes. In the Java order example, immutable Money and OrderLine values support a mutable Order aggregate that guards submission and currency rules. The article explains how to choose these models, define aggregate boundaries, handle cross-aggregate coordination, and avoid common design failures.

Value Objects, Entities, and Aggregates

Introduction

A domain model gets easier to change when its objects reflect how the business talks about the work. In an order system, an order has a continuing identity, its total is a value, and the rules for adding a line belong somewhere that can protect the order as a whole. Domain-driven design gives names to these differences: entities, value objects, and aggregates.

Those names are useful when they help answer real questions. Does this object need to be recognized as the same thing after an edit? Can it be replaced by another object with the same data? Which rule must remain true when a command changes the model? We’ll use a small Java order example to answer them.

Start with the business meaning

Consider an online shop. A customer places an order with one or more lines. Each line contains a product identifier, quantity, and price captured at purchase time. An order can move from draft to submitted, but a submitted order cannot have its lines edited.

The customer, order, and line do not have to share one object lifetime. The order needs an identifier because people and systems refer to it over time. A line can be modeled as a value if nobody needs to address that line independently. The total is a value computed from the lines, not another entity just because it appears on a screen.

flowchart LR
    Command[Add line command] --> Root[Order aggregate root]
    Root -->|owns and validates| Lines[Order lines]
    Lines -->|contain| Money[Money values]
    Root -->|references by ID| Customer[Customer ID]
    Root -->|protects| Rules[Non-empty submitted order]

The diagram shows a model boundary, not a required table layout. An ORM may store the order and its lines in separate tables, while a document database may store them together. Neither storage choice defines the domain aggregate by itself.

Identity and value equality

An entity is identified by continuity. If order ORD-481 changes its shipping address, it remains the same order. Its mutable state can differ at two points in time while its identity remains stable. Equality for an entity therefore usually compares its identity, not every field.

A value object has no identity that matters to the domain. Two Money values with the same amount and currency represent the same value. If a line’s quantity changes, the model can replace one OrderLine value with another. Code that stores a reference to the old line should not assume that it represents a durable business identity.

This distinction depends on the domain, not on the class name. A customer may be an entity in one system, while a customer address can be a value object. A reservation slot might be identified and tracked independently in a scheduling product; in a simple order checkout, a product-and-quantity line may only be a value. In Java, reference equality is not automatically domain equality: a production entity can define equality around its identifier, with care around when that identifier becomes available.

Immutability makes values easier to trust

Value objects are good candidates for immutability. A Money object can validate its currency and amount at construction, then expose no operation that changes either field. Callers can pass it around without wondering whether another part of the program will mutate it later.

Immutability is a design tool, not a requirement that every entity be frozen. An order can change status through methods that enforce transition rules. A mutable entity still needs encapsulation: callers should not be able to set status = SUBMITTED and bypass the checks that submission requires.

A small Java order model

This Java 17 sketch uses records for immutable values. Order is the aggregate root: callers use its methods to add a line or submit the order. The example leaves persistence and payment out of the model so the invariants stay visible.

import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.UUID;

record Money(BigDecimal amount, String currency) {
    Money {
        Objects.requireNonNull(amount, "amount");
        Objects.requireNonNull(currency, "currency");
        if (amount.signum() < 0) {
            throw new IllegalArgumentException("amount cannot be negative");
        }
        if (!currency.matches("[A-Z]{3}")) {
            throw new IllegalArgumentException("currency must be an ISO-style code");
        }
    }

    Money plus(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException("currencies must match");
        }
        return new Money(amount.add(other.amount), currency);
    }

    Money times(int quantity) {
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }
        return new Money(amount.multiply(BigDecimal.valueOf(quantity)), currency);
    }
}

record OrderLine(String productId, int quantity, Money unitPrice) {
    OrderLine {
        Objects.requireNonNull(productId, "productId");
        Objects.requireNonNull(unitPrice, "unitPrice");
        if (quantity <= 0) {
            throw new IllegalArgumentException("quantity must be positive");
        }
    }

    Money subtotal() {
        return unitPrice.times(quantity);
    }
}

final class Order {
    enum Status { DRAFT, SUBMITTED }

    private final UUID id;
    private final String customerId;
    private final String currency;
    private final List<OrderLine> lines = new ArrayList<>();
    private Status status = Status.DRAFT;

    Order(UUID id, String customerId, String currency) {
        this.id = Objects.requireNonNull(id, "id");
        this.customerId = Objects.requireNonNull(customerId, "customerId");
        if (currency == null || !currency.matches("[A-Z]{3}")) {
            throw new IllegalArgumentException("currency must be an ISO-style code");
        }
        this.currency = currency;
    }

    UUID id() { return id; }
    String customerId() { return customerId; }
    Status status() { return status; }
    List<OrderLine> lines() { return List.copyOf(lines); }

    void addLine(OrderLine line) {
        requireDraft();
        Objects.requireNonNull(line, "line");
        if (!currency.equals(line.unitPrice().currency())) {
            throw new IllegalArgumentException("line currency must match order currency");
        }
        lines.add(line);
    }

    Money total() {
        return lines.stream()
                .map(OrderLine::subtotal)
                .reduce(new Money(BigDecimal.ZERO, currency), Money::plus);
    }

    void submit() {
        requireDraft();
        if (lines.isEmpty()) {
            throw new IllegalStateException("cannot submit an empty order");
        }
        status = Status.SUBMITTED;
    }

    private void requireDraft() {
        if (status != Status.DRAFT) {
            throw new IllegalStateException("submitted orders cannot be changed");
        }
    }
}

A few modeling choices are deliberate. OrderLine has structural equality because its product, quantity, and price define it here. Order has stable identity through id. The order returns a copy of its line list, so callers cannot mutate the collection behind its back. The order establishes its currency up front and rejects lines priced in another currency. A real system may use a richer currency type and define when exchange rates are applied.

The aggregate root protects the rules for changes that pass through it. That protection only works if application code cannot load a mutable child and update it around the root. Keep child references private, route commands through the root, and make persistence mapping respect that model boundary.

Aggregate boundaries and invariants

An aggregate is a cluster of domain objects that the model treats as one consistency boundary. The root is the public entry point for changes to that cluster. Here, Order owns its lines and ensures that a submitted order is non-empty and cannot be edited afterward.

A boundary should follow invariants that must be true together. If a checkout command must never submit an empty order, checking that rule at the root makes the boundary clear. If an inventory reservation can be updated independently, it may belong to another aggregate. The order can refer to a customer or inventory item by identifier instead of pulling the entire object into its own boundary.

An aggregate is not a synonym for a database row, table, document, or transaction. It is a modeling decision about consistency. One aggregate can map to several tables, and a document can contain data that does not share one domain invariant. A common DDD rule of thumb keeps one transaction within one aggregate; an application can coordinate multiple aggregates in one transaction when it has a clear reason, but that choice increases coupling and contention. Many systems handle cross-aggregate work with explicit workflows and eventual consistency.

For more on assigning behavior to the object with the information to enforce a rule, see GRASP: Assigning Object Responsibilities. The broader design roadmap also links this topic to SOLID principles in practice and hexagonal architecture.

When to Use

Use value objects when a concept is defined by its data and you want validation or behavior to travel with that data. Money, date ranges, postal addresses, and measurements are common examples. Prefer immutable values when replacement is clearer than mutation.

Use entities when the business needs to distinguish one instance from another over time. Orders, users, subscriptions, and bookings often have identifiers because other workflows refer to them even after their attributes change.

Use an aggregate when a group of objects has rules that need a clear owner and consistency boundary. Start with the smallest boundary that protects those rules. Add a child to the aggregate when it must change as part of the same business operation, not just because the objects are related in a diagram.

When NOT to Use

Do not create a value-object class for every primitive if the type adds no rule, meaning, or useful constraint. CustomerId may improve correctness at a boundary; wrapping a local counter that has no risk of being confused may add noise.

Do not turn every object graph into one large aggregate. If changing an order must lock a customer, every inventory item, the warehouse, and the payment record, unrelated work will contend on one boundary and the model will be hard to operate.

Do not force all consistency through a domain object when a simple data structure and transaction already express the rule clearly. DDD patterns help with domain complexity; they are not a prerequisite for ordinary CRUD screens.

Production Failure Scenarios

Failure What caused it Better response
A submitted order loses a line after another endpoint edits it A child collection escaped the aggregate root Return immutable views and route mutations through the root
Two concurrent commands both pass a stock check The invariant depended on inventory state outside the order boundary Make inventory ownership explicit and use an atomic reservation, version check, or coordination workflow
A price changes after checkout and rewrites old receipts The order stored a product reference instead of the agreed purchase price Snapshot the price and currency on the line, with a clear correction policy
A retry charges twice after a timeout Payment side effects were treated like ordinary in-process state changes Use idempotency keys and a durable payment workflow; do not assume aggregate methods make external calls atomic

Trade-Off Table

Modeling choice Helps when Cost or limit
Immutable value object Validation and equality should follow the value Replacing values can be verbose for large structures
Entity identity Other operations must refer to the same instance over time Identity and lifecycle rules need careful ownership
Small aggregate A short set of invariants must hold together Rules spanning boundaries need coordination
Larger aggregate One operation truly requires strong consistency across its members Contention, loading cost, and change coupling grow
Reference another aggregate by ID The objects have separate lifecycles or update rates Callers need a lookup and must handle missing or stale references

Observability Checklist

  • Record order ID, command type, outcome, and a correlation ID for state-changing operations.
  • Count rejected transitions, such as attempts to edit a submitted order, without logging full customer or payment data.
  • Track optimistic-lock conflicts or reservation retries if the persistence strategy uses them.
  • Measure aggregate load and save latency when an order grows in size.
  • Keep business events such as OrderSubmitted distinct from low-level database updates.
  • Alert on stuck checkout workflows and duplicate payment attempts, not on every valid domain rejection.

Security and Compliance Notes

An aggregate boundary is not an authorization boundary by itself. Check that the authenticated actor may access the requested order before loading or mutating it, and scope lookups by tenant when the system is multi-tenant.

Avoid putting card numbers, secrets, or unnecessary personal data in value objects that are copied into logs or events. Store only the payment provider token or reference needed by the order workflow, and keep sensitive payment data in a properly scoped payment system. Apply retention and deletion rules to snapshots such as addresses; a historical price may need to persist while a personal delivery address may not.

Validate identifiers and quantities at system boundaries, then enforce domain invariants again inside the model. Boundary validation rejects malformed requests; domain checks protect correctness when the model is called from another path.

Common Pitfalls and Anti-Patterns

  • Primitive obsession: Passing BigDecimal, a three-letter string, and a separate currency string everywhere makes invalid combinations easy. Introduce a value type where it carries a real rule.
  • Mutable value objects: Changing a shared Money or Address instance can alter unrelated state. Prefer immutable values and replace them explicitly.
  • Identity by every field: Comparing entity fields can make an order appear to become a different entity after an address or status change.
  • Anemic aggregate root: A root with public setters cannot guard its invariants. Give it intention-revealing methods such as submit() or changeDeliveryAddress().
  • Aggregate as storage shape: A table or document boundary may be influenced by infrastructure. It is not evidence that the domain has one consistency boundary.
  • Oversized aggregates: Loading and locking every related object for a single operation creates contention. Split independently changing concepts and coordinate between them.
  • Hidden cross-aggregate mutation: A method on Order that changes InventoryItem blurs ownership. Use an application workflow or domain service to coordinate separate roots.

Quick Recap Checklist

  • Does this concept need a stable identity that survives changes?
  • If two instances have equal attributes, are they the same business value?
  • Can this value be immutable and validate itself when created?
  • Which invariant must remain true after this command?
  • Does one aggregate root own the changes needed to protect that invariant?
  • Are independent aggregates referenced by ID and coordinated explicitly?
  • Does the persistence mapping reflect, rather than dictate, the model?

Interview Questions

1. How do you decide whether a concept should be an entity or a value object?

Ask whether the domain needs to recognize the same instance over time. If changing attributes must leave the object's identity intact, model an entity. If the attributes fully define the concept and equal values are interchangeable, model a value object. The answer can vary by bounded context.

2. What does an aggregate root guarantee?

The root provides the supported path for commands that change members of its aggregate and checks the invariants owned by that boundary. It does not automatically guarantee distributed atomicity, authorization, or that unrelated aggregates update together.

3. Why should an aggregate not be equated with a database document?

An aggregate describes domain consistency and ownership. Storage mapping is an infrastructure decision: one aggregate can span several relational tables, and a document can contain data that does not share one domain invariant. Let the model and persistence design inform each other without treating them as identical.

Further Reading

Conclusion

Entities answer “which one is this?” across changes. Value objects answer “what value does this represent?” and often work best as immutable types. Aggregates give related objects a root that protects a small set of invariants. In the order example, Order owns line changes and submission rules, while customer and inventory concepts can keep their own lifecycles.

The useful test is concrete: name the business rule, identify which command can break it, and give that rule a clear owner. If the proposed boundary grows to include concepts that change independently, revisit it. A clear model should make valid changes easy and invalid transitions difficult.

Category

Related Posts

Discovering Domain Concepts and Behaviors

Turn use cases and domain language into a small, behavior-rich model. Learn how to find candidates, protect invariants, and avoid making every noun a class.

#object-oriented-design #domain-modeling #design

GRASP: Assigning Object Responsibilities

Learn all nine GRASP patterns through a Java order workflow, with code, trade-offs, failure cases, and design review questions for intermediate developers.

#object-oriented-design #grasp #responsibility-assignment

Behavioral Patterns: Organize Collaboration

Compare all eleven GoF behavioral patterns by the change they isolate, the coupling they reduce, and the runtime costs they add to a system in real code.

#design-patterns #object-oriented-design #behavioral-patterns