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.