Aggregates
An aggregate is a cluster of objects kept consistent as one unit. Code outside goes through its root, which enforces the business rules, records what happened as domain events and returns failures as values. It is loaded, changed and saved as a whole.
export class Order extends AggregateRoot<OrderId, OrderPlaced, OrderSnapshot> {
place(total: number, eventId: string, now: Date): Result<void, OrderAlreadyPlaced> {
if (this.placedTotal !== null) {
return err(new OrderAlreadyPlaced());
}
this.placedTotal = total;
this.record(new OrderPlaced({ aggregateId: this.id, id: eventId, occurredAt: now, payload: { total } }));
return ok();
}
}When to use
Make an aggregate of whatever must stay consistent within one transaction: an order and its lines, whose total must match. Keep it small, and refer to other aggregates by their identifier. A thing with an identity but no rules of its own, inside another object, is an entity; a thing described only by its values is a value object.
Usage
Declare an aggregate
Extend AggregateRoot with the identifier type, the union of the events it records and the type of its snapshot. Keep the constructor private and create through static factories.
import { AggregateRoot, err, ok, type Result } from "@alveolus/core";
import { OrderAlreadyPlaced } from "../errors/order-already-placed.error";
import { OrderPlaced } from "../events/order-placed.event";
import { OrderId } from "../value-objects/order-id.identifier";
export type OrderSnapshot = { id: string; total: number | null };
export class Order extends AggregateRoot<OrderId, OrderPlaced, OrderSnapshot> {
private placedTotal: number | null = null;
private constructor(id: OrderId) {
super(id);
}
static create(id: OrderId): Order {
return new Order(id);
}
get isPlaced(): boolean {
return this.placedTotal !== null;
}
place(total: number, eventId: string, now: Date): Result<void, OrderAlreadyPlaced> {
if (this.placedTotal !== null) {
return err(new OrderAlreadyPlaced());
}
this.placedTotal = total;
this.record(new OrderPlaced({ aggregateId: this.id, id: eventId, occurredAt: now, payload: { total } }));
return ok();
}
toSnapshot(): OrderSnapshot {
return { id: this.id.value, total: this.placedTotal };
}
}Change state through business methods
Every public method that changes the state checks the rules first, then changes the state, then records an event. It returns a Result: the caller sees in the signature which failures it must handle. Reads are getters.
set total(total: number) {
this.placedTotal = total;
}
place(total: number): void {
if (this.isPlaced) {
throw new OrderAlreadyPlaced();
}
this.placedTotal = total;
}place(total: number, eventId: string, now: Date): Result<void, OrderAlreadyPlaced> {
if (this.isPlaced) {
return err(new OrderAlreadyPlaced());
}
this.placedTotal = total;
this.record(new OrderPlaced({ aggregateId: this.id, id: eventId, occurredAt: now, payload: { total } }));
return ok();
}Why?
A setter lets any caller bypass the rules, and a thrown error does not appear in the signature. The errors-as-values rule requires every public method of an aggregate to return a Result.
Pass the time and the ids in
The domain never reads the clock nor generates ids. A business method that records an event takes the event id and the current date as parameters; the command handler gets them from the Clock and IdGenerator ports.
const placed = order.place(total, this.ids.next(), this.clock.now());Tests then pass fixed values and compare events exactly.
Validate input in a factory
A static factory is the only way to create the aggregate, so creation rules go there. When the input can be refused, return a Result instead of the aggregate.
static create(id: OrderId, lines: readonly OrderLine[]): Result<Order, EmptyOrder> {
if (lines.length === 0) {
return err(new EmptyOrder());
}
return ok(new Order(id, lines));
}Save and restore it with a snapshot
The state is private, so storage reads and writes it as a snapshot: plain data (strings, numbers, booleans, null, bigint, Date, arrays and objects of them); a value object is written as its fields. toSnapshot() is abstract and returns it; a static fromSnapshot(snapshot) rebuilds the aggregate through its private constructor. The snapshot type is declared with type, in the file of the aggregate.
static fromSnapshot(snapshot: OrderSnapshot): Order {
const order = new Order(new OrderId(snapshot.id));
order.placedTotal = snapshot.total;
return order;
}fromSnapshot restores state: it checks no business rule and records no event. The entities of the aggregate have their own snapshot, which the root composes. The command repository adapter maps the snapshot to its storage.
Refer to other aggregates by identity
private readonly customer: Customer;private readonly customerId: CustomerId;Holding another aggregate merges two consistency boundaries without anyone deciding it. Checked by reference-by-identity.
Hand over recorded events
The aggregate records events; it never publishes them. After saving it, the command handler pulls them and adds them to the outbox, in the same unit of work.
await this.orders.save(order);
await this.outbox.add(order.pullDomainEvents().map((event) => this.translator.translate(event, { correlationId })));domainEvents reads the pending events without clearing them, for tests.
Reference
abstract class AggregateRoot<
Id extends AnyIdentifier,
Event extends AnyDomainEvent = AnyDomainEvent,
Snapshot extends AnySnapshot = AnySnapshot,
> extends Entity<Id, Snapshot>| Type parameter | Description |
|---|---|
Id | The identifier of the aggregate. |
Event | Union of the domain events it records. |
Snapshot | The plain data its state is saved as. |
| Member | Type | Description |
|---|---|---|
constructor(id) | protected | Takes the identifier. |
id | Id | Inherited from Entity. |
domainEvents | readonly Event[] | The recorded events, in order. Reading them does not clear them. |
pullDomainEvents() | Event[] | Returns the recorded events, in order, and clears them. |
record(event) | protected, void | Records an event. |
equals(other) | boolean | Same class and equal identifiers. |
toSnapshot() | abstract, Snapshot | Returns the state as plain data. |
fromSnapshot(snapshot) | static, by convention | Rebuilds the aggregate. Written by you. |
AnyAggregateRoot is the type of any aggregate.
Caveats
domainEventsreturns a copy: changing it does not change the aggregate.- TypeScript has no abstract static methods:
fromSnapshotis a convention, not checked by the compiler. - Declare the snapshot with
type, notinterface: an interface does not satisfyAnySnapshot. - No version is kept: to prevent lost updates, put a
versionin your snapshot and check it in the repository adapter.
Import from @alveolus/core or @alveolus/core/aggregates.
Troubleshooting
Type 'OrderSnapshot' does not satisfy the constraint 'AnySnapshot': the snapshot is an interface, or holds a value object or an entity. Declare it with type and write value objects as plain fields.
See also
- Entities and Value objects, inside an aggregate
- Domain events and Domain errors, what it records and returns
- Repositories, to load and save it
- Rules:
errors-as-values,reference-by-identity,placement