createAggregate(config)

Creates an Aggregate — the transactional consistency boundary in DDD. Aggregates enforce invariants on every mutation, hydrate nested entities into rich objects, and collect domain events.

import { z } from 'zod';
import { createAggregate, createEntity } from '@sotajs/ddd';

const OrderItemSchema = z.object({
  productId: z.string().uuid(),
  quantity: z.number().positive(),
  price: z.number().positive(),
});
const OrderItem = createEntity({ schema: OrderItemSchema, actions: {} });

const OrderSchema = z.object({
  id: z.string().uuid(),
  status: z.enum(['pending', 'confirmed', 'shipped']),
  amountPaid: z.number(),
  items: z.array(OrderItemSchema),
});
const Order = createAggregate({
  name: 'Order',
  schema: OrderSchema,
  entities: { items: OrderItem },
  invariants: [
    (props) => {
      if (props.status === 'confirmed' && props.amountPaid === 0)
        throw new Error('Cannot confirm unpaid order');
    },
  ],
  actions: {
    confirm(state) { state.status = 'confirmed'; },
  },
});
type Order = ReturnType<typeof Order.create>;

invariants is required. TypeScript won’t compile without it. Invariants run at .create() and after every action. You cannot forget them. Source: aggregate.ts:33-56


Config

FieldRequiredDescription
nameYesHuman-readable name. Used in error messages and debugging.
schemaNo*Zod/Valibot schema — .parse() interface.
typeNo*ArkType type — callable, fastest path.
validateNo*Generic (data: unknown) => TProps.
entitiesNoMap of property keys to Entity factories. Nested data is auto-hydrated into entity instances.
invariantsYesArray of (props) => void. Each must throw on violation. Checked on create and every action.
actionsYesNamed operations. Can return { event } to record a domain event.
computedNoDerived getters — same as Entity.

*One of schema, type, or validate is required.


Instance API

MemberTypeDescription
Aggregate.create(data)Aggregate instanceValidates, hydrates entities, checks invariants.
aggregate.idstringThe aggregate root’s identity.
aggregate.propsReadonly<TProps>Frozen, dehydrated snapshot (entities unwrapped to raw data).
aggregate.actions.*(...args) => anyAction methods. Invariants run after each. Can return { event }.
aggregate.getPendingEvents()IDomainEvent[]Collects and clears pending domain events.
aggregate.clearEvents()voidDiscards pending events without reading.

Invariants

Invariants run twice: at .create() and after every action. They are synchronous functions that receive the aggregate’s props and throw on violation.

invariants: [
  (props) => {
    if (props.items.length === 0)
      throw new Error('Order must have at least one item');
  },
  (props) => {
    if (props.status === 'shipped' && !props.trackingId)
      throw new Error('Shipped orders must have a tracking ID');
  },
],

All invariants run after every action — not just the “relevant” ones. Keep them fast: no I/O, no async, no network calls. They are pure validation. Source: aggregate.ts:207-209


Nested Entities

When an aggregate contains sub-entities, declare them in entities:

const CustomerInfo = createEntity({
  schema: z.object({ name: z.string(), email: z.string().email() }),
  actions: {
    updateName(state, name: string) { state.name = name; },
  },
});

const Order = createAggregate({
  name: 'Order',
  schema: OrderSchema,
  entities: { customerInfo: CustomerInfo },
  invariants: [...],
  actions: {...},
});

const order = Order.create({
  id: '...',
  customerInfo: { name: 'Alice', email: 'a@test.com' },
});

// customerInfo is a real entity instance, not a plain object:
order.state.customerInfo.actions.updateName('Alicia');

On creation, CustomerInfo.create(rawData) is called automatically. On .props read, entities are dehydrated back to plain objects (for clean serialization). On .state read, entities stay hydrated. Source: aggregate.ts:89-104


Domain Events

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

actions: {
  ship(state) {
    if (state.status !== 'pending') throw new Error('Only pending orders can ship');
    state.status = 'shipped';
    return { event: { aggregateId: state.id, timestamp: new Date() } };
  },
},

// After calling the action:
order.actions.ship();
const events = order.getPendingEvents();
// events = [{ aggregateId: '...', timestamp: ... }]

Events are pull-based: actions return events, they accumulate in a queue, you decide when to dispatch. This keeps the domain layer free of infrastructure concerns — no event bus, no message broker, no side effects. Source: aggregate.ts:201-205


Entity vs Aggregate — when to use which

EntityAggregate
Identity✅ Has id✅ Has id
Actions✅ Mutable through actions✅ Mutable through actions
Invariants❌ No invariants field✅ Required invariants
Nested entities❌ Plain nested objects✅ Auto-hydrated entity instances
Domain events❌ No event support✅ Pull-based event queue
Auto-setters✅ From schema.shape❌ Not generated

Use Entity when you need identity + behavior without cross-field consistency rules. Use Aggregate when you need invariants that must be checked on every mutation — or when you compose multiple entities into a transactional boundary.

This distinction follows Evans: an Aggregate is a cluster of objects treated as a unit for data changes. The Aggregate Root is the only access point. See: Architecture direction (issue #6)


Pitfalls

  • Invariants run on every action — all of them. Keep the list short and fast.
  • getPendingEvents() clears the queue. Call it once per use case. If you need to read without clearing, access the internal queue directly (not recommended).
  • Shallow copies during actions. Same as Entity. Nested structures share references with the draft.
  • Props are dehydrated — nested entities are unwrapped to raw data on .props. Use .state if you need entity instances.