DDD Tactical: Aggregates — Tasks¶
What? Eight progressive exercises that walk you from "design a small aggregate" to "fix a multi-aggregate transaction, add optimistic locking, and implement a snapshot strategy". Each task has clear inputs, a validation table you can run as JUnit tests, and a worked solution at the end. The exercises mirror real production scenarios drawn from Vaughn Vernon's Effective Aggregate Design examples. How? Work each task in order. Try to make every row of the validation table pass before reading the worked solution. If you get stuck, re-read the corresponding section of
senior.mdorprofessional.mdfirst — most exercises map directly to one rule from those files.
Task 1 — Design the Order aggregate from scratch¶
Goal: Implement an Order aggregate root with LineItem children and an Address value object. External code can only obtain references to Order.
Requirements: - Constructor: Order(OrderId, CustomerId, Address). - Methods on root: addItem(ProductId, int qty, Money unitPrice), removeItem(LineItemId), changeShippingAddress(Address), place(). - Invariants: - Only DRAFT orders are mutable. - At most 50 line items. - Total never exceeds 100,000 USD. - Cannot place an empty order. - LineItem constructor and mutators must be package-private. - items() returns an unmodifiable view.
Validation table:
| Test | Expected |
|---|---|
| Add item to DRAFT order, total updates | total == subtotal |
| Add 51st item to DRAFT order | DomainException("max 50 items") |
| Add item to PLACED order | DomainException("Order is not DRAFT") |
place() on empty order | DomainException |
External code calls items().add(...) | UnsupportedOperationException |
External code calls new LineItem(...) from another package | compile error |
Task 2 — Fix a cross-aggregate transaction¶
Goal: Replace a multi-aggregate transaction with eventual consistency via a domain event.
Starting code:
@Transactional
public void placeOrder(OrderId orderId) {
Order order = orders.findById(orderId).orElseThrow();
Inventory inv = inventories.findByProduct(order.firstProductId()).orElseThrow();
inv.reserve(order.firstQuantity());
order.place();
orders.save(order);
inventories.save(inv);
}
Requirements: - One aggregate per transaction. - Order.place() emits OrderPlacedEvent. - A listener handles inventory reservation in its own transaction, after commit.
Validation table:
| Test | Expected |
|---|---|
placeOrder commits the order even if inventory listener fails later | order is PLACED in DB |
| Inventory eventually reflects reservation | inventory rows reduced after async dispatch |
| No deadlock under load (2 concurrent placements) | both succeed without timeout |
No @ManyToOne between Order and Inventory | grep returns empty |
Task 3 — Add optimistic locking¶
Goal: Add @Version to Order and a retry policy to the command handler.
Requirements: - @Version long version on the Order entity. - Application service retries on OptimisticLockException up to 3 times with exponential backoff (50ms, 100ms, 200ms). - After 3 failures, the exception propagates to the caller.
Validation table:
| Test | Expected |
|---|---|
Two concurrent addItem calls, no conflict | both items present after both commit |
Two concurrent addItem calls that conflict | the second triggers retry, succeeds |
| 4 conflicting commands | fourth throws OptimisticLockException |
| Single command, no conflict | no retry, single SQL UPDATE |
Task 4 — ID-only reference between aggregates¶
Goal: Convert a model with @ManyToOne Customer customer on Order to an id-only reference.
Starting code:
Requirements: - Replace with @Embedded CustomerId customerId. - Update application code that previously called order.getCustomer().getName() to load the customer via CustomerRepository. - No cascade between Order and Customer.
Validation table:
| Test | Expected |
|---|---|
| Saving an order does not write to the customer table | customers.updated_at unchanged |
OrderView includes customer name | loaded via two queries, joined in DTO |
Schema has orders.customer_id UUID NOT NULL | DDL inspection |
No @ManyToOne(Customer.class) anywhere on Order | grep returns empty |
Task 5 — Implement a snapshot for an event-sourced aggregate¶
Goal: Take an existing event-sourced Order and add a snapshot strategy. The repository should write a snapshot every 100 events.
Requirements: - Snapshot table stores aggregate_id, version, payload (JSON), created_at. - On load: read latest snapshot, then events with version > snapshot.version. - On save: if the new version crosses a multiple of 100, write a snapshot.
Validation table:
| Test | Expected |
|---|---|
| Load aggregate with 250 events, snapshot at v200 | replay starts at v200, applies 50 events |
| Save command that takes version from 199 to 200 | snapshot row written |
| Save command that takes version from 200 to 201 | no new snapshot |
| Load with no snapshot, all 250 events replayed | matches load via snapshot path |
Task 6 — Compute aggregate-local invariant from a derived value¶
Goal: Add an invariant: "no more than 3 items of the same product per order". Implement it correctly.
Requirements: - The check happens at addItem time. - Counts items by ProductId. - The check is part of the would-be new state (validate before mutate).
Validation table:
| Test | Expected |
|---|---|
| Add 3 items of product A | succeeds |
| Add 4th item of product A | DomainException("max 3 per product") |
| Add 3 items of A, then 3 of B | both succeed |
| Add and then remove an item of A; add a 4th of A | succeeds (because removed first) |
Task 7 — Spot the boundary mistake¶
Goal: Given a Project aggregate that contains Task entities and also references a Team aggregate by @ManyToOne, identify which rule is broken and propose a fix.
@Entity
public class Project {
@Id private UUID id;
@OneToMany(cascade = ALL, orphanRemoval = true) private List<Task> tasks;
@ManyToOne(cascade = PERSIST) private Team team; // <-- problem
}
Requirements: - Write a short diagnosis (1-2 sentences) and identify the violated rule from specification.md. - Provide corrected code. - Explain how cross-aggregate effects (e.g., "team renamed") should propagate.
Validation table:
| Test | Expected |
|---|---|
| Diagnosis names C5 (id-only references) | yes |
Corrected code has @Embedded TeamId teamId | yes |
| Solution uses an event for "team renamed" | yes (e.g., TeamRenamedEvent) |
Task 8 — End-to-end command flow¶
Goal: Build a PlaceOrderCommandHandler that exercises every rule from senior.md.
Requirements: - Loads exactly one aggregate. - Calls one root method (Order.place()). - Saves the aggregate. - Publishes domain events after commit. - Retries on optimistic-lock conflicts up to 3 times. - Logs each retry attempt.
Validation table:
| Test | Expected |
|---|---|
| Happy path, no contention | 1 SQL UPDATE, 1 event published |
| One conflict, then success | retry log present, 1 event published |
| Three conflicts | OptimisticLockException propagates |
Order load uses @EntityGraph to fetch items eagerly | one SQL with join |
Worked solution: Task 1¶
public final class OrderId {
private final UUID value;
private OrderId(UUID value) { this.value = Objects.requireNonNull(value); }
public static OrderId of(UUID value) { return new OrderId(value); }
@Override public boolean equals(Object o) {
return o instanceof OrderId other && other.value.equals(this.value);
}
@Override public int hashCode() { return value.hashCode(); }
}
public final class LineItemId {
private final UUID value;
public static LineItemId newId() { return new LineItemId(UUID.randomUUID()); }
private LineItemId(UUID value) { this.value = value; }
}
public final class Order {
private final OrderId id;
private final CustomerId customerId;
private final List<LineItem> items = new ArrayList<>();
private Address shippingAddress;
private OrderStatus status;
private Money total;
private static final int MAX_ITEMS = 50;
private static final Money MAX_TOTAL = Money.of("USD", 100_000);
public Order(OrderId id, CustomerId customerId, Address shippingAddress) {
this.id = id;
this.customerId = customerId;
this.shippingAddress = shippingAddress;
this.status = OrderStatus.DRAFT;
this.total = Money.zero(Currency.USD);
}
public void addItem(ProductId pid, int qty, Money unitPrice) {
requireDraft();
if (items.size() >= MAX_ITEMS)
throw new DomainException("max " + MAX_ITEMS + " items");
var item = new LineItem(LineItemId.newId(), pid, qty, unitPrice);
var next = total.add(item.subtotal());
if (next.greaterThan(MAX_TOTAL))
throw new DomainException("Total exceeds " + MAX_TOTAL);
items.add(item);
total = next;
}
public void place() {
requireDraft();
if (items.isEmpty()) throw new DomainException("Cannot place empty order");
this.status = OrderStatus.PLACED;
}
public List<LineItem> items() { return Collections.unmodifiableList(items); }
private void requireDraft() {
if (status != OrderStatus.DRAFT)
throw new DomainException("Order is not DRAFT");
}
}
public final class LineItem {
private final LineItemId id;
private final ProductId productId;
private int quantity;
private final Money unitPrice;
LineItem(LineItemId id, ProductId productId, int quantity, Money unitPrice) {
this.id = id;
this.productId = productId;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
Money subtotal() { return unitPrice.multiply(quantity); }
}
Note the package-private LineItem constructor and subtotal(). External code cannot construct or read a line item except through the root.
What's next¶
interview.md— how interviewers test these same skills.find-bug.md— bug scenarios that exercise the same muscles.optimize.md— performance angles on these exercises.