What you’ll learn
- How to compose entities into an aggregate
- Why
invariantsis 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. UsecreateEntityfor 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
.propsread, entities are dehydrated back to plain objects — ready for serialization. Use.statefor 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
| Entity | Aggregate | |
|---|---|---|
invariants | ❌ | ✅ Required |
| Cross-field rules on every mutation | ❌ | ✅ |
| Nested entities | Plain objects | Auto-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