Skip to main content

What is an Aggregate?

An Aggregate is a cluster of domain objects treated as a single unit for data changes. It has a root entity (the Aggregate itself) that controls access to all objects inside it.
import { Aggregate, Id, DomainError } from "@woltz/rich-domain";

interface OrderProps {
  id: Id;
  customerId: string;
  status: "draft" | "confirmed" | "shipped" | "delivered";
  items: OrderItem[];
  shippingAddress: Address;
  createdAt: Date;
}

class Order extends Aggregate<OrderProps> {
  get customerId() {
    return this.props.customerId;
  }

  get status() {
    return this.props.status;
  }

  get items() {
    return this.props.items;
  }

  get total() {
    return this.props.items.reduce((sum, item) => sum + item.subtotal, 0);
  }

  addItem(productId: string, quantity: number, unitPrice: number) {
    if (this.props.status !== "draft") {
      throw new DomainError("Cannot modify confirmed order");
    }
    this.props.items.push(new OrderItem({ productId, quantity, unitPrice }));
  }

  confirm() {
    if (this.props.items.length === 0) {
      throw new DomainError("Cannot confirm empty order");
    }
    this.props.status = "confirmed";
  }

  ship() {
    if (this.props.status !== "confirmed") {
      throw new DomainError("Order must be confirmed before shipping");
    }
    this.props.status = "shipped";
  }
}

Key Characteristics

Consistency Boundary

All invariants within an Aggregate are enforced immediately. Changes to other Aggregates are eventually consistent.

Single Unit of Persistence

Aggregates are loaded and saved as a whole. Repositories work with Aggregates, not individual entities.

Transactional Boundary

All changes within an Aggregate happen in a single transaction.

Encapsulation

External objects can only reference the Aggregate root, never internal entities directly.

Creating Aggregates

New Aggregate

const order = new Order({
  customerId: "cust-123",
  status: "draft",
  items: [],
  shippingAddress: new Address({
    street: "123 Main St",
    city: "New York",
    zipCode: "10001",
  }),
  createdAt: new Date(),
});

console.log(order.isNew()); // true

From Database

const order = new Order({
  id: Id.from(dbRow.id),
  customerId: dbRow.customerId,
  status: dbRow.status,
  items: dbRow.items.map(
    (item) =>
      new OrderItem({
        id: Id.from(item.id),
        productId: item.productId,
        quantity: item.quantity,
        unitPrice: item.unitPrice,
      })
  ),
  shippingAddress: new Address(dbRow.shippingAddress),
  createdAt: dbRow.createdAt,
});

console.log(order.isNew()); // false

Validation

Add schema validation to ensure Aggregates are always valid:
import { z } from "zod";
import {
  Aggregate,
  EntityValidation,
  EntityHooks,
  Id,
} from "@woltz/rich-domain";

const orderSchema = z.object({
  id: z.custom<Id>((val) => val instanceof Id),
  customerId: z.string().min(1),
  status: z.enum(["draft", "confirmed", "shipped", "delivered"]),
  items: z.array(z.custom<OrderItem>((val) => val instanceof OrderItem)),
  shippingAddress: z.custom<Address>((val) => val instanceof Address),
  createdAt: z.date(),
});

type OrderProps = z.infer<typeof orderSchema>;

class Order extends Aggregate<OrderProps> {
  protected static validation: EntityValidation<OrderProps> = {
    schema: orderSchema,
    config: {
      onCreate: true,
      onUpdate: true,
      throwOnError: true,
    },
  };

  protected static hooks: EntityHooks<OrderProps, Order> = {
    rules: (order) => {
      // Business rule: shipped orders must have at least one item
      if (order.status === "shipped" && order.items.length === 0) {
        throw new DomainError("Shipped orders must have items");
      }
    },
  };

  // ... methods
}

Change Tracking

Aggregates automatically track changes to all nested entities and collections:
const order = new Order({
  id: Id.from("order-123"),
  customerId: "cust-456",
  status: "draft",
  items: [
    new OrderItem({
      id: Id.from("item-1"),
      productId: "prod-a",
      quantity: 2,
      unitPrice: 29.99,
    }),
  ],
  shippingAddress: new Address({
    street: "123 Main",
    city: "NYC",
    zipCode: "10001",
  }),
  createdAt: new Date(),
});

