What you’ll learn

  • How to compose entities into an aggregate
  • Why invariants is required and what it guarantees
  • How nested entities are auto-hydrated
  • The pull-based domain event pattern

1. Define the entities inside

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

Plain objects instead of entities:

items: z.array(z.object({ productId: z.string(), quantity: z.number() }))
// Plain object — no identity, no behavior, no auto-setters

Entities give you .equals(), auto-setters, typed actions — even for nested items. Use createEntity for any nested object with its own identity.

2. Wire into createAggregate

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

const Order = createAggregate({
  name: 'Order',
  schema: OrderSchema,
  entities: {
    items: OrderItem,
    customerInfo: CustomerInfo,
  },
  invariants: [
    (props) => {
      if (props.items.length === 0)
        throw new Error('Order must have at least one item');
    },
    (props) => {
      if (props.status === 'confirmed' && props.amountPaid <= 0)
        throw new Error('Cannot confirm unpaid order');
    },
  ],
  actions: {
    confirm(state) {
      if (state.status !== 'pending') throw new Error('Already processed');
      state.status = 'confirmed';
    },
    ship(state) {
      if (state.status !== 'confirmed') throw new Error('Not confirmed');
      state.status = 'shipped';
      return { event: { aggregateId: state.id, timestamp: new Date() } };
    },
  },
});
type Order = ReturnType<typeof Order.create>;

3. Invariants — required, run on every mutation

invariants is required. TypeScript won’t compile without it. All invariants run after every action:

order.actions.confirm(); // runs ALL invariants — not just "relevant" ones

Empty invariants array:

invariants: [], // compiles, zero protection

No invariants → use createEntity. Aggregate exists for the invariants guarantee.

4. Nested entities — auto-hydrated

const order = Order.create({
  id: '...', status: 'pending', amountPaid: 0,
  items: [
    { productId: '...', quantity: 2, price: 50 },
    { productId: '...', quantity: 1, price: 100 },
  ],
  customerInfo: { name: 'Alice', email: 'a@test.com' },
});

// items are real entity instances:
order.state.items[0].props.quantity; // 2
order.state.items[0].equals(order.state.items[1]); // false

// customerInfo is a real entity:
order.state.customerInfo.actions.setName('Alicia');

On .props read, entities are dehydrated back to plain objects — ready for serialization. Use .state for hydrated entity instances.

5. Domain events — pull, don’t push

Actions return { event }. Events accumulate. You dispatch:

order.actions.ship();
const events = order.getPendingEvents();
await eventBus.publish(events);

Ignoring events or auto-dispatching:

// ❌ no event returned — downstream systems don't know
// ❌ eventBus.publish() inside action — side effect in domain layer

The domain layer produces events. The application layer dispatches them. Pull-based means no hidden side effects.

Entity vs Aggregate

EntityAggregate
invariants✅ Required
Cross-field rules on every mutation
Nested entitiesPlain objectsAuto-hydrated
Domain events✅ Pull-based queue
Auto-setters

What you’ve learned

Aggregate is the transactional consistency boundary. invariants run on .create() and after every action — all invariants, every time. This is the guarantee that makes Aggregate different from Entity.

Nested entities are auto-hydrated. Declare them in entities and they become real entity instances — with .equals(), auto-setters, and typed actions. On .props read they dehydrate back to plain objects.

Domain events are pull-based. Return { event } from actions. Events accumulate in a queue. You decide when to dispatch — no hidden side effects.

Use Aggregate when you have cross-field invariants. Without invariants, createEntity is simpler and gives you auto-setters. Aggregate’s sole reason to exist is the invariants guarantee.

What’s next?

Domain logic is the easiest code to test — no database, no HTTP, no mocks. Testing covers unit-testing entities, aggregates, invariants, and events with plain Vitest assertions.

Testing