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>;
invariantsis 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
| Field | Required | Description |
|---|---|---|
name | Yes | Human-readable name. Used in error messages and debugging. |
schema | No* | Zod/Valibot schema — .parse() interface. |
type | No* | ArkType type — callable, fastest path. |
validate | No* | Generic (data: unknown) => TProps. |
entities | No | Map of property keys to Entity factories. Nested data is auto-hydrated into entity instances. |
invariants | Yes | Array of (props) => void. Each must throw on violation. Checked on create and every action. |
actions | Yes | Named operations. Can return { event } to record a domain event. |
computed | No | Derived getters — same as Entity. |
*One of
schema,type, orvalidateis required.
Instance API
| Member | Type | Description |
|---|---|---|
Aggregate.create(data) | Aggregate instance | Validates, hydrates entities, checks invariants. |
aggregate.id | string | The aggregate root’s identity. |
aggregate.props | Readonly<TProps> | Frozen, dehydrated snapshot (entities unwrapped to raw data). |
aggregate.actions.* | (...args) => any | Action methods. Invariants run after each. Can return { event }. |
aggregate.getPendingEvents() | IDomainEvent[] | Collects and clears pending domain events. |
aggregate.clearEvents() | void | Discards 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.propsread, entities are dehydrated back to plain objects (for clean serialization). On.stateread, 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
| Entity | Aggregate | |
|---|---|---|
| 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.stateif you need entity instances.