The full cycle in 60 lines
You already write Zod schemas for API validation. @sotajs/ddd takes the same schema and gives you
a typed, safe domain model. Here’s the complete path — from schema to use case.
1. Define your Entity
import { z } from 'zod';
import { createEntity } from '@sotajs/ddd';
const UserProfileSchema = z.object({
id: z.string().uuid(),
username: z.string(),
bio: z.string().optional(),
});
type UserProfileProps = z.infer<typeof UserProfileSchema>;
export const UserProfile = createEntity({
schema: UserProfileSchema,
actions: {
updateBio(state, bio: string) {
state.bio = bio;
},
updateUsername(state, name: string) {
state.username = name;
},
},
computed: {
displayName(props) {
return props.username;
},
},
});
export type UserProfile = ReturnType<typeof UserProfile.create>;
What just happened? Your Zod schema defined the shape and validation.
actionsnamed the operations you can do.computeddeclared derived properties.ReturnType<typeof UserProfile.create>gave you the entity instance type — no manual type declarations.
// Usage
const profile = UserProfile.create({
id: 'b3f1ae2c-abcd',
username: 'alice',
});
profile.actions.updateBio('new bio');
console.log(profile.displayName); // 'alice'
2. Compose into an Aggregate
When multiple entities must stay consistent together, wrap them in an Aggregate:
import { createAggregate } from '@sotajs/ddd';
const OrderSchema = z.object({
id: z.string().uuid(),
status: z.enum(['pending', 'confirmed', 'shipped']),
amountPaid: z.number(),
customerInfo: UserProfileSchema, // nested entity
});
type OrderProps = z.infer<typeof OrderSchema>;
export const Order = createAggregate({
name: 'Order',
schema: OrderSchema,
entities: {
customerInfo: UserProfile, // auto-hydrated into an entity instance
},
invariants: [
(props) => {
if (props.status === 'confirmed' && props.amountPaid === 0) {
throw new Error('Cannot confirm unpaid order');
}
},
],
actions: {
confirm(state) {
state.status = 'confirmed';
},
},
});
export type Order = ReturnType<typeof Order.create>;
invariantsis required. TypeScript won’t compile without it. Invariants run on.create()and after every action. You cannot forget them.
// Invariant catches violations at creation time:
Order.create({ id: '...', status: 'confirmed', amountPaid: 0 });
// Error: Cannot confirm unpaid order
const order = Order.create({ id: '...', status: 'pending', amountPaid: 100 });
order.actions.confirm(); // ok — amountPaid > 0
// Nested entity is a real entity instance, not a plain object:
order.state.customerInfo.actions.updateBio('VIP customer');
3. Write a Repository
The library doesn’t dictate persistence. Here’s how it looks with Prisma:
// order.repository.ts
import { prisma } from './db';
import { Order } from './order.aggregate';
export const orderRepository = {
async findById(id: string): Promise<Order | null> {
const row = await prisma.order.findUnique({
where: { id },
include: { customerInfo: true },
});
return row ? Order.create(row) : null;
},
async save(order: Order): Promise<void> {
await prisma.order.upsert({
where: { id: order.id },
create: { ...order.props },
update: { ...order.props },
});
},
};
That’s it. Two lines of mapping:
Order.create(row)on load,{ ...order.props }on save. The library doesn’t care about your ORM.
Same pattern with Drizzle or Knex — load raw data, pass to .create().
4. Write a Use Case
// confirm-order.command.ts
import { orderRepository } from './order.repository';
export const confirmOrderCommand = async (orderId: string) => {
const order = await orderRepository.findById(orderId);
if (!order) throw new Error('Order not found');
order.actions.confirm();
await orderRepository.save(order);
// Collect domain events if any:
const events = order.getPendingEvents();
// dispatch(events) — your event bus, your rules
return { id: order.id, status: order.props.status };
};
The use case is a plain async function. No framework, no decorators. Works in NestJS controllers, Express handlers, or Lambda functions.
5. Wire it up (NestJS example)
// order.controller.ts
import { Controller, Post, Param } from '@nestjs/common';
import { confirmOrderCommand } from './confirm-order.command';
@Controller('orders')
export class OrderController {
@Post(':id/confirm')
async confirm(@Param('id') id: string) {
return confirmOrderCommand(id);
}
}
The controller knows nothing about the domain. The domain knows nothing about HTTP. The library sits in between — giving you typed, safe domain objects.
What you just built
ZodSchema → createEntity → createAggregate → Repository → UseCase → Controller
The library contributed four lines: createEntity, createAggregate, createBrandedId, createValueObject.
Everything else is plain TypeScript and your framework of choice.
Next steps
- Entity API — full config reference
- Aggregate API — transactions, invariants, events, nested entities
- Value Object API — structural equality, immutability
- BrandedId API — typed identity at compile time
- What we don’t cover — honest boundaries of the library