// Make changes
order.addItem("prod-b", 1, 49.99); // Create
order.items[0].updateQuantity(5); // Update
order.removeItem(Id.from("item-1")); // Delete

// Get all changes
const changes = order.getChanges();

console.log(changes.hasCreates()); // true
console.log(changes.hasUpdates()); // true
console.log(changes.hasDeletes()); // true

// Get batch operations for persistence
const batch = changes.toBatchOperations();
// {
//   deletes: [{ entity: "OrderItem", ids: ["item-1"] }],
//   creates: [{ entity: "OrderItem", items: [...] }],
//   updates: [{ entity: "OrderItem", items: [...] }]
// }
Changes are tracked automatically through proxies. No manual tracking needed.

Domain Events

Aggregates can emit domain events for cross-aggregate communication:
import { DomainEvent } from "@woltz/rich-domain";

class OrderConfirmedEvent extends DomainEvent {
  constructor(
    aggregateId: Id,
    public readonly customerId: string,
    public readonly total: number
  ) {
    super(aggregateId);
  }

  protected getPayload() {
    return { customerId: this.customerId, total: this.total };
  }
}

class Order extends Aggregate<OrderProps> {
  confirm() {
    if (this.props.items.length === 0) {
      throw new Error("Cannot confirm empty order");
    }

    this.props.status = "confirmed";

    // Emit event
    this.addDomainEvent(
      new OrderConfirmedEvent(this.id, this.customerId, this.total)
    );
  }
}

// Dispatch events after saving
await orderRepository.save(order);
await order.dispatchAll(eventBus);
order.clearEvents();
See more in Domain Events

Serialization

Convert the entire Aggregate (including nested entities) to JSON:
const json = order.toJSON();
// {
//   id: "order-123",
//   customerId: "cust-456",
//   status: "confirmed",
//   items: [
//     { id: "item-1", productId: "prod-a", quantity: 2, unitPrice: 29.99 },
//     { id: "item-2", productId: "prod-b", quantity: 1, unitPrice: 49.99 }
//   ],
//   shippingAddress: { street: "123 Main", city: "NYC", zipCode: "10001" },
//   createdAt: "2024-01-15T10:30:00.000Z"
// }

Design Guidelines

Large Aggregates lead to concurrency issues and performance problems. Include only what’s needed to enforce invariants.
// ❌ Too large
class Customer extends Aggregate<CustomerProps> {
  orders: Order[];      // Could be thousands
  reviews: Review[];    // Independent lifecycle
  wishlist: Product[];  // Different consistency needs
}

// ✅ Separate Aggregates
class Customer extends Aggregate<CustomerProps> { }
class Order extends Aggregate<OrderProps> { customerId: string; }
class Review extends Aggregate<ReviewProps> { customerId: string; }
Don’t hold direct references to other Aggregates. Use IDs instead.
// ❌ Direct reference
class Order extends Aggregate<OrderProps> {
  customer: Customer; // Creates tight coupling
}

// ✅ Reference by ID
class Order extends Aggregate<OrderProps> {
  customerId: string;
}
All business rules should be enforced through Aggregate methods, never bypassed.
class Order extends Aggregate<OrderProps> {
  // ✅ Enforces business rules
  addItem(productId: string, quantity: number, unitPrice: number) {
    if (this.status !== "draft") {
      throw new Error("Cannot modify confirmed order");
    }
    if (quantity <= 0) {
      throw new Error("Quantity must be positive");
    }
    this.props.items.push(new OrderItem({ productId, quantity, unitPrice }));
  }
}
When creation logic is complex, use static factory methods.
class Order extends Aggregate<OrderProps> {
  static createFromCart(cart: Cart, customerId: string): Order {
    const order = new Order({
      customerId,
      status: "draft",
      items: cart.items.map(
        (item) => new OrderItem({
          productId: item.productId,
          quantity: item.quantity,
          unitPrice: item.unitPrice,
        })
      ),
      shippingAddress: cart.shippingAddress,
      createdAt: new Date(),
    });

    order.addDomainEvent(new OrderCreatedEvent(order.id, customerId));
    return order;
  }
}