Skip to content

    System Design

    DDD Tactical Patterns: Aggregates, Entities and Value Objects

    Model an order with a DDD aggregate root, value objects, entities and domain events in Java 25, and run tests that show what breaks when the rules are bypassed.

    By Published 12 min read

    Share

    Standard lessonadvancedJava labBackend System Design

    Before this: Domain-Driven Design and Bounded Contexts

    What you'll learn: By the end of this lesson you will be able to tell an entity from a value object, explain what an aggregate and its root are for, pick an aggregate boundary from the invariants it must protect, keep money as integer minor units in a Java record, record domain events from the root and handle them with a sealed type, and run a lab that proves the invariants hold and shows what the same rules look like when callers can walk around them.

    A restaurant kitchen has one expediter. Waiters hand over notes ("table 4 wants no onions"), but only the expediter edits the ticket and checks that it makes sense. If every waiter and cook could scribble on every ticket, it would soon say two contradictory things and nobody could tell who changed what. The expediter is the single door through which the ticket changes.

    That is the idea behind the tactical patterns of domain-driven design (DDD). The strategic half, bounded contexts and context maps, is in the ShopStream services stage; the dedicated bounded-context lesson is planned and has no page yet. This lesson is the code-level half: how the objects inside one context are shaped so the rules of the business cannot be broken by accident.

    Verified on 2026-11-08 with the Java 25 language level (the lab compiles with --release 25; the captured output records JDK 25.0.3) and the sources listed under Official documentation.

    The core idea

    An aggregate is a cluster of objects that change together under one root, and the root is the only door. Outside code asks the root to do things; the root checks the rules and either changes everything consistently or changes nothing. A rule that must always be true, called an invariant, lives inside that boundary and is enforced there, not scattered across the services that call it.

    Three examples run through the lesson:

    • Minimal example: a Money record and a tiny Order aggregate, the lab in "Build it".
    • Realistic example: ShopStream's Order next to its Inventory, in "In practice".
    • Failure example: the same rules in a service over public fields, and a caller that walks around it.

    The building blocks

    Eric Evans's DDD Reference summarises each pattern in a few lines. Two decide most designs.

    Entity Value object
    Defined by Identity: an object "distinguished by its identity, rather than its attributes" Its attributes: "you care only about the attributes and logic of an element"
    Two with the same attributes Still two things The same thing, interchangeable
    Changes Its state changes over its life while it stays itself Never changes: "treat the value object as immutable"; operations return a new value
    ShopStream examples Order, an order line Money, Quantity, Sku, an address
    Java shape A class with an id A record

    Martin Fowler's note on value objects gives the practical reason for immutability: if two places share one mutable object, a change in one has consequences you did not expect in the other, and creating a new object instead removes that class of bug.

    Value objects as records

    A Java record is a natural fit. The Java 25 documentation says the compiler generates the accessors, equals, hashCode and toString, that two records are equal when they are of the same type and hold equal component values, that a record is implicitly final, and that a compact constructor runs your checks before the components are assigned to the fields. Validation in the compact constructor means an invalid value cannot exist:

    public record Money(Currency currency, long minorUnits) {
    
        public Money {
            Objects.requireNonNull(currency, "currency");
            if (currency.getDefaultFractionDigits() < 0) {
                throw new DomainRuleViolation("currency " + currency.getCurrencyCode() + " has no minor unit");
            }
            if (minorUnits < 0) {
                throw new DomainRuleViolation("amount must not be negative: " + minorUnits);
            }
        }

    Money holds the amount as a long count of the currency's minor unit (euro cents), never as a double. The lab prints the reason: in double arithmetic 0.10 + 0.20 == 0.30 is false (the sum is 0.30000000000000004), while Money adds 10 and 20 cents exactly. Addition uses Math.addExact, so an overflow throws instead of wrapping. A record holding a mutable object is only as immutable as that object, so value objects should hold values: numbers, strings, other records.

    The lab's Quantity (1 to 99 units) and Sku (3 to 20 characters of A to Z, 0 to 9 and dash) follow the same pattern, so each rule lives where its concept is born. The limits are illustrative.

    The aggregate and its root

    Evans's aggregate pattern, in outline: define a boundary around each cluster of entities and value objects; choose one entity as the root and let outside objects hold references to the root only; give the root responsibility for the invariants of the whole; use the same boundaries for transactions and distribution; apply consistency rules synchronously inside a boundary and handle updates across boundaries asynchronously. Fowler's one-line consequence: "Transactions should not cross aggregate boundaries."

    The Order aggregate: one door, one transactionCheckout service to Order root (addLine, place, pay); Order root to Order lines (changes); Order root to Money, Quantity, Sku (builds); Order root to Pending events (records); Order root to Shopper (holds the id only)ORDER AGGREGATECheckout service: commands onlyCheckoutservicecommands onlyOrder root: checks every ruleOrder rootchecks every ruleOrder lines: entities, one per SKUOrder linesentities, one perSKUMoney, Quantity, Sku: immutable recordsMoney,Quantity, Skuimmutable recordsPending events: recorded after successPending eventsrecorded aftersuccessShopper: another aggregateShopperanother aggregateaddLine, place, paychangesbuildsrecordsholds the id only

    Figure 1. What sits inside the boundary. How to read this: the dashed zone is the consistency boundary. The caller reaches only the root. The lines are changed only by the root, and the Order refers to a Shopper by id, never by object.

    Choosing the boundary from the invariants

    Vaughn Vernon's essay "Effective Aggregate Design" gives the test. An invariant is "a business rule that must always be consistent", meaning transactional consistency, immediate and atomic. His rules: model true invariants in consistency boundaries, and design small aggregates, because large clusters built for convenience limit performance and scalability. A use case that seems to demand one transaction can often be met with eventual consistency between aggregates.

    flowchart TD accTitle: Should two things be in one aggregate? accDescr: A decision flow. If a rule must hold at every instant across both objects, they belong in one aggregate. If the business can tolerate a short delay, keep them as separate aggregates that refer to each other by id and react to events. Q["Two objects change in the same use case"] --> A{"Is there a rule that must be true at every instant across both?"} A -->|"yes: a true invariant"| ONE["One aggregate, one root, one transaction"] A -->|"no: a delay is acceptable"| B{"Can the second change react to an event from the first?"} B -->|"yes"| TWO["Two aggregates, refer by id, update asynchronously"] B -->|"no, redraw the model"| R["Reconsider the boundary, or the use case"]

    Figure 2. A boundary test. How to read this: start with the invariant, not the object graph. Showing stock on the order screen is a convenience, not an invariant.

    Enforcing invariants in the root

    The lab's Order protects these rules (all illustrative):

    Invariant Where it is enforced
    Lines change only while the order is a draft requireStatus at the top of addLine and removeLine
    One line per SKU; the same SKU at another price is refused addLine
    At most 20 lines, 1 to 99 units per line addLine and Quantity
    Every price is in the order's currency requireCurrency
    The total equals the sum of the lines total() computes it on every call, so it is never stored and cannot drift
    Placed only with a line; paid only when placed and for the exact total; cancelled only before payment place, pay, cancel

    The total row is Vernon's c = a + b example: the easiest way to keep a derived value consistent is not to store it. Two habits make the root safe: check everything that can fail before changing anything, and hand out no internals. addLine begins like this:

    public void addLine(Sku sku, Quantity quantity, Money unitPrice) {
        requireStatus(Status.DRAFT, "add a line");
        requireCurrency(unitPrice);
        // Everything that can fail is checked before anything changes.
        ensureTotalCanGrowBy(unitPrice.times(quantity.value()));

    OrderLine has a package-private constructor and setQuantity, and lines() returns List.copyOf(...), an unmodifiable snapshot, so code outside the package can read a line but not change it.

    stateDiagram-v2 accTitle: Order status as a state machine accDescr: An order starts as a draft. Place moves it to placed, pay moves it to paid. Cancel is allowed from draft or placed, and a paid or cancelled order accepts no further commands. [*] --> DRAFT DRAFT --> PLACED: place (at least one line) PLACED --> PAID: pay (exact total) DRAFT --> CANCELLED: cancel (with reason) PLACED --> CANCELLED: cancel (with reason) PAID --> [*] CANCELLED --> [*]

    Figure 3. The lifecycle the root enforces. How to read this: each arrow is a command with its guard in brackets. Any command not drawn from a state, such as adding a line to a placed order, is refused.

    Domain events and sealed types

    Evans describes a domain event as a representation of something that happened in the domain that experts care about; it is ordinarily immutable because it records the past, and typically carries a timestamp and the identities involved. Events are also how aggregates talk across a boundary.

    In the lab the root records an event only after a command succeeds, and the caller collects them with pullEvents(), which returns the list and clears it. The events are records under a sealed interface:

    public sealed interface OrderEvent permits OrderPlaced, OrderPaid, OrderCancelled {
        OrderId orderId();
        record OrderPlaced(OrderId orderId, Money total, int lineCount) implements OrderEvent { }
        record OrderPaid(OrderId orderId, Money amount) implements OrderEvent { }
        record OrderCancelled(OrderId orderId, String reason) implements OrderEvent { }
    }

    JEP 409 says a sealed type may be extended only by the types it permits, records are implicitly final so they fit, and a pattern-matching switch over a sealed type needs no default: the compiler reports an error if a case is missing. The Demo's handler relies on that:

    static String describe(OrderEvent event) {
        return switch (event) {
            case OrderPlaced(OrderId id, Money total, int lines) ->
                    "OrderPlaced " + id.value() + " total " + total + " in " + lines + " lines";
            case OrderPaid(OrderId id, Money amount) -> "OrderPaid " + id.value() + " amount " + amount;
            case OrderCancelled(OrderId id, String reason) -> "OrderCancelled " + id.value() + " because " + reason;
        };
    }

    Add a fourth event and every such switch stops compiling until it handles it. The lab's events carry no timestamp so its output stays deterministic.

    sequenceDiagram accTitle: A command, its events and their publication accDescr: The checkout service asks the application service to place an order. The application service loads the Order, calls place, saves it, and only then publishes the recorded events to other contexts. participant C as Checkout service participant A as Application service participant O as Order aggregate participant S as Order store participant B as Event publisher C->>A: place order o-1001 A->>S: load order S-->>A: Order A->>O: place() O-->>A: ok, OrderPlaced recorded A->>S: save Order (one transaction) A->>O: pullEvents() O-->>A: [OrderPlaced] A->>B: publish OrderPlaced

    Figure 4. One command, one transaction, then the events. How to read this: the transaction covers one aggregate. Publishing happens after the save; making save and publish agree is the outbox problem in the order pipeline stage.

    Build it

    Goal. See the invariants hold for every caller, and see the same rules fail when the state is public.

    Setup. From the repository's examples/backend folder:

    cd examples/backend && ./gradlew :labs:order-aggregate:check
    ./gradlew -q :labs:order-aggregate:run

    The first command runs 27 tests (23 on the aggregate, 4 on the bypass). The second prints the walk-through below. The code is under examples/backend/labs/order-aggregate/src/main/java/buildingai/labs/orderaggregate/: domain/ holds Order, OrderLine, the value objects and the sealed events, and anemic/ holds the contrasting design.

    How it works. The tests live outside the domain package on purpose, so they can use only the public API every other part of an application sees. BypassTest does the opposite: it uses the anemic AnemicOrder, public fields with the rules in an AnemicOrderService, and shows the states that service cannot prevent.

    Steps.

    1. Run the tests. They cover the value objects, each invariant in the table above, that a refused command changes neither state nor events, entity equality by id, that lines() cannot be modified, and, by reflection, that OrderLine has no public mutator or constructor.
    2. Run the demo and compare it with the output below.

    Expected output, captured from a real run (JDK 25.0.3, 2026-11-08):

    
    == 1. Money as a double and as integer minor units ==
    double: 0.10 + 0.20 == 0.30 is false (the sum is 0.30000000000000004)
    Money:  EUR 0.10 + EUR 0.20 equals EUR 0.30 is true
    value equality: Money.of("EUR", 129).equals(Money.of("EUR", 129)) is true
    
    == 2. The happy path through the aggregate ==
      line 1 MILK-1L x3 @ EUR 1.29
      line 2 BREAD-WHT x1 @ EUR 2.49
    total EUR 6.36, status DRAFT
    after place and pay: status PAID
      event: OrderPlaced o-1001 total EUR 6.36 in 2 lines
      event: OrderPaid o-1001 amount EUR 6.36
    events left after pullEvents: 0
    
    == 3. Commands the aggregate refuses (it is left unchanged each time) ==
    REJECTED  place an empty order: DomainRuleViolation: an order needs at least one line to be placed
    REJECTED  add 2 more of a line that holds 98: DomainRuleViolation: quantity must be between 1 and 99: 100
    REJECTED  quantity 0: DomainRuleViolation: quantity must be between 1 and 99: 0
    REJECTED  negative money: DomainRuleViolation: amount must not be negative: -1
    REJECTED  price in USD on a EUR order: DomainRuleViolation: price is in USD but the order is in EUR
    REJECTED  same SKU at a different price: DomainRuleViolation: price for EGGS-12 differs from the line already in the order
    REJECTED  blank SKU: DomainRuleViolation: sku must not be blank
    REJECTED  amounts that overflow a long: DomainRuleViolation: amount overflow adding EUR 90000000000000000.00 and EUR 90000000000000000.00
    REJECTED  add a line after placing: DomainRuleViolation: cannot add a line when the order is PLACED
    REJECTED  pay 1.00 for a 1.29 order: DomainRuleViolation: payment EUR 1.00 does not equal the order total EUR 1.29
    REJECTED  cancel a paid order: DomainRuleViolation: cannot cancel an order that is PAID
    REJECTED  change lines through the returned list: UnsupportedOperationException: null
    o-1002 still holds line 1 EGGS-12 x98 @ EUR 3.09 and total EUR 302.82
    
    == 4. The same abuse against the anemic design (rules in a service, state public) ==
    via the service: total 258, recomputed 258
    REJECTED  service: quantity 0: quantity must be between 1 and 99: 0
    direct field write, quantity = -5: total 258, recomputed -645  <- ACCEPTED, total no longer matches the lines
    direct field write, status = PAID on an order with 0 lines  <- ACCEPTED, status PAID
    add a line to a PLACED order through the list  <- ACCEPTED, 2 lines, stored total still 258
    Section What it shows
    1 A double cannot hold 0.10 + 0.20 exactly; integer cents can. Two Money values with equal components are equal.
    2 Adding milk twice merges into one line (x3). place and pay record two events, and pullEvents clears them.
    3 Twelve refused attempts, one per rule. The order holding 98 eggs is unchanged afterwards (x98): a refused command leaves no trace.
    4 The service refuses quantity 0, yet writing quantity = -5 into the field is accepted and the stored total (258) no longer matches the lines (-645). An order is marked paid with no lines; a line is added to a placed order.

    Break it. In Order.addLine, delete the requireStatus call: linesCannotChangeAfterPlacing fails. In Money, delete the minorUnits < 0 check: moneyCannotBeBuiltInvalid and the overflow test fail, because a wrapped-around sum is no longer caught. In OrderLine, make setQuantity public: aLineExposesNoPublicWayToChangeItself fails, which is the bypass caught by a test.

    Stretch. Add a LineQuantityChanged event and watch the Demo's switch stop compiling. Add a Clock so events carry a timestamp.

    What the model leaves out. Persistence, repositories, concurrency control, the outbox, real payment, refunds, and the Shopper aggregate behind ShopperId. The limits (20 lines, 99 units) are teaching numbers.

    Trade-offs

    Choice Gain Cost
    Rich aggregate (rules in the root) Rules cannot be skipped More domain code; callers use commands, not setters
    Anemic model (rules in services) Simple data classes, easy to map to tables Every caller must remember the rules (section 4 of the lab)
    Small aggregates Fewer conflicts, cheaper loads Cross-aggregate rules become eventual
    Large aggregates Cross-object rules are instant Contention and slow loads grow with the cluster (Vernon)

    In practice: keeping stock out of the Order

    This is the realistic example. It is an educational scenario on the course's running example, ShopStream, and nothing in it describes a real company. Figures are illustrative.

    This is a realistic scenario, not a case history.

    The situation

    ShopStream's checkout must not sell stock that is not there (data model stage). A developer proposes putting the stock count inside the Order aggregate so that placing an order and reserving stock share one transaction.

    Starting architecture

    Order and Inventory as two aggregates linked by idCheckout service to Order (place order); Order to Order events (records); Order events to Inventory (reserve stock); Order events to PaymentsORDERS CONTEXTINVENTORY CONTEXTCheckout serviceCheckoutserviceOrder: lines, total, statusOrderlines, total,statusInventory: stock per storeInventorystock per storeOrder events: OrderPlacedOrder eventsOrderPlacedPayments: other contextPaymentsother contextplace orderrecordsreserve stock

    Figure 5. Two aggregates, two transactions, joined by events. How to read this: each box is one consistency boundary. The Order is saved alone; the reservation reacts to the event, and a saga undoes the order if it fails.

    Options considered

    Option Behaviour Cost Risk
    A. Stock inside the Order One transaction for both Every order locks or loads stock rows The Order becomes a large aggregate; two contexts now share one model
    B. Two aggregates, linked by id and events Order saved first, stock reserved after A saga and a failure path A short window where an order exists before its reservation

    The decision

    Option B. No rule has to hold at every instant across an order and a stock count: "waiting for stock" is an acceptable state, and the order pipeline stage handles the failure with a saga. The true invariants (one line per SKU, total equals lines, exact payment) are all inside the Order, which keeps a ShopperId and SKUs, never Shopper or Product objects.

    Rollout and verification

    An application service per command loads one aggregate, calls one method, saves, and publishes the events afterwards. Verification is by invariant: no stored order has a quantity outside 1 to 99, and every paid order has a payment record. A database CHECK is a cheap backstop (illustrative, not run here):

    ALTER TABLE order_lines ADD CONSTRAINT quantity_range CHECK (quantity BETWEEN 1 AND 99);

    The 2 a.m. failure

    The page reads: paid orders are not shipping. A support script set status = 'PAID' directly in the table for 40 orders (illustrative) while repairing a payment mismatch. That is the bypass of the lab's section 4 in production: no command ran, so no OrderPaid event was recorded and the delivery context never heard of them. The check: list orders marked paid with no payment record or published event. The mitigation: redo the repair through the aggregate's pay command so events are produced. The lasting fix is a repair path that uses the same application services as the product, plus the constraints above.

    Architect's notes

    • Start from invariants, not nouns. A word in the glossary is not an aggregate; a rule that must always hold is.
    • One aggregate per transaction. If a use case needs two, reconsider the boundary before reaching for a distributed transaction.
    • Reference other aggregates by id, which keeps loads small and boundaries honest.
    • Do not model everything this way. A CRUD screen with no real rules does not need aggregates (architecture styles).

    Common mistakes

    • Public setters and exposed collections. Each one is a way around the root.
    • Building the aggregate from the database schema, so every foreign key becomes an object reference and the cluster grows without limit.
    • Using double or float for money. Use integer minor units or a decimal type with an explicit rounding rule.
    • Updating two aggregates in one transaction because the ORM allows it.
    • Letting scripts and admin tools write the tables directly.

    Key takeaways

    • An entity is defined by identity; a value object by its attributes, and it is immutable.
    • An aggregate is a consistency boundary with one root; choose it from true invariants and keep it small.
    • Records make good value objects: the compact constructor makes invalid values impossible.
    • Money belongs in integer minor units with exact, overflow-checked arithmetic.
    • Check every rule before changing state, so a refused command leaves nothing behind.
    • Sealed event types make the compiler check that every event is handled.
    • Rules kept in a service next to public state are advice; a caller can skip them.

    Practice exercises

    Exercise 1 (30 min). Run "Break it" three times. For each, name the invariant that broke and the test that caught it.

    Exercise 2 (45 min). Add a Discount value object (an integer percentage, 0 to 50) to total(), with a tested rounding rule.

    Exercise 3 (1 hour). For a food-delivery app, choose the aggregates for Order, Courier and Restaurant Menu. For each pair, say whether a true invariant spans them or an event can link them.

    Exercise 4 (2 hours). Add an OrderRepository with an in-memory implementation and a version number; write a test where two callers load the same order and the second save is refused.

    Official documentation

    Ask about this article

    Get answers grounded in this post. AI-generated — based on this article, and may be imperfect.

    Scaled AI Weekly

    Enjoyed this? Get more like it every Monday.

    Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.

    Get new articles by email

    Free. We email a confirmation link first, and you can unsubscribe from any email.

    Join engineers building production AI systems

    Comments