Skip to content

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 AggregateRoot or Entity
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:

Alonecustomer: Customer
In a collectionAn array, a Map, a Set or a Promise of Customer.
In a unioncustomer: Customer | undefined

An 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.

❌ Avoid: src/ordering/domain/aggregates/order.aggregate.ts
ts
import { AggregateRoot } from "@alveolus/core";

import type { Customer } from "./customer.aggregate";

export class Order extends AggregateRoot<OrderId> {
	private readonly customer: Customer;
}
✅ Prefer: src/ordering/domain/aggregates/order.aggregate.ts
ts
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 ​

alveolus.config.ts
ts
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 ​

Released under the MIT License.