> ## Documentation Index
> Fetch the complete documentation index at: https://woltz.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Entities & Aggregates

> Domain objects with identity that form the core building blocks of your domain model

## What are Entities and Aggregates?

Entities and Aggregates are domain objects defined by their **identity**, not by their attributes. Two objects with the same attributes but different IDs are considered distinct objects.

The main difference lies in their architectural role, not in their change
tracking implementation:

* **Entity**: Domain object with identity that normally lives **inside** an Aggregate
* **Aggregate**: Root entity that defines a **consistency boundary** and is the entry point for repositories and domain events

<CardGroup cols={2}>
  <Card title="Entity" icon="cube">
    Objects with identity that live inside Aggregates. Not accessed directly through repositories.
  </Card>

  <Card title="Aggregate" icon="cubes">
    Root of a cluster of related entities. Models the consistency boundary persisted by a repository.
  </Card>
</CardGroup>

## Key Differences

| Feature                                | Entity | Aggregate |
| -------------------------------------- | ------ | --------- |
| Has identity                           | Yes    | Yes       |
| Participates in persistence            | Yes    | Yes       |
| Defines a consistency boundary         | No     | Yes       |
| Repository root in the core API        | No     | Yes       |
| Can contain and manage child entities  | Yes    | Yes       |
| Can record domain events               | No     | Yes       |
| Tracks its own and nested changes      | Yes    | Yes       |
| Can technically be nested in an object | Yes    | Yes       |

<Note>
  `Entity` and `Aggregate` both inherit change tracking from `BaseEntity`.
  Therefore, either one can technically contain and track nested entities.
  The distinction is architectural: repositories in the core API operate on
  aggregate roots, and only `Aggregate` provides domain-event management.
</Note>

<Warning>
  Although nesting one `Aggregate` inside another is technically supported by
  the object graph tracker, it usually crosses aggregate boundaries. Prefer
  referencing another aggregate root by `Id`. Persistence adapters may treat an
  embedded aggregate as an owned child unless its relationship is configured as
  a reference.
</Warning>

## Creating Entities

Entities are identity-bearing domain objects, typically used as part of an
Aggregate. They have the same validation, serialization, and change tracking
capabilities inherited from `BaseEntity`, but they do not manage domain events:

```typescript theme={null}
import { Entity, Id, DomainError } from "@woltz/rich-domain";

interface OrderItemProps {
  id: Id;
  productId: string;
  quantity: number;
  unitPrice: number;
}

class OrderItem extends Entity<OrderItemProps> {
  get productId() {
    return this.props.productId;
  }

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

  get subtotal() {
    return this.props.quantity * this.props.unitPrice;
  }

  updateQuantity(quantity: number) {
    if (quantity <= 0) {
      throw new DomainError("Quantity must be positive");
    }
    this.props.quantity = quantity;
  }
}

// New entity (auto-generated ID)
const item = new OrderItem({
  productId: "prod-123",
  quantity: 2,
  unitPrice: 49.99,
});

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

// Existing entity (from database)
const existingItem = new OrderItem({
  id: Id.from("item-456"),
  productId: "prod-123",
  quantity: 2,
  unitPrice: 49.99,
});

console.log(existingItem.isNew()); // false
```

## Creating Aggregates

Aggregates are the root of a cluster of entities and define consistency boundaries:

```typescript theme={null}
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 }));
  }

  removeItem(itemId: Id) {
    if (this.props.status !== "draft") {
      throw new DomainError("Cannot modify confirmed order");
    }
    this.props.items = this.props.items.filter(
      (item) => !item.id.equals(itemId)
    );
  }

  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";
  }
}
```

## Identity & Equality

Both Entities and Aggregates are compared by ID, not by attributes:

```typescript theme={null}
const item1 = new OrderItem({
  id: Id.from("item-1"),
  productId: "prod-123",
  quantity: 2,
  unitPrice: 49.99,
});

const item2 = new OrderItem({
  id: Id.from("item-1"),
  productId: "prod-999", // different attributes
  quantity: 10,
  unitPrice: 99.99,
});

const item3 = new OrderItem({
  id: Id.from("item-2"), // different ID
  productId: "prod-123",
  quantity: 2,
  unitPrice: 49.99,
});

item1.equals(item2); // true - same ID
item1.equals(item3); // false - different IDs
item1.equals("item-1"); // true - can compare with string
item1.equals(Id.from("item-1")); // true - can compare with Id
```

## Validation

Both support validation with Standard Schema-compatible validators such as Zod:

```typescript theme={null}
import { z } from "zod";
import {
  Aggregate,
  DomainError,
  Entity,
  EntityHooks,
  EntityValidation,
  Id,
} from "@woltz/rich-domain";

// Entity validation
const orderItemSchema = z.object({
  id: z.custom<Id>((val) => val instanceof Id),
  productId: z.string().min(1),
  quantity: z.number().int().positive(),
  unitPrice: z.number().positive(),
});

type OrderItemProps = z.infer<typeof orderItemSchema>;

class OrderItem extends Entity<OrderItemProps> {
  protected static validation: EntityValidation<OrderItemProps> = {
    schema: orderItemSchema,
    config: {
      onCreate: true,
      onUpdate: true,
      throwOnError: true,
    },
  };
}

// Aggregate validation with business rules
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");
      }
    },
  };
}
```

