Aggregate Roots
Aggregate Roots are the heart of domain-driven design and event sourcing in Eventicle. They encapsulate business logic, maintain consistency boundaries, and generate events that represent state changes.
What Are Aggregate Roots?
An Aggregate Root is an entity that:
-
Controls Access: Acts as the single entry point for modifying aggregate state
-
Ensures Consistency: Enforces business rules and invariants
-
Generates Events: Emits events when state changes occur
-
Rebuilds State: Reconstructs current state from historical events
-
Provides Identity: Has a unique identifier within its bounded context
Basic Aggregate Structure
import { AggregateRoot } from "@eventicle/eventiclejs";
class BankAccount extends AggregateRoot {
balance: number = 0;
status: "active" | "frozen" | "closed" = "active";
constructor() {
super("bank-accounts", []);
// Define event reducers
this.reducers = {
AccountOpened: (event) => {
this.id = event.payload.accountId;
this.balance = event.payload.initialDeposit;
this.status = "active";
},
MoneyDeposited: (event) => {
this.balance += event.payload.amount;
},
MoneyWithdrawn: (event) => {
this.balance -= event.payload.amount;
},
AccountFrozen: (event) => {
this.status = "frozen";
}
};
}
// Business methods
static open(accountId: string, initialDeposit: number): BankAccount {
const account = new BankAccount();
account.raiseEvent({
type: "AccountOpened",
payload: { accountId, initialDeposit }
});
return account;
}
deposit(amount: number) {
if (this.status !== "active") {
throw new Error("Cannot deposit to inactive account");
}
this.raiseEvent({
type: "MoneyDeposited",
payload: { amount, timestamp: new Date() }
});
}
withdraw(amount: number) {
if (this.status !== "active") {
throw new Error("Cannot withdraw from inactive account");
}
if (this.balance < amount) {
throw new Error("Insufficient funds");
}
this.raiseEvent({
type: "MoneyWithdrawn",
payload: { amount, timestamp: new Date() }
});
}
freeze() {
if (this.status === "active") {
this.raiseEvent({
type: "AccountFrozen",
payload: { reason: "Manual freeze", timestamp: new Date() }
});
}
}
}
Aggregate Root Lifecycle
1. Creation
Aggregates are typically created through static factory methods:
// Create new aggregate
const account = BankAccount.open("acc-123", 1000);
// At this point, the aggregate has:
// - Generated an "AccountOpened" event
// - Applied the event to set initial state
// - Event is pending (not yet persisted)
2. Loading from Events
Existing aggregates are reconstructed from their event history:
import { aggregates } from "@eventicle/eventiclejs";
// Load aggregate by ID
const account = await aggregates.load(BankAccount, "acc-123");
// The aggregate is rebuilt by:
// 1. Loading all events for the aggregate
// 2. Applying each event through reducers
// 3. Resulting in current state
3. Modification
Business operations modify state through event generation:
// Load existing aggregate
const account = await aggregates.load(BankAccount, "acc-123");
// Perform business operations
account.deposit(500);
account.withdraw(200);
// Events are raised but not yet persisted
console.log(account.newEvents.length); // 2
4. Persistence
Changes are persisted by saving the aggregate:
// Save the aggregate (emits all pending events)
const emittedEvents = await aggregates.persist(account);
// After persistence:
// - Events are emitted to the event stream
// - newEvents array is cleared
// - Other components can react to the events
Event Reducers
Reducers are pure functions that apply events to aggregate state:
class Order extends AggregateRoot {
items: OrderItem[] = [];
status: string = "draft";
total: number = 0;
constructor() {
super("orders", []);
this.reducers = {
// Simple state assignment
OrderCreated: (event) => {
this.id = event.payload.orderId;
this.status = "created";
},
// Complex state calculation
ItemAdded: (event) => {
this.items.push(event.payload.item);
this.total = this.items.reduce((sum, item) =>
sum + (item.price * item.quantity), 0
);
},
// Conditional state changes
PaymentReceived: (event) => {
if (event.payload.amount >= this.total) {
this.status = "paid";
}
}
};
}
}
Business Logic and Invariants
Aggregates enforce business rules before generating events:
class ShoppingCart extends AggregateRoot {
items: CartItem[] = [];
customerId: string = "";
maxItems: number = 10;
addItem(productId: string, quantity: number, price: number) {
// Validate business rules
if (this.items.length >= this.maxItems) {
throw new Error("Cart is full");
}
if (quantity <= 0) {
throw new Error("Quantity must be positive");
}
if (price < 0) {
throw new Error("Price cannot be negative");
}
// Check if item already exists
const existingItem = this.items.find(i => i.productId === productId);
if (existingItem) {
throw new Error("Item already in cart");
}
// Business rule passed, raise event
this.raiseEvent({
type: "ItemAddedToCart",
payload: { productId, quantity, price }
});
}
}
Loading and Saving Aggregates
Basic Operations
import { aggregates } from "@eventicle/eventiclejs";
// Create and save new aggregate
const newOrder = Order.create("order-123", "customer-456");
await aggregates.persist(newOrder);
// Load existing aggregate
const existingOrder = await aggregates.load(Order, "order-123");
// Modify and save
existingOrder.addItem("product-1", 2, 29.99);
await aggregates.persist(existingOrder);
Checkpoints and Performance
For aggregates with many events, use checkpoints to improve loading performance:
class HighVolumeAggregate extends AggregateRoot {
constructor() {
super("high-volume", [], {
checkpointFrequency: 100 // Create checkpoint every 100 events
});
}
// Get current checkpoint data
currentCheckpoint() {
return {
balance: this.balance,
transactionCount: this.transactionCount,
lastActivity: this.lastActivity
};
}
// Restore from checkpoint
fromCheckpoint(data: any) {
this.balance = data.balance;
this.transactionCount = data.transactionCount;
this.lastActivity = data.lastActivity;
}
}
Multi-Tenant Aggregates
For multi-tenant systems, use TenantAggregateRoot:
import { TenantAggregateRoot } from "@eventicle/eventiclejs";
class TenantOrder extends TenantAggregateRoot {
constructor() {
super("tenant-orders", []);
this.reducers = {
OrderCreated: (event) => {
this.id = event.payload.orderId;
this.tenantId = event.payload.tenantId;
}
};
}
static create(orderId: string, tenantId: string): TenantOrder {
const order = new TenantOrder();
order.raiseEvent({
type: "OrderCreated",
payload: { orderId, tenantId }
});
return order;
}
}
// Load tenant-specific aggregate
const tenantOrder = await aggregates.loadTenant(TenantOrder, "order-123", "tenant-abc");
Automatically Raised Events
Some events are automatically generated by the framework:
-
Snapshot Events: When checkpoints are created
-
Lifecycle Events: For aggregate creation/deletion
-
Causation Events: Tracking event relationships
// These events are automatically added:
// - AggregateCreated when first events are raised
// - AggregateSnapshot when checkpoints occur
// - CausationTracking for event relationships
Using Commands with Aggregate Roots
Combine aggregates with commands for clean separation:
import { dispatchDirectCommand } from "@eventicle/eventiclejs";
export class AccountCommands {
static async openAccount(accountId: string, initialDeposit: number) {
return dispatchDirectCommand(async () => {
const account = BankAccount.open(accountId, initialDeposit);
return {
response: account.id,
events: await aggregates.persist(account)
};
}, "bank-accounts");
}
static async deposit(accountId: string, amount: number) {
return dispatchDirectCommand(async () => {
const account = await aggregates.load(BankAccount, accountId);
account.deposit(amount);
return {
response: { newBalance: account.balance },
events: await aggregates.persist(account)
};
}, "bank-accounts");
}
}
Error Handling
class RobustAggregate extends AggregateRoot {
performOperation(data: any) {
try {
// Validate input
this.validateInput(data);
// Check business rules
this.enforceBusinessRules(data);
// Generate event
this.raiseEvent({
type: "OperationPerformed",
payload: data
});
} catch (error) {
// Log error but don't raise event
console.error("Operation failed:", error.message);
throw error;
}
}
private validateInput(data: any) {
if (!data || typeof data !== 'object') {
throw new Error("Invalid input data");
}
}
private enforceBusinessRules(data: any) {
// Business rule validation
if (this.someBusinessCondition(data)) {
throw new Error("Business rule violation");
}
}
}
Best Practices
-
Single Responsibility: Each aggregate should have one clear purpose
-
Small Boundaries: Keep aggregates focused and avoid large object graphs
-
Immutable Events: Never modify events after they’re created
-
Validate Early: Check business rules before raising events
-
Handle Errors: Provide clear error messages for business rule violations
-
Use Checkpoints: For aggregates with many events
-
Test Thoroughly: Test both happy path and error scenarios
Next Steps
-
Learn about XState Aggregate Roots for complex state machines
-
Explore Commands for handling user actions
-
Understand Testing Aggregates thoroughly
-
See Performance Optimization for large aggregates