What you’ll learn

  • How a Zod schema becomes an Entity in one step
  • What auto-setters are and when they’re enough
  • When to write an action vs let auto-setters do the work
  • How computed properties derive values from state

An Entity has identity, mutable state changed only through defined actions, and computed properties. This guide walks through every decision from schema to usage.

1. Your Zod schema is the starting point

You already validate API payloads with Zod. That same schema becomes your entity’s type system:

import { z } from 'zod';

const UserSchema = z.object({
  id: z.string().uuid(),
  username: z.string().min(3),
  bio: z.string().optional(),
  emailVerified: z.boolean().default(false),
});
type UserProps = z.infer<typeof UserSchema>;

No duplication. Write once, use everywhere.

Skipping Zod and writing raw validators:

// Don't do this — you lose type inference and auto-setters
createEntity({
  validate: (data) => {
    if (typeof data.id !== 'string') throw new Error('bad id');
    return data;
  },
  actions: {},
});

schema gives you auto-setters from schema.shape. A raw validate callback loses that. Zod → full inference + auto-setters, for free.

2. Wrap it in createEntity

import { createEntity } from '@sotajs/ddd';

const User = createEntity({
  schema: UserSchema,
  actions: {},
});
type User = ReturnType<typeof User.create>;

That’s a valid entity. Identity, frozen props, structural equality — all working. And you get auto-setters for every non-id field:

const user = User.create({ id: 'b3f1...', username: 'alice' });
user.actions.setBio('TypeScript person');   // typed: string | undefined → string
user.actions.setEmailVerified(true);        // typed: boolean → boolean

Wrapping auto-setters in actions:

actions: {
  updateBio(state, bio: string) { state.bio = bio; },
  toggleVerified(state) { state.emailVerified = !state.emailVerified; },
}

setBio() and setEmailVerified() already exist. Writing action wrappers is noise. If your action body does a single assignment — delete it.

3. Add actions — for business logic

Auto-setters handle raw field mutation. Actions carry business meaning:

actions: {
  verifyEmail(state) {
    if (state.emailVerified) throw new Error('Already verified');
    state.emailVerified = true;
  },
  deactivate(state) {
    if (!state.emailVerified) throw new Error('Must verify email first');
    state.status = 'inactive';
  },
}

Why verifyEmail exists but updateBio doesn’t: verification has a guard clause and carries domain semantics. Changing a bio is just data entry.

Validating outside the entity:

// Controller — bad:
if (!user.props.emailVerified) throw new Error('not verified');
user.actions.deactivate();

The guard should be inside deactivate(). An entity that can be put in an invalid state by calling actions in the wrong order has leaked its invariants. Make the action defensive — callers shouldn’t need to know preconditions.

4. Add computed — derived values as properties

computed: {
  displayName(props) { return props.username; },
  isVerified(props) { return props.emailVerified; },
  initials(props) { return props.username.slice(0, 2).toUpperCase(); },
}

Usage:

user.displayName;  // 'alice' — property access, not function call
user.isVerified;   // false
user.initials;     // 'AL'

Recalculated from current props on every access. Keep them fast — no I/O, no async.

Heavy computation in computed:

computed: {
  topFriends(props) {
    return db.query('SELECT ...'); // ❌ runs on every access
  },
}

If the value requires I/O or caching, calculate it in the use case and pass it as a separate return value. Computed is for synchronous derivation from props.

5. The complete entity

import { z } from 'zod';
import { createEntity } from '@sotajs/ddd';

const UserSchema = z.object({
  id: z.string().uuid(),
  username: z.string().min(3),
  bio: z.string().optional(),
  emailVerified: z.boolean().default(false),
  status: z.enum(['active', 'inactive']).default('active'),
});
type UserProps = z.infer<typeof UserSchema>;

const User = createEntity({
  schema: UserSchema,
  actions: {
    verifyEmail(state) {
      if (state.emailVerified) throw new Error('Already verified');
      state.emailVerified = true;
    },
    deactivate(state) {
      if (!state.emailVerified) throw new Error('Must verify email first');
      state.status = 'inactive';
    },
    reactivate(state) {
      if (state.status !== 'inactive') throw new Error('Not inactive');
      state.status = 'active';
    },
  },
  computed: {
    displayName(props) { return props.username; },
    isVerified(props) { return props.emailVerified; },
  },
});
type User = ReturnType<typeof User.create>;

6. Using the entity

const user = User.create({ id: 'b3f1ae2c-abcd', username: 'alice' });

// Auto-setter: simple field mutation
user.actions.setBio('TypeScript enthusiast');

// Action: business operation with guard clause
user.actions.verifyEmail();
user.actions.deactivate();

// Read state
user.props.status;      // 'inactive'
user.displayName;       // 'alice'
user.isVerified;        // true

// Identity comparison
user.equals(User.create({ id: 'b3f1ae2c-abcd', username: 'bob' }));
// true — same id, different props

What you’ve learned

Your Zod schema is your entity’s type system. No duplication — the same schema validates API input and defines your domain model. Pass it to createEntity and you get identity, frozen props, and structural equality for free.

Auto-setters handle simple field mutation. Every Zod field (except id) gets a typed setField() automatically. Don’t wrap them in custom actions — if an action body is a single assignment, delete it.

Actions carry business meaning. Write an action when the mutation needs a guard clause, changes multiple fields atomically, or represents a state machine transition. The entity should protect its own invariants — callers shouldn’t need to know preconditions.

Computed properties derive values from state. Accessed as properties (not function calls), recalculated on every access. Keep them synchronous, pure, and fast — no I/O.

What’s next?

You can now create an Entity from a Zod schema. But how do you read its state and mutate fields in practice? Reading and Mutating State covers props, auto-setters in depth, identity comparison, and when auto-setters are all the mutation you need.

Reading and Mutating State