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:
| Location | When | Example |
|---|---|---|
| Zod schema | Single-field format constraints | z.string().email() |
| Action guard clause | Preconditions for a mutation | ”Can’t ship unconfirmed order” |
Aggregate invariants | Rules 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
| Entity | Aggregate | |
|---|---|---|
| Single-field validation | Zod schema | Zod schema |
| Action precondition | Guard in action | Guard in action |
| Cross-field rule (all actions) | ❌ | invariants array |
| Cross-field rule (specific action) | Guard in that action | Guard in that action |
❌ Aggregate with empty invariants:
createAggregate({
name: 'User',
schema: UserSchema,
invariants: [], // empty — use createEntity instead
actions: { ... },
})
Aggregate exists for the
invariantsguarantee. No invariants → usecreateEntity.
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.