Fundamentals 16 min read

Why Aggregates Are So Hard in DDD: Using Invariants to Find Real Boundaries

The article explains that an aggregate is defined by the business invariants that must stay strongly consistent, shows how the aggregate root protects those rules, and provides concrete design principles—ID references, single‑aggregate transactions, small boundaries, validation tests, and common pitfalls—illustrated with an order domain example.

Yumin Fish Harvest
Yumin Fish Harvest
Yumin Fish Harvest
Why Aggregates Are So Hard in DDD: Using Invariants to Find Real Boundaries

Aggregate

An Order consists of Order (the root entity), OrderItem (internal entity), Money (value object) and Address (value‑object snapshot). The following invariants must always hold:

At least one OrderItem is present.

Total amount equals the sum of all item subtotals.

Paid or shipped orders cannot be modified arbitrarily.

Status transitions (cancel → pay → ship) must follow the defined flow.

Aggregate = a set of objects that must stay consistent as a whole; it is treated as a single unit from the outside.
Order Aggregate
├── Order          // aggregate root / entity
├── OrderItem      // internal entity
├── Money          // value object
└── Address        // value object

Thus an aggregate is a business‑consistency boundary, not a database table or a single class.

Aggregate Root

The aggregate root is the only public entry point. All external operations must go through it; internal objects are not directly accessible.

Aggregate root = the only public entry of an aggregate; all modifications must go through it.

Typical external actions and their correct root‑based implementations:

Adding an order item : wrong – order.getItems().add(item); correct – order.addItem(...) Changing quantity : wrong – orderItem.setQty(3); correct – order.changeItemQuantity(productId, 3) Paying : wrong – order.setStatus(PAID); correct – order.pay() Cancelling : wrong – order.setStatus(CANCELLED); correct – order.cancel() For example, order.changeItemQuantity(...) performs five steps:

Check whether the current status allows modification.

Validate the new quantity.

Update the corresponding OrderItem.

Recalculate the total amount.

Ensure the order remains valid after the change.

Invariant

Invariant = a business rule that must always be true.

In the order domain the following are invariants:

Total amount = sum of item subtotals.

Shipped orders cannot be cancelled.

Rules such as “add points after order placement” or “send SMS notification” are not invariants because they can be eventually consistent or may fail without corrupting the order.

Two Golden Design Principles

Principle 1: Use ID references between aggregates

Wrong: holding full objects inside another aggregate.

public class Order {
    private Member member;          // ❌ direct reference
    private List<Product> products; // ❌ direct reference
}

Correct: store only the root IDs.

public class Order {
    private MemberId memberId;      // ✅ reference by ID only
    // OrderItem stores productId + snapshot of name/price
}

Reasons:

Clearly defines the aggregate boundary; the order does not need the whole member or product aggregate.

Prevents the object graph from exploding (loading an order would otherwise pull in all members and their orders).

Snapshots (e.g., product name/price at order time) ensure later changes to the product do not affect historical orders.

Principle 2: One transaction modifies only one aggregate

Each business operation / database transaction should affect a single aggregate. Consistency across aggregates is achieved with domain events, not a giant transaction.

Why:

Large transactions lock many rows, leading to deadlocks and time‑outs under high concurrency.

Any failure rolls back the whole operation, hurting user experience.

Coupled services become a performance bottleneck.

Correct approach: the order service creates the Order aggregate and publishes an OrderPlacedEvent. Stock, member and coupon services listen to the event and update their own aggregates in independent transactions, allowing eventual consistency (e.g., points may arrive a second later).

Designing Small Aggregates

Guideline: “Make it as small as possible.” Larger aggregates cause slower loads, longer transactions and more concurrency conflicts.

Identify invariants that require strong consistency (e.g., total amount = sum of items).

Group the objects involved in those invariants into the same aggregate.

Objects not involved in strong‑consistency invariants become separate aggregates, linked by IDs and coordinated via events.

Typical e‑commerce aggregates:

Order – root Order; contains OrderItem, Address, Money; invariants: total amount and valid state transitions.

Product – root Product; contains Sku, Price; invariants: price non‑negative, product must be on‑sale to be purchasable.

Stock – root StockItem; invariant: available quantity ≥ 0 (no oversell).

Cart – root Cart; invariant: no duplicate items, quantity limits.

Member – root Member; invariant: points ≥ 0, level derived from points.

Verifying Aggregate Design

Verification 1: External code cannot bypass the root

class OrderTest {
    @Test
    void cannotModifyItemsFromOutsideAggregateRoot() {
        Order order = Order.place(memberId, lines, address);
        assertThrows(UnsupportedOperationException.class,
            () -> order.getItems().add(new OrderItem(...)));
    }
}

Verification 2: Invariants are automatically maintained by the root

class OrderTest {
    @Test
    void totalAmountIsRecalculatedAfterChangingItemQuantity() {
        Order order = Order.place(memberId, lines, address);
        order.changeItemQuantity(productId, 3);
        assertEquals(order.getItems().stream()
            .map(OrderItem::subtotal)
            .reduce(Money.ZERO_CNY, Money::add),
            order.getTotalAmount());
    }
}

Verification 3: Transaction boundaries do not cross aggregates

Rough check commands (shell example):

# Find saves of multiple aggregates in an application service
grep -R "Repository.*save" src/main/java/com/youxuan/store/*/application
# Detect direct references to other aggregates
grep -R "private .*Member\|private .*Product\|private .*Coupon" src/main/java/com/youxuan/store/*/domain/model

Common Pitfalls

Designing aggregates that are too large – split when invariants are unrelated.

Holding direct object references across aggregates – leads to massive object graphs and blurred boundaries.

Modifying several aggregates in one transaction – ask whether strong consistency is truly required; usually eventual consistency via events is better.

Exposing mutable internal collections (e.g., returning the raw List<OrderItem>) – return an unmodifiable view to prevent external mutation.

Aggregates answer “which objects must stay together”. The next step is to decide where business rules belong – inside domain objects (charged model) or in services (anemic model).

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

microservicesDDDDomain ModelingAggregateInvariantTransaction Boundary
Yumin Fish Harvest
Written by

Yumin Fish Harvest

A deep‑sea salvage fisherman sharing architecture insights, practical tips, and lessons learned.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.