createAggregate(config)

Creates an Aggregate class — the transactional consistency boundary in DDD. Aggregates enforce invariants on every mutation, hydrate nested entities, and collect domain events for later dispatching.

Signature

function createAggregate<
  TProps extends { id: string },
  TEntities extends EntitiesMap<TProps>,
  TActions extends Record<string, (props: HydratedProps<TProps, TEntities>, ...args: any[]) => any>,
  TComputed extends Record<string, (props: HydratedProps<TProps, TEntities>) => any> = {},
>(config: AggregateConfig<TProps, TEntities, TActions, TComputed>): AggregateClass

Config

PropertyTypeDescription
namestringHuman-readable name for debugging.
validate(data: unknown) => TPropsValidates raw input. Returns typed props or throws.
entitiesRecord<string, EntityClass>Entity factories to hydrate nested data into entity instances.
invariantsArray<(props) => void>Checked on creation and after every action. Throw on violation.
actionsRecord<string, (draft, ...args) => any>Domain actions. Return { event } to record a domain event.
computedRecord<string, (props) => any>Derived getters.

Returned Class

MemberTypeDescription
Aggregate.create(data)AggregateStatic factory. Validates, hydrates entities, checks invariants.
aggregate.idstringThe aggregate root’s identity.
aggregate.propsReadonly<TProps>Frozen, dehydrated snapshot (entities unwrapped to raw props).
aggregate.stateReadonly<TProps>Deprecated. Use .props.
aggregate.actions.*(...args) => voidAction methods. Each runs through invariant checks post-mutation.
aggregate.getPendingEvents()IDomainEvent[]Collects and clears pending domain events.
aggregate.clearEvents()voidDiscards pending events without reading.

Domain Events

Actions can return { event } to record a domain event:

actions: {
  ship: (draft) => {
    draft.status = "shipped";
    return { event: { aggregateId: draft.id, timestamp: new Date() } };
  },
}

Events are collected internally. Call getPendingEvents() after your use case to retrieve and clear them. Dispatch them to your message bus, event store, or outbox pattern.

Nested Entities

When an aggregate contains sub-entities (e.g., Order contains OrderItem entities), declare them in the entities map:

const Order = createAggregate({
  entities: {
    items: OrderItemEntity,  // Entity factory
  },
  // ...
});

On creation, nested data matching entity keys is automatically hydrated through Entity.create(). On .props read, entities are dehydrated back to raw data.

Example

import { createAggregate } from "@sotajs/ddd";

const Order = createAggregate({
  name: "Order",
  validate: (data) => {
    const d = data as Record<string, unknown>;
    return {
      id: d.id as string,
      customerId: d.customerId as string,
      items: (d.items as any[]) ?? [],
      status: "pending" as const,
    };
  },

  invariants: [
    (props) => {
      if (props.items.length === 0) throw new Error("Must have ≥ 1 item");
    },
    (props) => {
      if (props.items.some((i: any) => i.quantity <= 0))
        throw new Error("Quantities must be positive");
    },
  ],

  actions: {
    addItem: (draft, item) => {
      draft.items.push(item);
    },
    ship: (draft) => {
      if (draft.status !== "pending") throw new Error("Already shipped");
      draft.status = "shipped";
      return {
        event: { aggregateId: draft.id, timestamp: new Date() },
      };
    },
  },

  computed: {
    itemCount: (props) => props.items.length,
  },
});

const order = Order.create({
  id: "o1",
  customerId: "c1",
  items: [{ productId: "p1", quantity: 2 }],
});

order.actions.addItem({ productId: "p2", quantity: 1 });
order.actions.ship();

// Invariant violation:
// order.actions.ship(); // throws "Already shipped"

const events = order.getPendingEvents();
// events.length === 1, events cleared after read

Pitfalls

  • Invariants run on every action. Keep them fast — no I/O, no async.
  • All invariants run after every action, not just the relevant ones.
  • getPendingEvents() clears the queue. Call it once per use case.
  • Props are dehydrated — nested entities are unwrapped to raw data on .props access.
  • Shallow copies. Like Entity, mutations use shallow copy. Deeply nested structures need explicit handling.