Domain modeling
The @beignet/core/domain subpath provides small helpers for domain-driven design: entities, value objects, and domain events.
bun add @beignet/core
Placement
Put domain code with the feature that owns the business concept:
features/posts/domain/post.ts
features/posts/domain/events/published.ts
features/comments/domain/events/comment-added.ts
Use top-level domain/ only for shared-kernel concepts that are genuinely used
by multiple features, such as EmailAddress, Money, or TenantId. Avoid
creating features/shared/domain; shared code should not pretend to be a
feature.
Value objects
Immutable, validated types that represent a concept with no identity (e.g. an email address, a currency amount):
import { defineValueObject } from "@beignet/core/domain";
import { z } from "zod";
const Email = defineValueObject("Email")
.schema(z.string().email())
.build();
const email = await Email.create("user@example.com"); // validated string
await Email.isValid("not-an-email"); // false
Entities
Domain objects with identity and behavior. Entities are immutable — methods return new instances:
import { defineEntity } from "@beignet/core/domain";
import { z } from "zod";
const Todo = defineEntity("Todo")
.props(z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
}))
.methods((self) => ({
complete: () => self.with({ completed: true }),
rename: (title: string) => self.with({ title }),
}))
.build();
const todo = await Todo.create({ id: "1", title: "Buy milk", completed: false });
const done = await todo.complete(); // new instance with completed: true
Every entity gets a .with() method for partial updates, returning a new instance.
Domain events
Typed event declarations for use with use cases. For listeners and event bus helpers, use Events. For explicit background work, use Jobs. For time-triggered workflows, use Scheduled tasks.
import { defineEvent } from "@beignet/core/events";
import { z } from "zod";
const todoCreated = defineEvent("todo.created", {
payload: z.object({ id: z.string(), title: z.string() }),
});
const todoCompleted = defineEvent("todo.completed", {
payload: z.object({ id: z.string() }),
});
Domain events are declarations: they describe the shape of something that
happened. .emits() declares which events a use case may emit, and the
use-case events helper records or publishes only that declared set. Record
inside Unit of Work transactions when listeners must only run after commit.
import { useCase } from "@/lib/use-case";
import { z } from "zod";
const createTodo = useCase
.command("todos.create")
.input(z.object({ title: z.string() }))
.output(z.object({ id: z.string(), title: z.string() }))
.emits([todoCreated])
.run(async ({ input, ctx, events }) => {
const todo = await ctx.ports.uow.transaction(async (tx) => {
const created = await tx.todos.create(input);
await events.record(tx.events, todoCreated, {
id: created.id,
title: created.title,
});
return created;
});
return todo;
});
Schema libraries
All three helpers work with any Standard Schema library — Zod, Valibot, ArkType, etc.