What you’ll learn

  • Three places invariants can live — and which two are correct
  • How Aggregate invariants differ from action guard clauses
  • When to use Aggregate vs Entity for invariants
  • Why invariants must be synchronous

Where invariants live

There are three places invariants can live. Only two are correct:

LocationWhenExample
Zod schemaSingle-field format constraintsz.string().email()
Action guard clausePreconditions for a mutation”Can’t ship unconfirmed order”
Aggregate invariantsRules spanning multiple fields”Total must match sum of items”

Zod catches "notanemail"@sotajs/ddd catches “can’t ship unconfirmed.”

Invariants in Aggregate

invariants is a required field on Aggregate. It runs at .create() and after every action — all invariants, not just “relevant” ones:

const Order = createAggregate({
  name: 'Order',
  schema: OrderSchema,
  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');
    },
    (props) => {
      const itemTotal = props.items.reduce((sum, i) => sum + i.price * i.quantity, 0);
      if (Math.abs(props.total - itemTotal) > 0.01)
        throw new Error('Total must match sum of item prices');
    },
  ],
  actions: {
    confirm(state) {
      if (state.status !== 'pending') throw new Error('Already processed');
      state.status = 'confirmed';
    },
  },
});

Checking invariants in the controller:

// Controller:
function confirmOrder(id: string) {
  const order = repo.get(id);
  if (order.props.amountPaid <= 0) throw new Error('Unpaid');
  order.actions.confirm();
}

“Can’t confirm unpaid” belongs to the domain, not to a specific HTTP endpoint. In the controller, every other caller must duplicate the check.

Invariants in Entity (via actions)

Entity doesn’t have an invariants field. Guards live in actions:

const User = createEntity({
  schema: UserSchema,
  actions: {
    deactivate(state) {
      if (!state.emailVerified) throw new Error('Must verify email first');
      if (state.status === 'inactive') throw new Error('Already inactive');
      state.status = 'inactive';
    },
  },
});

Skipping guards:

actions: {
  deactivate(state) {
    state.status = 'inactive'; // any caller can deactivate, anytime
  },
}

Without guards, the entity can reach invalid states. Actions should reject calls that would break domain rules.

Entity vs Aggregate for invariants

EntityAggregate
Single-field validationZod schemaZod schema
Action preconditionGuard in actionGuard in action
Cross-field rule (all actions)invariants array
Cross-field rule (specific action)Guard in that actionGuard in that action

Aggregate with empty invariants:

createAggregate({
  name: 'User',
  schema: UserSchema,
  invariants: [], // empty — use createEntity instead
  actions: { ... },
})

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

Invariants must be synchronous

Async invariant:

invariants: [
  async (props) => {
    const exists = await db.users.count({ where: { email: props.email } });
    if (exists) throw new Error('Email taken');
  },
]

Uniqueness checks belong in the use case before entity creation, not in invariants. Invariants validate the entity’s own state — not the outside world.


What you’ve learned

Three locations for validation. Zod for format, action guards for preconditions, Aggregate invariants for cross-field rules that must hold after every mutation.

Invariants run on every action. All invariants, every time — not just the ones that seem “relevant.” This is the guarantee that makes Aggregate different from Entity.

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

Invariants are synchronous and pure. No database calls, no HTTP. Validate the entity’s own state. External checks (uniqueness, existence) belong in the use case.

What’s next?

Value Objects represent concepts without identity — money, addresses, coordinates. They compare by structure, not by ID. Value Objects covers structural equality, immutability, and when to use a VO instead of an Entity.

Value Objects