no-aggregate-reference
An aggregate refers to another aggregate by its identifier, never by holding it.
- Rule
tactical/no-aggregate-reference- Category
- Tactical: how building blocks are written
- Reports
- A property or constructor parameter typed with another aggregate
- Applies to
- Every class that extends
AggregateRootorEntity - Turn off
"tactical/no-aggregate-reference": "off"
Why
Order holds a Customer. The next handler that places an order also updates the customer, in the same transaction, because it is right there. Loading an order now drags the customer along, two users editing either one conflict, and the two boundaries have merged without anyone deciding it.
The fix
The order keeps a CustomerId. Each aggregate stays a consistency boundary of its own, loaded, changed and saved alone. The command handler loads the customer when it really needs it, and changes to it happen in their own transaction, usually in reaction to an event.
What it checks
In every class that extends AggregateRoot or Entity, no property and no constructor parameter is typed with another aggregate:
customer: CustomerMap, a Set or a Promise of Customer.customer: Customer | undefinedAn entity inside an aggregate follows the same rule: an OrderLine cannot hold a Customer either.
What it reports
src/ordering/domain/aggregates/order.aggregate.ts:6
tactical/no-aggregate-reference: Order.customer holds the
aggregate Customer: reference it by its identifier instead.Fix it
Hold the identifier instead
So that each aggregate stays its own boundary, replace the property with the identifier of the other aggregate, and put that identifier in the snapshot.
import { AggregateRoot } from "@alveolus/core";
import type { Customer } from "./customer.aggregate";
export class Order extends AggregateRoot<OrderId> {
private readonly customer: Customer;
}import { AggregateRoot } from "@alveolus/core";
import type { CustomerId } from "../value-objects/customer-id.identifier";
export class Order extends AggregateRoot<OrderId> {
private readonly customerId: CustomerId;
}Load the other aggregate in the handler
When a rule needs data from the other aggregate, the command handler loads it through its repository and passes what the rule needs to the business method. When the other aggregate must change too, a second handler does it on the event, in its own transaction.
Turn it off
rules: { "tactical/no-aggregate-reference": "off" },On an existing project, prefer a baseline: new code keeps the rule while you rework the old references.
See also
- Aggregates: refer to other aggregates by identity
tactical/no-thrown-failure, another rule on aggregates- Rules, every rule by category