createEntity(config)
Creates an Entity — an object with a unique identity (id), mutable state changed only through
defined actions, and computed (derived) properties.
import { z } from 'zod';
import { createEntity } from '@sotajs/ddd';
const UserSchema = z.object({
id: z.string().uuid(),
username: z.string(),
bio: z.string().optional(),
emailVerified: z.boolean().default(false),
});
const User = createEntity({
schema: UserSchema,
actions: {
verifyEmail(state) {
if (state.emailVerified) throw new Error('Already verified');
state.emailVerified = true;
},
},
computed: {
displayName(props) { return props.username; },
},
});
type User = ReturnType<typeof User.create>;
const user = User.create({ id: 'b3f1ae2c-abcd', username: 'alice' });
user.actions.verifyEmail();
console.log(user.displayName); // 'alice'
How does
displayNamework? It’s a getter defined on the entity prototype. Each computed key becomes a property — not a function call. Defined once in config, reads like native data. Recalculates from current props on every access. Source: entity.ts
Config
| Field | Required | Description |
|---|---|---|
schema | No* | Zod/Valibot schema — .parse() interface. Provides type inference + auto-setters. |
type | No* | ArkType type — callable. Fastest validation path. |
validate | No* | Generic (data: unknown) => TProps. Use with any validator or plain function. |
actions | Yes | Named operations that mutate state. First argument is a mutable draft. |
computed | No | Derived getters. Each key becomes a property on the entity instance. |
*One of
schema,type, orvalidateis required. Pick the one that matches your validator.
Instance API
| Member | Type | Description |
|---|---|---|
Entity.create(data) | Entity instance | Static factory. Validates input through schema/type/validate, returns entity. |
entity.id | string | The entity’s identity. |
entity.props | Readonly<TProps> | Frozen snapshot of current state. |
entity.actions.* | (...args) => void | Action methods. Each mutates state through a shallow copy. |
entity.equals(other) | boolean | Identity comparison by id. |
How state mutation works
When you call an action, the library:
- Reads current state from an internal
WeakMap - Creates a shallow copy (
{ ...current }) - Passes the copy to your action function — you mutate it freely
- Checks invariants (only on Aggregate)
- Writes the copy back to the
WeakMap
// Your action
verifyEmail(state) {
if (state.emailVerified) throw new Error('Already verified');
state.emailVerified = true; // mutating a draft copy
}
// The library swaps the draft in — atomic from your perspective
State never lives on
this. It’s stored in aWeakMapkeyed by the entity instance. No public fields, no accidental exposure through iteration or logging. Source: entity.ts:152-217
Why can’t I mutate props directly?
user.props.bio = 'x'; // TypeError: Cannot assign to read only property
propsreturnsdeepFreeze({ ...current })— a recursively frozen copy. Every read creates a new snapshot. The original state is untouched. This is not a convention — it’s a physical constraint of the runtime. Source: entity.ts:112-115
Auto-setters
For every non-id field in your schema, the library generates a setField(value) method:
// Schema: { id, username, bio }
user.actions.setUsername('new-name'); // generated automatically
user.actions.setBio('new-bio'); // generated automatically
How? The library introspects
schema.shapeat definition time. Every key exceptidgets a setter. For ArkType orvalidatecallbacks, auto-setters are discovered by callingvalidate({ id: "any" })and inspecting the returned keys. Source: entity.ts:83-124
Validator-agnostic
Three equivalent ways to define the same entity:
// Zod / Valibot
createEntity({ schema: z.object({ id: z.string(), name: z.string() }), actions: {...} })
// ArkType
createEntity({ type: type({ id: 'string', name: 'string' }), actions: {...} })
// Plain function
createEntity({ validate: (data: unknown) => data as { id: string; name: string }, actions: {...} })
The library only cares that your validator has a
.parse()method (schema), is callable (type), or is a function (validate). Everything else is your choice.
Pitfalls
- Computed values are not cached. They recalculate on every access. Keep them fast.
- State is shallow-copied during actions. If your state has nested objects, the draft shares references with the original for unchanged properties.
deepFreezeon.propsprevents external mutation. - Auto-setters skip when
validate({ id: "any" })throws. Define explicit setters inactionsfor strict validators.