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. actions named the operations you can do. computed declared 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>;

invariants is 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