## Optional Input Properties

Sometimes you need properties that are required in the entity but optional when constructing it, because they're generated internally via hooks. Use the second generic parameter to specify optional input fields:

```typescript theme={null}
import { z } from "zod";
import { Aggregate, Id, EntityValidation, EntityHooks } from "@woltz/rich-domain";

const userSchema = z.object({
  id: z.custom<Id>((val) => val instanceof Id),
  email: z.string().email(),
  password: z.string().min(8), // Required in schema
  name: z.string(),
  createdAt: z.date(), // Required in schema
});

type UserProps = z.infer<typeof userSchema>;

// Make 'password' and 'createdAt' optional in constructor
class User extends Aggregate<UserProps, "password" | "createdAt"> {
  protected static validation: EntityValidation<UserProps> = {
    schema: userSchema,
  };

  protected static hooks: EntityHooks<UserProps, User> = {
    onBeforeCreate: (props) => {
      // Generate password if not provided
      if (!props.password) {
        props.password = generateEncryptedPassword();
      }
      // Set createdAt if not provided
      if (!props.createdAt) {
        props.createdAt = new Date();
      }
    },
  };

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

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

// ✅ Works without password and createdAt
const user = new User({
  email: "user@example.com",
  name: "John Doe",
});

// ✅ Also works with explicit values
const userWithPassword = new User({
  email: "user@example.com",
  name: "John Doe",
  password: "custom-password-12345678",
});
```

<Note>
  Fields marked as optional input are still **required in the entity** and validated by the schema. The difference is they're optional when calling `new User()` because they'll be generated in `onBeforeCreate`.
</Note>

**Common Use Cases:**

* Auto-generated passwords or tokens
* Timestamps (createdAt, updatedAt)
* Computed identifiers
* Default configurations

## Consistency Boundaries

<CardGroup cols={2}>
  <Card title="Immediate Consistency" icon="shield">
    Model aggregate methods so their invariants are valid before persistence.
  </Card>

  <Card title="Eventual Consistency" icon="arrows-rotate">
    Coordinate changes across aggregate boundaries with domain events or application services.
  </Card>
</CardGroup>

Rich Domain supplies the building blocks, but it does not automatically define
your invariants or open a database transaction. The application and persistence
adapter remain responsible for executing a save inside the required
transactional boundary.

## Change Tracking

Both entities and aggregates track changes to their own properties and nested
`BaseEntity` instances. In normal DDD usage, call `getChanges()` on the aggregate
root so the result represents the persistence boundary:

```typescript theme={null}
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,
    }),
    new OrderItem({
      id: Id.from("item-2"),
      productId: "prod-b",
      quantity: 1,
      unitPrice: 19.99,
    }),
  ],
  shippingAddress: new Address({
    street: "123 Main",
    city: "NYC",
    zipCode: "10001",
  }),
  createdAt: new Date(),
});

// Make changes after the initial tracking baseline
order.addItem("prod-c", 1, 49.99); // Create
order.items[0].updateQuantity(5); // Update
order.removeItem(Id.from("item-2")); // Delete

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

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

// Group and order operations for a persistence adapter
const batch = changes.toBatchOperations();

console.log(batch.deletes[0].ids); // ["item-2"]
console.log(batch.creates[0].items[0].parentId); // "order-123"
console.log(batch.updates[0].items[0].changedFields); // { quantity: 5 }
```

<Note>
  Construction establishes the initial tracking baseline. `getChanges()` reports
  mutations made after construction or after `markAsClean()`; it does not emit a
  create operation for the root object itself. Repositories use `isNew()` to
  decide whether the aggregate root requires an INSERT.
</Note>

## Domain Events

Aggregates can emit domain events for cross-aggregate communication:

```typescript theme={null}
import { DomainEvent } from "@woltz/rich-domain";

type OrderConfirmedPayload = {
  orderId: string;
  customerId: string;
  total: number;
};

class OrderConfirmedEvent extends DomainEvent<OrderConfirmedPayload> {}

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

    this.props.status = "confirmed";

    this.addDomainEvent(new OrderConfirmedEvent({
      orderId: this.id.value,
      customerId: this.customerId,
      total: this.total,
    }));
  }
}

// Persist first, then publish. dispatchAll() clears events after publishing.
await orderRepository.save(order);
await order.dispatchAll(eventBus);
```

See more in [Domain Events](/core/domain-events)

## Serialization

Both can be converted to JSON for APIs or persistence:

```typescript theme={null}
const item = new OrderItem({
  id: Id.from("item-123"),
  productId: "prod-456",
  quantity: 3,
  unitPrice: 29.99,
});

const itemJson = item.toJSON();
// {
//   id: "item-123",
//   productId: "prod-456",
//   quantity: 3,
//   unitPrice: 29.99
// }

const orderJson = order.toJSON();
// {
//   id: "order-123",
//   customerId: "cust-456",
//   status: "confirmed",
//   items: [
//     { id: "item-1", productId: "prod-a", quantity: 2, unitPrice: 29.99 }
//   ],
//   shippingAddress: { street: "123 Main", city: "NYC", zipCode: "10001" },
//   createdAt: "2024-01-15T10:30:00.000Z"
// }
```
