How Aggregate Roots Guard Business Invariants – The Final Defense Line
The article examines a payroll bug caused by exposing internal collections, then explains three immutable‑focused rules for aggregate roots—expose only through the root, keep each transaction to a single aggregate, and reference other aggregates by ID only—showing how proper design prevents consistency errors, performance loss, and concurrency conflicts in Spring Boot applications.
Last month a payroll reconciliation revealed a discrepancy: the recorded total amount was 12,450 yuan, but the sum of the line items (base salary, bonus, attendance deduction, etc.) added up to 12,950 yuan, a 500 yuan gap. The investigation traced the bug to a newly added "temporary subsidy" feature that directly called getLineItems().add(...) on the PaySlip aggregate, bypassing any recalculation of the totalAmount field.
What Is an Aggregate Root: Not a Data Relationship, but a Consistency Boundary
Many newcomers mistakenly treat a one‑to‑many database relationship (e.g., Employee and PaySlip) as an aggregate. The correct view is that an aggregate defines the scope of business invariants, not relational tables. The invariant "pay‑slip total must always equal the sum of its line items" forces PaySlip to be the aggregate root; all external code must interact with the aggregate only through this root.
Rule One: External Access Only Through the Aggregate Root
Bad example – the collection is exposed directly:
public class PaySlip {
private List<PaySlipLineItem> lineItems = new ArrayList<>();
private Money totalAmount;
public List<PaySlipLineItem> getLineItems() {
return lineItems; // returns internal collection reference
}
public Money getTotalAmount() {
return totalAmount; // no guarantee it matches lineItems
}
}Code that obtains a PaySlip instance can now add or remove items without any validation, leaving totalAmount out of sync.
Correct design – all mutations go through the root:
public class PaySlip {
private final List<PaySlipLineItem> lineItems = new ArrayList<>();
private Money totalAmount = Money.ZERO;
public static PaySlip createDraft(EmployeeId employeeId, PayCycle cycle) {
PaySlip paySlip = new PaySlip(PaySlipId.generate(), employeeId, cycle);
paySlip.status = PaySlipStatus.PENDING;
return paySlip;
}
/** Add a line item – the only entry point for external code */
public void addLineItem(LineItemType type, Money amount) {
assertEditable();
lineItems.add(new PaySlipLineItem(LineItemId.generate(), type, amount));
recalculateTotal(); // total is always consistent
}
/** Remove a line item – validates business rules */
public void removeLineItem(LineItemId lineItemId) {
assertEditable();
boolean removed = lineItems.removeIf(item -> item.getId().equals(lineItemId));
if (!removed) {
throw new DomainException("Line item not found: " + lineItemId.getValue());
}
if (lineItems.isEmpty()) {
throw new DomainException("PaySlip must retain at least one line item");
}
recalculateTotal();
}
/** Expose an immutable view of the collection */
public List<PaySlipLineItem> getLineItems() {
return Collections.unmodifiableList(lineItems);
}
public Money getTotalAmount() {
return totalAmount;
}
private void recalculateTotal() {
this.totalAmount = lineItems.stream()
.map(PaySlipLineItem::getAmount)
.reduce(Money.ZERO, Money::add);
}
private void assertEditable() {
if (this.status != PaySlipStatus.PENDING) {
throw new DomainException("PaySlip status is " + status + ", line items cannot be modified");
}
}
}Now the total amount is always recomputed after any change, and external code cannot bypass the aggregate root.
Rule Two: One Transaction Should Modify Only One Aggregate
A naïve service might try to approve a pay‑slip and, in the same transaction, update the employee’s approved‑pay‑slip counter:
@Service
public class PaySlipApprovalAppService {
@Transactional
public void approve(PaySlipId paySlipId) {
PaySlip paySlip = paySlipRepository.findById(paySlipId)
.orElseThrow(() -> new PaySlipNotFoundException(paySlipId));
paySlip.approve();
paySlipRepository.save(paySlip);
// "Conveniently" also modify Employee aggregate
Employee employee = employeeRepository.findById(paySlip.getEmployeeId());
employee.incrementApprovedPaySlipCount();
employeeRepository.save(employee);
}
}This code compiles and may pass unit tests, but it creates two problems: tighter coupling between aggregates and a larger lock scope that increases the chance of optimistic‑lock conflicts.
The recommended approach is to keep the transaction focused on a single aggregate and publish a domain event for the other side:
@Service
public class PaySlipApprovalAppService {
@Transactional
public void approve(PaySlipId paySlipId) {
PaySlip paySlip = paySlipRepository.findById(paySlipId)
.orElseThrow(() -> new PaySlipNotFoundException(paySlipId));
paySlip.approve(); // raises PaySlipApproved event
paySlipRepository.save(paySlip);
}
}
@Component
public class EmployeePaidCountUpdater {
@EventListener
@Async
@Transactional
public void on(PaySlipApproved event) {
employeeUpdateService.incrementApprovedCount(event.getEmployeeId());
}
}The approve() method now only concerns the PaySlip aggregate; the employee counter is updated asynchronously in its own transaction, eliminating unnecessary coupling.
Rule Three: Aggregates May Only Hold ID References to Each Other
Direct object references force the ORM to load entire object graphs, causing performance waste and possible LazyInitializationException. The safe pattern is to store only the identifier:
// Wrong – PaySlip holds a full Employee object
public class PaySlip {
private Employee employee; // loads whole employee graph
}
// Correct – only the EmployeeId is stored
public class PaySlip {
private EmployeeId employeeId; // fetch employee explicitly when needed
}By keeping only IDs, the aggregate’s loading cost and lock range stay minimal.
How Large Should an Aggregate Be? Performance and Concurrency Costs
Over‑sized aggregates, such as an Employee root that also contains every pay‑slip, attendance record, and performance review, suffer two major drawbacks:
Loading cost: Even a simple query for the employee’s department may trigger loading dozens of pay‑slips and thousands of attendance records.
Concurrency conflicts: Optimistic‑lock versioning applies to the whole aggregate, so unrelated updates (e.g., changing department vs. marking a pay‑slip as calculated) contend for the same lock, leading to frequent OptimisticLockException.
Splitting the model into independent aggregates resolves both issues:
public class Employee {
private EmployeeId id;
private ContractInfo contractInfo;
private OrganizationPosition position;
// no collections of pay‑slips or attendance records
}
public class PaySlip {
private PaySlipId id;
private EmployeeId employeeId; // reference by ID only
private List<PaySlipLineItem> lineItems;
}
public class AttendanceRecord {
private AttendanceRecordId id;
private EmployeeId employeeId;
}Queries that need data from multiple aggregates are handled at the read‑model layer (e.g., via CQRS), not by enlarging the write‑model aggregate.
How to Determine Aggregate Boundaries
When deciding the size of an aggregate, ask three questions repeatedly:
Does the business rule require absolute, moment‑by‑moment consistency between the two pieces of data? If eventual consistency (a few hundred milliseconds) is acceptable, keep them in separate aggregates.
Will the sub‑object ever be queried or modified independently? If not, it belongs inside the parent aggregate.
Do the objects have significantly different modification frequencies? High‑frequency objects should not be locked together with low‑frequency ones.
Summary
External code must modify internal state only via the aggregate root’s business methods; direct collection access caused the initial 500 yuan bug.
Prefer a single‑aggregate transaction; use domain events for cross‑aggregate side effects.
Aggregates should reference each other solely by ID to keep loading and locking scopes under control.
Big aggregates that bundle an entire employee lifecycle increase loading cost and lock contention; split them into focused aggregates and handle cross‑aggregate queries in the read model.
Next, the series will compare domain services and application services to clarify where complex payroll logic belongs.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Tinker Programmer
Solving problems with code, sharing practical tech insights, and leveling up together!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
