Skip to content

Defining an aggregate root

An aggregate root holds the state one event source’s history implies, and the methods that change it. You write the rules once, on the aggregate, and every command that changes an order goes through them.

The aggregate belongs to the slice whose command uses it. Put it in that slice file, next to the event it applies; Injecting into commands adds the command to the same file:

Features/Orders/Adding/Adding.ts
import { field } from '@cratis/fundamentals';
import { eventType } from '@cratis/chronicle/events';
import { AggregateRoot } from '@cratis/arc.chronicle';
@eventType('ItemAdded')
export class ItemAdded {
@field(String) productId: string;
@field(Number) quantity: number;
constructor(productId: string, quantity: number) {
this.productId = productId;
this.quantity = quantity;
}
}
export class OrderLimitExceeded extends Error {
constructor() { super('The order total must not exceed 100'); }
}
export class Order extends AggregateRoot {
quantity = 0;
constructor() {
super();
this.on(ItemAdded, event => { this.quantity += event.quantity; });
}
canAdd(quantity: number): boolean {
return Number.isSafeInteger(quantity) && quantity > 0 && quantity <= 100 - this.quantity;
}
addItem(productId: string, quantity: number): void {
if (!this.canAdd(quantity)) throw new OrderLimitExceeded();
this.apply(new ItemAdded(productId, quantity));
}
}

Order keeps one piece of state, the total quantity. canAdd answers the rule against that state, addItem guards it, and apply() records the change. The explicit 'ItemAdded' ID keeps the stored event type stable even if a bundler renames the class; without it, the SDK uses the class name.

When a second slice needs the same aggregate, for example Features/Orders/Removing/ with a RemoveItemFromOrder command, move Order and the events it handles one level up, to Features/Orders/Order.ts, and import them from both slice files. That is where vertical slices keep what several slices share.

this.on(EventClass, handler) registers the handler for one event type. The match is by class, not by method name, and each event type can have one handler; registering a second throws.

The handler runs in two situations:

  • Replay. When the aggregate is loaded, Arc fetches the stored events of the handled types and passes each one in, rebuilt as an instance of its class.
  • Apply. apply(event) runs the handler immediately, so the next rule in the same command already sees the new state.

Keep handlers to state changes. A handler that sends an email or calls a service does so again for every old event on every load.

The handler takes an optional second EventContext argument during replay, when you need the stored event’s sequence number or occurred time. During apply() there is no context, so treat it as possibly undefined.

Arc loads only the event types the aggregate handles. An aggregate with no handlers can still apply events; it replays nothing.

The aggregate has no failure list. A command rejects a change the usual Arc way: handle() asks the aggregate, and returns rejected(...) when the rule says no. The caller gets a 400 with your message, and nothing the aggregate applied is appended. Injecting into commands shows the command.

The throw in addItem is a guard for a caller that skipped the question. It fails the command with an exception, which the caller sees as a 500, and nothing is appended either. Throw a class of your own, like OrderLimitExceeded, so the log names the domain problem.

For input you can check without history, such as a positive quantity, a command validator answers with a 400 before the aggregate is even loaded.

The protected isNew property is true when the aggregate’s route had no events at all when it loaded, including events it does not handle. Use it for “create once” rules:

start(): void {
if (!this.isNew) throw new Error('The order has already started');
this.apply(new OrderStarted());
}

This method fragment assumes an OrderStarted event type registered with this.on(...) or appended without a handler.

Arc on .NET’s aggregate has Failed(...) for collecting rule failures, OnActivate, and a factory for loading an aggregate by an ID other than the command key. The TypeScript aggregate has none of these. Reject through a thrown error or Arc validation, and load one event source per command key.

Next, inject the aggregate into a command.