npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@roastery/beans

v0.6.0

Published

Blueprint-driven DDD building blocks for TypeScript: declare the model once as a plain object and derive validated construction, the TypeBox schema, serialization, typed accessors, domain events, repository ports and coherent fixtures — across a domain la

Readme

@roastery/beans

Declare the model once, as a plain object. Everything else is derived — validated construction, the aggregate TypeBox schema, toJSON/fromJSON, typed accessors, and coherent fixtures.

DDD building blocks for the Roastery CMS ecosystem, split into a domain layer (Entity, ValueObject, DomainEvent) and an application layer (Command) — both driven by the same blueprint machinery.

Checked with Biome

import { blueprint } from "@roastery/beans";
import { entityOf } from "@roastery/beans/domain/entity/helpers";
import { EmailVO, SlugVO, StringVO } from "@roastery/beans/domain/collections/value-objects";
import { OptionalStringVO } from "@roastery/beans/domain/collections/value-objects/optional";

const authorProperties = blueprint({
	name: StringVO,
	email: EmailVO,
	slug: SlugVO,
	bio: OptionalStringVO,
}).with({
	slug: { derive: (raw) => raw.name },
});

class Author extends entityOf(authorProperties, "author") {}

const author = new Author({ name: "Alan Reis", email: "[email protected]" });

author.slug; // "alan-reis" — derived from a sibling, already normalised
author.bio;  // string | undefined — the key was omittable because the VO accepts undefined
author.id;   // UUID v7, stamped by the base

Author.demo();              // a complete, coherent fixture — no factory, no faker, no seed file
Author.fromJSON(untrusted); // strict: the whole payload validated before anything is built

That is the entire class — one line. There is no constructor, no create() factory returning a wrapper, no hand-written schema, and no field declared twice. Add methods to it and they sit alongside everything the base already gives you.

Why beans

The blueprint is the model

authorProperties above is a plain object, and it is the single source of truth. The base reads it to derive the aggregate TypeBox schema (recursively, every level with additionalProperties: false), to install the accessors on the prototype, to drive toJSON/fromJSON, and to resolve the construction rules. The schema is memoized against that object's identity, so every instance of a class shares one compiled validator.

Domain rules ride on the same object, under a symbol key — blueprint(shape).with({ slug: { derive } }) — so a property can carry a default or be computed from its siblings without a constructor, and without leaking into the schema or the serialized output.

One line binds a class to its blueprint

entityOf(properties, source) returns a base class already wired to the blueprint, so a subclass declares only its own behaviour — and the accessors come out typed, with no declaration merge:

class Author extends entityOf(authorProperties, "author") {
  public rename(value: string): void {
    this.set("name", value);
    this.raiseEvent(AuthorRenamed); // protected members stay reachable
  }
}

author.slug;   // string — typed
Author.demo(); // an Author, not the base

Extending Entity directly and implementing defineEntity() is unchanged and still right when a subclass computes its definition rather than stating it — but it costs three extra pieces of ceremony, and one of them is easy to forget:

// biome-ignore lint/correctness/noUnusedVariables: merging with the class below is the use.
interface Author extends AccessorsOf<typeof authorProperties> {}
class Author extends Entity<typeof authorProperties> {
  protected defineEntity(): EntityDefinition<typeof authorProperties> {
    return { properties: authorProperties, source: "author" };
  }
}

Skipping that interface line still compiles and still works at runtime — the accessors just vanish from the type system. It is the one silent failure mode in the package, and entityOf removes the chance to hit it. commandOf is the same deal for the application layer, taking Deps and Result as explicit type arguments since no blueprint mentions them.

demo() is a construction path, not a test helper

Every Entity, Command and ValueObject builds itself with no data at all:

Author.demo();
// { id: "01a0…", createdAt: "2026-…", name: "string", email: "[email protected]", slug: "string", bio: undefined }

Rules resolve in demo mode too, so the fixture is coherent rather than a bag of unrelated defaults — slug above is derived from the demo name, already slugified, exactly as it would be in production. Fixtures stop being a parallel codebase that drifts from the model.

A catalog, not just a base class

60 ready-made Value Objects — 20 primitives (UUID, email, slug, datetime, password, URL, and a full numeric grid of Number/Integer/Double × unconstrained/Positive*/Negative*), each with an Optional* and a Nullable* variant — plus their 20 TypeBox schemas and 12 factories (customStringVO, customNumberVO, customDoubleVO, customEnumVO, customObjectVO, optionalVO, nullableVO, unionVO, customBinaryVO, defineValueObject, …) for the constraints that do not deserve a file of their own.

optional and nullable are deliberately not interchangeable: an Optional*VO key may be omitted from the payload entirely (the type system knows it), while a Nullable*VO key must be passed as an explicit null — the usual "wasn't provided" (request body) vs. "provided, and empty" (database column) split, enforced at compile time.

One engine, two layers

A Command is the same machine as an Entity, minus identity and minus mutation: same blueprint, same rules, same demo()/fromJSON(), same synchronous fail-fast validation on construction. What it adds is execute(deps) — the one place I/O belongs — returning { result, events }.

class CreateAuthor extends Command<typeof createAuthorProperties, Deps, Author> {
	protected defineCommand(): CommandDefinition<typeof createAuthorProperties> {
		return { properties: createAuthorProperties, source: "create-author" };
	}

	public async execute(deps: Deps): Promise<CommandResult<Author>> {
		const author = new Author({ name: this.name, email: this.email });
		await deps.authors.save(author);
		return { result: author, events: collectDomainEvents(author) };
	}
}

Input validation failures are re-tagged at the command's boundary — UnprocessableContentException (422) for a bad field, BadRequestException (400) for a malformed payload — so error-handling middleware can tell "this request was wrong" apart from "a domain invariant broke" by reading error[Layer], with the original preserved as cause.

What beans is not

It stops where the opinions start, on purpose:

  • No Result/Either. Invalid data throws a specific, typed exception (InvalidPropertyException, ImmutablePropertyException, IncompleteIdentityException, …), each carrying the property and the source.
  • No production repository, no Unit of Work, no ORM integration. The repository pillar ships the contract and nothing else: RepositoryOf and the ICan* capability types are 100% type-only — no factory, no symbol, no runtime, not one byte emitted. toJSON/fromJSON are still the persistence boundary, and the adapter on the other side is still yours to write. The one implementation that does ship is inMemoryRepositoryOf, and it lives behind @roastery/beans/testing precisely so it can't be mistaken for one.
  • No DI container. commands only gates access to a fixed, once-supplied dependency record at compile time — it doesn't resolve, construct, or scope dependencies for you.
  • No event bus and no dispatcher. raiseEvent buffers, pullDomainEvents drains, collectDomainEvents hands the array to a Command's caller. Publishing is the application's call — but an event can declare what it carries, so what crosses the bus is validated on both ends.
  • No query side. Command is the write path only.

Where those choices have consequences a caller can actually run into, they are gathered in Known limits rather than left scattered across the sections that introduce them.

The building blocks

  • Entity (domain) — Blueprint-driven base class: validated construction, toJSON/fromJSON, atomic set/setMany with automatic updatedAt stamping, typed accessors, nested aggregates, a transient [Storage] slot, a domain-event buffer, and destroy(). id (UUID v7), createdAt and updatedAt come built in.
  • DomainRecord (domain) — An Entity minus identity: no id/createdAt/updatedAt, never a row, but everything else — blueprint-derived schema, strict hydration, demo(), blueprint rules, redaction, and protected set/setMany. For the composite domain values that deserve verbs (Money, Address, DateRange) instead of being flattened into a customObjectVO. Usable as a key of an entity, a command or another record.
  • ValueObject (domain) — Immutable, self-validating wrapper around a value. The subclass declares only defineMeta(); validation runs in the constructor.
  • DomainEvent (domain) — Optional abstract base for the events an Entity raises, plus defineDomainEvent(name, payload?) (a factory building an event class from its name, and optionally the payload it carries — a shape, Json or SafeJson), three TC39 lifecycle decorators (onCreate, onUpdate, onDelete) that raise them automatically at a fixed point, and two TC39 method decorators (emit, onError) that raise them around an arbitrary instance method — emit once it has run to completion, onError when it throws.
  • Collections (domain, aliased under application) — The Value Object / schema catalog and the custom factories described above.
  • Command (application) — Blueprint-driven base for orchestrating domain behaviour behind a validated input, resolving to a CommandResult ({ result, events }). AggregateCommand specializes it for a single-aggregate result: execute() comes already implemented, the subclass writes handle() instead.
  • Repository (domain) — Type-only ports derived from an entity's blueprint: RepositoryOf<typeof User, Spec> builds the contract an adapter implements, out of granular ICan* capabilities a use case asks for in its Deps. findByEmail exists only because the entity declares email. inMemoryRepositoryOf (testing) generates a working double for that same contract, from the same blueprint.
  • Commands (application) — Two-phase builder (commands(spec).withDependencies(deps)) that gates access to a set of Command subclasses by their declared dependencies, entirely at compile time, and hands back a ready-to-run bound function per command via get() or a direct accessor. Pass an emitter — commands(spec, { emitter }) — and the same registry also publishes every event those commands raise and runs the reactions registered with .on(eventClass, handlerClass).
  • The Roastery Way (@roastery/beans/way, spans both layers) — One import path for the low-ceremony subset above: entityOf, recordOf, the value-object catalog, defineDomainEvent, defineUseCase, defineEventHandler, commands. Re-exports only — nothing new is implemented.

Technologies

| Tool | Purpose | |------|---------| | @roastery/terroir | Schema validation, exception hierarchy, well-known symbols, and TypeBox re-exports | | TypeBox | Runtime schema validation and TypeScript type inference | | slugify | URL-safe slug generation | | tsup | Bundling to ESM + CJS with .d.ts generation | | Bun | Runtime, test runner, and package manager | | Knip | Unused exports and dependency detection | | Husky + commitlint | Git hooks and conventional commit enforcement |

Installation

bun add @roastery/beans

@roastery/terroir (schema validation, the exception hierarchy, and the well-known slot symbols) and slugify are regular dependencies and come along with it — nothing else to install.

TypeScript is the one peer dependency, since the package is types-first: every subpath ships .d.ts, and strict plus verbatimModuleSyntax are what the documented patterns are written against.

bun add -d typescript

Importing the terroir symbols directly (import { Storage } from "@roastery/terroir/symbols") works off the transitive install, but add it explicitly if your code reaches for terroir on its own:

bun add @roastery/terroir

Local development (link)

If you're developing beans alongside another project, you can link it locally:

# Inside the beans directory
bun run setup  # builds and registers the link

# Inside your consuming project
bun link @roastery/beans

The Roastery Way

@roastery/beans/way is one import path for the low-ceremony subset of beans — everything needed to model a domain, raise and react to events, and expose behaviour as a use case, without class X extends Entity/Command/ValueObject, a defineEntity/defineCommand/defineMeta override, an interface X extends AccessorsOf<…> {} merge, or a terroir symbol in sight:

import {
  blueprint, entityOf, recordOf,
  defineDomainEvent, defineUseCase,
  defineEventHandler, commands,
} from "@roastery/beans/way";
import { StringVO } from "@roastery/beans/way/collections/value-objects";

// Domain
const BeanPlanted = defineDomainEvent("bean.planted");

const beanProperties = blueprint({ name: StringVO }).done();
class Bean extends entityOf(beanProperties, "bean") {
  plant() { this.raiseEvent(BeanPlanted); }
}

// Use case
class PlantBean extends defineUseCase<typeof plantBeanProperties, Deps, Bean>(plantBeanProperties, "plant-bean") {
  protected async handle({ beans }: Deps): Promise<Bean> {
    const bean = new Bean({ name: this.name });
    bean.plant();
    await beans.save(bean);
    return bean;
  }
}

// Reaction — pass the event's class directly, no InstanceType<...> needed
const LogBeanPlanted = defineEventHandler<typeof BeanPlanted, Deps>(async (event, deps) => {
  deps.logger.log(event.name);
});

// Orchestration — registers the use case, publishes what it raises, runs the reaction
const registry = commands({ plantBean: PlantBean }, { emitter })
  .withDependencies({ beans, logger })
  .on(BeanPlanted, LogBeanPlanted);

const { result } = await registry.plantBean({ name: "Arabica" });

Start without events

The { emitter } above is optional — omit it and the last step asks you to decide nothing about where events go:

import { commands } from "@roastery/beans/way";

const registry = commands({ plantBean: PlantBean }).withDependencies({ beans });

const { result, events } = await registry.plantBean({ name: "Arabica" });

Same spec, same ready-to-run function per key, same CommandResultevents and all. Nothing is given up by starting here: events are still raised by the aggregate and still collected into the result; what is opt-in is publishing them (and, with nowhere to publish, there is no .on() either). Moving up later is one argument — the spec, the dependencies and every defineUseCase stay exactly as they are.

Each option arrives when its own problem does, and they are independent of one another:

// 1. just use cases
commands(spec).withDependencies(deps);

// 2. + atomicity, the day a use case writes a second aggregate
commands(spec, { transaction }).withDependencies(deps);

// 3. + publication and reactions, the day there is somewhere to publish
commands(spec, { transaction, emitter }).withDependencies(deps).on(Event, Handler);

This is not a third layer. domain and application are still the only two layers beans has — every name @roastery/beans/way (and its /collections/* subpath) exports is re-exported verbatim from its original home in one of them; nothing is reimplemented, and the barrel has no behaviour of its own. It's a curated cross-cutting index, picking only the entries whose whole design goal was already minimizing ceremony:

| Concern | From @roastery/beans/way | The precise form underneath | |---|---|---| | Declare properties + rules | blueprint | (same — always used either way) | | Model an entity | entityOf | class X extends Entity<Shape> + defineEntity() + interface merge | | A value | @roastery/beans/way/collections/value-objects (StringVO, EmailVO, UuidVO, …) + its optional/nullable/custom subpaths | same classes, same subpath either way | | A domain event | defineDomainEvent | class X extends DomainEvent + defineName() (+ static readonly payload) | | A use case | defineUseCase | AggregateCommand/aggregateCommandOf/Command — reach here directly once a use case needs more than one aggregate as its result | | React to an event | defineEventHandler | class X implements IEventHandler<Event, Deps> | | Wire it all up | commands ({ emitter } opt-in: publishes + runs reactions) | (same — it already lives in application; way only shortens the path) | | Make a use case atomic | transactional + ITransactionRunner (the transaction option is on commands itself) | @roastery/beans/application/command/decorators · @roastery/beans/domain/repository/types |

The value-object catalog lives one level deeper, at @roastery/beans/way/collections/value-objects (plus /optional, /nullable, /custom) rather than in the root of way itself — flattened into the same barrel as blueprint/entityOf/defineUseCase, its ~75 names would drown the half-dozen that actually shape how a feature is put together. Same split domain/application already draw for their own catalogs, one level down.

Reach past this barrel, into the specific subpath named on the right, the moment a use case stops fitting this shape — its result isn't a single aggregate, or the behaviour touches more than one — or an entity's definition needs to be computed rather than stated. Every one of those escape hatches is documented in full under its own section below.


Entity

Abstract base class that every domain entity extends, driven by a blueprint: a plain object mapping each domain property to its ValueObject or Entity class. The subclass declares only defineEntity() — no constructor, no hand-written schema, no getters.

import { Entity } from "@roastery/beans";
import type { AccessorsOf, EntityDefinition } from "@roastery/beans/domain/entity/types";
import { SlugVO, StringVO } from "@roastery/beans/domain/collections/value-objects";

const postProperties = {
  title: StringVO,
  slug: SlugVO,
};

// biome-ignore lint/correctness/noUnusedVariables: merging with the class below is the use.
interface Post extends AccessorsOf<typeof postProperties> {}
class Post extends Entity<typeof postProperties> {
  protected defineEntity(): EntityDefinition<typeof postProperties> {
    return { properties: postProperties, source: "post" };
  }

  rename(title: string) {
    this.set("title", title); // set/setMany are protected — only reachable from here
  }
}

Usage:

const post = new Post({ title: "Hello", slug: "Hello World" });

post.title;                  // "Hello" — accessor derived from the blueprint
post.slug;                   // "hello-world" — the VO's transform ran
post.id;                     // UUID v7, generated by the base
post.rename("Hi");           // validates, replaces, stamps updatedAt (set returns true if it changed)
post.toJSON();               // plain object
Post.fromJSON(row);          // strict static hydration, identity preserved
Post.demo();                 // fixture without data — every VO on its default

Key rules:

  • defineEntity must be a prototype method, never a class field — the base invokes it during construction, before field initializers run. It must also be pure: fromJSON reads the blueprint through a probe without running any constructor.
  • Identity is optional in the payload, all-or-nothing. Omit id/createdAt/updatedAt entirely for a fresh identity, or provide id and createdAt together (with updatedAt still optional). Half a payload is rejected at compile time and at runtime.
  • The interface Post extends AccessorsOf<…> {} line is what types the accessors. They are installed at runtime regardless; the merge is how TypeScript learns about them. A blueprint key may not collide with an existing member (schema, toJSON, get, set, id, …) — the base throws PropertyNameCollisionException carrying the key.
  • Two hydration paths, and they differ. new Post({ ...row }) validates property by property and ignores unknown keys; Post.fromJSON(row) validates the whole payload against the aggregate schema first, rejecting missing and unknown keys. Use fromJSON for payloads of untrusted origin.
  • Aggregates nest. A blueprint value may be another Entity subclass: accessors return the nested instance (so reads chain), toJSON/fromJSON/schema recurse, and set("author", raw) rebuilds the nested entity from its raw payload. Blueprint cycles are detected and reported as CyclicEntityDefinitionException.
  • The schema is derived, not declared. post.schema is a TypeBox object built from the blueprint (identity fields included), compiled once per class and emitted with additionalProperties: false at every level.

Blueprint rules

A blueprint can also carry the domain's own rules — which properties may be omitted, and how the base fills them. That is what keeps a subclass free of a hand-written constructor even when the domain has defaults and derivations:

import { blueprint } from "@roastery/beans/domain/entity/helpers";
import { BooleanVO, SlugVO, StringVO } from "@roastery/beans/domain/collections/value-objects";

// Domain vocabulary: aliases inherit defineMeta and transform
class TagName extends StringVO {}
class TagSlug extends SlugVO {}
class TagVisibility extends BooleanVO {}

const postTagProperties = blueprint({
  name: TagName,
  slug: TagSlug,
  hidden: TagVisibility,
}).with({
  slug: { derive: (raw) => raw.name },  // omitted? comes from the name
  hidden: { default: false },           // the entity's default, not the VO's
});

// biome-ignore lint/correctness/noUnusedVariables: merging with the class below is the use.
interface PostTag extends AccessorsOf<typeof postTagProperties> {}
class PostTag extends Entity<typeof postTagProperties> {
  protected defineEntity(): EntityDefinition<typeof postTagProperties> {
    return { properties: postTagProperties, source: "post-tag" };
  }
}

new PostTag({ name: "Alan Reis" }); // slug: "alan-reis", hidden: false

blueprint(shape) on its own returns only the builder, so a blueprint has to be closed. When there are no rules, close it with .done() rather than .with({}) — an empty rule map reads like one somebody forgot to fill in:

const readUserProperties = blueprint({ id: UuidVO }).done();

// identical in every way — same object, no rules slot:
const readUserProperties = { id: UuidVO };

Both spellings work; .done() simply lets every blueprint in a codebase open and close the same way, instead of some starting with the helper and others with a bare literal.

The ruled keys become optional in the constructor payload — that is the whole type-level effect. Everything else holds:

| Aspect | Behaviour | |--------|-----------| | Precedence | explicit value > default > derive | | derive input | the payload with every default already applied, and every sibling already built and normalised (a SlugVO sibling reads back slugified) | | Ordering | derivations run in blueprint order and see the earlier ones; a derivation reading a key derived after it gets undefined, and the property's validation rejects it | | demo() | rules apply, so fixtures stay coherent — PostTag.demo() yields hidden: false (the entity's default, not BooleanVO's true) and a slug derived from the demo name | | set / setMany | unchanged — rules do not re-fire; tag.set("name", …) leaves slug alone | | Schema and fromJSON | unchanged — rules act on input only, toJSON() always emits every property, and hydration stays as strict as ever | | Nesting | a nested entity's rules apply to its raw payload, so new Post({ tag: { name: "Alan Reis" } }) is as valid nested as it is on its own — including through set("tag", raw) and in demo() |

A key backed by an Optional*VO (or any optionalVO(schema)) gets that same optional-payload treatment without needing a rule at allsubtitle?: string compiles on its own. Nullable*VO/nullableVO keys do not: null is a value to state, not an omission.

default and derive are mutually exclusive, and a rule must name a property the blueprint declares — both are compile errors, and both throw InvalidEntityDefinitionException at runtime for plain-JS callers.

Two phases (blueprint(…).with(…)) because a literal cannot reference its own typeof: the first call fixes the shape, which is what makes raw fully typed inside with. A blueprint with no rules stays a plain object literal, exactly as before.

Where the Rules symbol lives. In @roastery/terroir/symbols, exactly like the other slots below — terroir 0.2.1 ships it, so this package declares no symbol of its own. grep -rn 'Symbol("' src returns nothing, and it must stay that way: symbol equality is by reference, so a local redeclaration would key a different slot and every rule would silently stop resolving.

Property rules with onSet

blueprint().with() produces values. onSet() checks them: a protected, empty-by-default hook on Entity and DomainRecord that declares one handler per blueprint key, run on the raw value just before that property is built — on construction and on every mutation alike.

class Post extends entityOf(postProperties, "post") {
	protected override onSet(): SetHandlersOf<typeof postProperties> {
		return {
			title: (value, raw) => {
				if (raw.hidden && value.length > 40)
					throw new InvalidPropertyException("title", "post");
			},
		};
	}

	public rename(title: string): void {
		this.set("title", title); // the handler runs here too
	}
}

A handler returns void: it enforces by throwing, and the exception is the domain's own choice. It never rewrites the value — normalising stays the value-object's transform.

| Aspect | Behaviour | |--------|-----------| | When | new X({…}), fromJSON, demo(), set and setMany — every path that sets a value | | Which keys | only a key that has a raw value to set: an explicit payload value, a blueprint default, or a derive result. A key falling back to its own value-object's default fires nothing, so demo() fires only the derived keys | | value | the raw value, typed from the property's class — string for a StringVO key, the nested payload for an entity- or record-valued one | | raw | the same read-only view a derive rule gets. On construction: blueprint order, earlier siblings already normalised, later ones still undefined. On mutation: the current values overlaid by the batch being written | | Ordering | before the value-object validates — the business rule precedes the schema | | Atomicity | handlers all run before the build phase, so one that throws leaves the entity untouched and stamps no updatedAt | | Per write, not per change | on mutation it fires for every key named, even one whose value turns out to be unchanged; whether a value differs is only known after it is built |

Two rules, the same ones defineEntity/defineRecord carry: onSet must be a prototype method, never a class field, and it must be pure — the base invokes it inside the constructor, before the context slot exists, so it must not read this. That is exactly why the handlers take raw as an argument. The class-field mistake is only half detectable (during construction the field does not exist yet to be found), so the guard throws InvalidEntityDefinitionException on the first mutation rather than letting the rule stay silently dead.

DomainRecord.onSet() is identical, typed RecordSetHandlersOf<…>. Command has no onSet: it never mutates, and its input validation is already re-tagged at the application boundary.

Slot symbols

The bases key their internal slots with the ecosystem's well-known symbols, which live in @roastery/terroir/symbols — not in this package. Symbol equality is by reference, so one declaration site is what lets beans write a slot and a consumer read that same slot:

| Symbol | Purpose | |--------|---------| | Context | Identification context of a ValueObject / built property map of an Entity | | Meta | Schema + demo default of a ValueObject | | Properties | Blueprint of an Entity | | Rules | Per-property domain rules (default / derive) a blueprint carries | | Source | Entity-type name of an Entity (e.g. "post") | | Storage | Per-instance transient store of an Entity | | Events | Per-instance domain-event buffer of an Entity, drained by pullDomainEvents() | | Demo | Sentinel that turns a constructor call into demo mode — used by the demo() statics |

EntityStorage

Each entity instance has a built-in key-value store (string → string) under the protected [Storage] slot. Useful for transient, non-domain state — it never reaches toJSON() or the schema, and starts empty on fromJSON/demo. Expose whatever facade fits your entity:

import { Storage } from "@roastery/terroir/symbols";

class Post extends Entity<typeof postProperties> {
  // ...

  public addTag(tag: string): void {
    const current = this[Storage].get("tags") ?? "";
    this[Storage].set("tags", current ? `${current},${tag}` : tag);
  }

  public getTags(): string[] {
    return (this[Storage].get("tags") ?? "").split(",").filter(Boolean);
  }
}

The storage API is intentionally minimal:

| Method | Description | |--------|-------------| | get(key) | Returns the value, or null if the key does not exist (or a fallback's result, with the two-argument overload) | | set(key, value) | Stores a value under the given key and returns it | | del(key) | Removes the entry for the given key | | clear() | Drops every entry — what destroy() calls to release the transient state |

Domain events

Each entity instance also has a built-in event buffer. Call the protected raiseEvent from a business method to record that something domain-meaningful happened; set/setMany never raise events on their own — nothing fires automatically. The base stamps occurredAt and aggregateId itself, so every event carries at least the id of the entity that raised it, and no subclass can get that wrong:

class Order extends Entity<typeof orderProperties> {
  // ...

  public confirm(): void {
    this.set("status", "confirmed");
    this.raiseEvent({ name: "order.confirmed", total: this.total });
  }
}

const order = new Order({ /* ... */ });
order.confirm();
order.pullDomainEvents();
// [{ name: "order.confirmed", total: 42, occurredAt: "...", aggregateId: order.id }]

pullDomainEvents() is public: call it after a successful repository.save(order) to drain and dispatch the events. beans stops at the buffer — there is no event bus or dispatcher in this package, so what happens with the drained array is entirely up to the consuming application.

By default it drains the whole aggregate: the root's buffer first, then each nested entity, record and wrapper, recursively, in blueprint order. A nested entity is a participant in the aggregate, not a second root, so its events belong to the root's pull. Pass { deep: false } to restrict the drain to the root's own buffer:

post.pullDomainEvents();                // Post + its nested Author, recursively
post.pullDomainEvents({ deep: false }); // only what Post itself raised

The default matters most when a blueprint holds an entity carrying lifecycle decorators — a decorated nested entity raises on its own construction, and the old shallow default left that event in a buffer nobody read. collectDomainEvents (application layer) has always pulled deep for exactly this reason.

For an event with its own payload fields, DomainEvent (its own pillar, @roastery/beans/domain/domain-event) is an optional abstract base that saves you from repeating the object literal at every call site. A subclass declares only defineName(); occurredAt is stamped automatically, and the constructor's only required argument is aggregateId:

import { DomainEvent } from "@roastery/beans/domain/domain-event";
import { Entity } from "@roastery/beans/domain/entity";

class OrderConfirmed extends DomainEvent {
  public constructor(
    aggregateId: string,
    public readonly total: number,
  ) {
    super(aggregateId);
  }

  protected defineName(): string {
    return "order.confirmed";
  }
}

class Order extends Entity<typeof orderProperties> {
  // ...

  public confirm(): void {
    this.set("status", "confirmed");
    this.raiseEvent(new OrderConfirmed(this.id, this.total));
  }
}

raiseEvent still stamps occurredAt/aggregateId on the way into the buffer regardless of what the passed event already carries, so a DomainEvent instance and a plain { name, ...payload } object work the same way there — DomainEvent is sugar, not a different contract.

When an event carries no payload of its own — its constructor is exactly DomainEvent's, taking only aggregateIdraiseEvent also accepts the bare class reference, no new required. It builds the instance itself, passing this.id:

class OrderCancelled extends DomainEvent {
  protected defineName(): string {
    return "order.cancelled";
  }
}

class Order extends Entity<typeof orderProperties> {
  // ...

  public cancel(): void {
    this.set("status", "cancelled");
    this.raiseEvent(OrderCancelled); // no `new OrderCancelled(this.id)` needed
  }
}

This only works for a constructor shaped new (aggregateId: string) => …OrderConfirmed above, whose constructor also takes total, is not assignable to that shape, so TypeScript rejects this.raiseEvent(OrderConfirmed) at compile time and routes you back to this.raiseEvent(new OrderConfirmed(this.id, this.total)).

For a payload-less event like OrderCancelled, writing the subclass is boilerplate — defineDomainEvent(name) builds the same class from just its name:

import { defineDomainEvent } from "@roastery/beans/domain/domain-event";

const OrderCancelled = defineDomainEvent("order.cancelled"); // same class shape as above

class Order extends Entity<typeof orderProperties> {
  // ...

  public cancel(): void {
    this.set("status", "cancelled");
    this.raiseEvent(OrderCancelled);
  }
}

Call it at module scope, once — the same rule the custom value objects already follow: each call mints a fresh class, so two calls with the same name produce unrelated classes and instanceof does not relate them.

Declaring what an event carries

An event can declare a payload with a second argument, and then the entity fills it in on its own. Three forms:

import { defineDomainEvent } from "@roastery/beans/domain/domain-event";
import { Json, SafeJson } from "@roastery/terroir/symbols";

const OrderShipped = defineDomainEvent("order.shipped", { code: StringVO, to: Address });
const OrderAudited = defineDomainEvent("order.audited", SafeJson);
const OrderDumped  = defineDomainEvent("order.dumped", Json);
const OrderPlain   = defineDomainEvent("order.plain");  // no payload — the default

| Second argument | What lands under event.payload | fromJSON | |---|---|---| | (omitted) | nothing — the key is absent entirely | — | | a shape | reshapeTo(shape, entity), with the root's identity dropped | yes | | Json | entity.toJSON() — complete, unredacted | no | | SafeJson | entity.toSafeJSON() — every sensitive key masked | no |

The declaration lives on the event class and nowhere else. The lifecycle and method decorators take no second argument — they read the static off the class they already receive:

@onCreate(OrderPlaced)
class Order extends entityOf(orderProperties, "order") {
  @emit(OrderShipped)
  public ship(): void { /* ... */ }
}

const order = new Order({ code: "A1", token: "hunter2", to: { city: "SP" } });
order.ship();

order.pullDomainEvents()[1];
// {
//   name: "order.shipped",
//   occurredAt: "2026-08-25T01:39:43.160Z",
//   aggregateId: "01a03692-…",              // the root id lives here, not in the payload
//   payload: { code: "A1", to: { id: "01a03692-…", createdAt: "…", city: "SP" } },
// }

On the far side of a bus, the same class validates what arrived — one declaration, both ends:

registry.on(OrderShipped, defineEventHandler<OrderShipped>(async (event) => {
  const { code, to } = OrderShipped.fromJSON(event.payload); // throws InvalidDomainDataException on a mismatch
}));
  • The shape is an allowlist, and nothing in it is redacted. The cut comes from toJSON(), never toSafeJSON(), so the payload stays hydratable — which means a key marked sensitive and named in a shape goes onto the bus in the clear. Leaving it out of the shape is how it stays out, and that is the point of declaring one: a field nobody remembered to mark sensitive is still absent from a shape nobody added it to.
  • SafeJson redacts, and so does not round-trip. Target.fromJSON(payload) will not rebuild what toSafeJSON masked. That form is for consumption and audit, not hydration.
  • Only the shape form gets fromJSON. Json and SafeJson carry the raising entity's whole serialization, and an event class does not know which entity raised it — there is no static format to check an arrival against. The asymmetry is the argument for preferring a shape.
  • The root's identity is dropped from a shape's cutaggregateId already carries it. A nested aggregate keeps its own, which is what lets the payload feed that aggregate's fromJSON one level down. Json/SafeJson keep everything, because the directive means the entity's serialization.
  • A payload shape is classes only — value objects, entities, records, wrappers. Unlike a reshape target it may not nest an anonymous shape ({ author: { name: StringVO } }), because the same object has to serve as both the target of the cut and the blueprint of the check, and an anonymous target carries no schema. Name the nested class instead.
  • A payload-carrying event is still a bare class. Its constructor is unchanged — new (aggregateId: string) — because the payload comes from the entity, not from the constructor. That is why every decorator accepts one, and why this.raiseEvent(OrderShipped) still works without new.
  • The hand-written form declares the same static, read structurally, and a subclass inherits it:
class OrderShipped extends DomainEvent {
  public static readonly payload = { code: StringVO, to: Address };

  protected defineName(): string { return "order.shipped"; }
}

An event whose constructor takes extra arguments — OrderConfirmed above — still needs the hand-written subclass, and is raised with new.

There is a runnable walk-through of all three forms side by side — what each one carries, what identity survives a shape's cut, what SafeJson masks and what Json does not, and the two things the type system refuses — in examples/testing-event-payload.ts:

bun examples/testing-event-payload.ts

Destroying an entity

destroy() marks an entity destroyed and releases its transient [Storage]. It's a lightweight marker, not a hard guard — there's no way to force garbage collection from inside the entity itself, so get/set/toJSON/etc. keep working afterwards. Idempotent: a second call is a no-op.

const order = new Order({ /* ... */ });

order.isDestroyed; // false
order.destroy();
order.isDestroyed; // true
order.destroy();   // no-op — already destroyed

Lifecycle decorators

Three class decorators, from @roastery/beans/domain/entity/decorators, declare which event each point of an entity's lifecycle raises automatically — so a subclass stops having to call this.raiseEvent(...) by hand at each of those points. Stackable: a class may carry all three, each touching only its own concern.

import { onCreate, onUpdate, onDelete } from "@roastery/beans/domain/entity/decorators";

@onCreate(UserCreated)
@onUpdate(UserUpdated)
@onDelete(UserDeleted)
class User extends Entity<typeof userProperties> {
  protected defineEntity(): EntityDefinition<typeof userProperties> {
    return { properties: userProperties, source: "user" };
  }

  rename(name: string) {
    this.set("name", name); // set/setMany are protected — only reachable from here
  }
}

const user = new User({ name: "Alan" }); // raises UserCreated
user.rename("Alan Reis");                // raises UserUpdated (only because something changed)
user.destroy();                          // raises UserDeleted (only on the first call)

User.fromJSON(row);                      // raises nothing — hydration is not a domain fact
new User({ id, createdAt, name: "..." }); // raises nothing either — same rule as fromJSON
  • onCreate fires on a fresh construction — no id/createdAt in the payload, including .demo().
  • onUpdate fires when set/setMany actually changes something — it reads the boolean those return, so two real mutations in the same millisecond fire twice; a no-op set to the same value does not fire it at all.
  • onDelete fires the first time destroy() is called; a repeated call does not fire it again.
  • Each decorator takes a DomainEvent subclass reference whose constructor takes only aggregateId — the same bare-class form raiseEvent already accepts without new. That includes an event declaring a payload: the payload comes from the entity, not from the constructor, so the class stays bare and the decorator takes no second argument.
  • The decorated class keeps its own name, so stack traces and DI containers still identify it as itself.
  • On a nested entity, the root's pull is what drains it. A decorated class used as another blueprint's property raises into its own buffer, and onCreate fires every time the parent builds it. parent.pullDomainEvents() walks into it by default — but { deep: false } there would strand the event, and so would decorating something that is not an aggregate root.
  • There is deliberately no onRead. Rebuilding an entity from storage changes nothing, so it is not a domain fact — and an event raised there would ride along in every CommandResult, making a command that merely loads an aggregate to delete it report a spurious "read" next to the real event. Audit reads where reads actually happen: in the repository.

Method decorators

Two method decorators, from the same @roastery/beans/domain/entity/decorators subpath, raise an event around an arbitrary instance method — not a fixed lifecycle point like the three decorators above, any business operation. They cover the two outcomes a method has: emit raises once it has run to completion, onError raises only if it throws.

import { emit, onError, fromClass } from "@roastery/beans/domain/entity/decorators";

class Order extends Entity<typeof orderProperties> {
  protected defineEntity(): EntityDefinition<typeof orderProperties> {
    return { properties: orderProperties, source: "order" };
  }

  @emit(OrderShipped)
  public ship(): void {
    // business logic
  }

  @onError(OrderShippingFailed) // bare class — same reading as onCreate/onUpdate/onDelete
  public shipOrAbort(): void {
    // business logic that may throw
  }

  @onError((error) => new OrderShippingFailed("", String(error))) // factory — carries the error
  public shipOrFail(): void {
    // business logic that may throw
  }
}

order.ship();        // runs ship(), then raises OrderShipped
order.shipOrAbort(); // if it throws: raises a fresh OrderShippingFailed, then re-throws
order.shipOrFail();  // if it throws: raises OrderShippingFailed built from the error, then re-throws
  • emit fires once the method body has returned — never if it throws; there is no try/catch, so a thrown exception simply propagates and the event never raises. The event is a consequence of the operation having succeeded, which is why there is no "before" counterpart: an event raised before the work happens claims a domain fact that may not turn out to be one.
  • emit does not publish. emit is also the one member of IEventEmitter, one layer up, where it does mean "publish onto the bus". The decorator only raises into the entity's own buffer, which leaves through pullDomainEvents — same word, different layer, different contract.
  • onError fires only if the method body throws — it wraps the call in a try/catch, raises the event, then always re-throws the original error. It never swallows the failure; the event is a side channel only. A different onError from a registry's own (see Commands), which isolates a throwing reaction by swallowing it — this one wraps an Entity method and never swallows.
  • onError accepts either a bare class (@onError(SomeEvent), the same payload-less form the other four decorators take) or a factory (@onError((error) => ...), for an event that folds the caught error into its own payload). A bare class is normalized internally through fromClass — exported on its own for the times that factory value is needed detached from the decorator call. Reach for the factory only when the event actually needs the error.
  • emit and onError on the same method are mutually exclusive per call, by construction: a clean run reaches only emit's raise, a throw reaches only onError's. Two stacked emits both fire, the one written closest to the method first — TC39 applies method decorators bottom-up, so it wraps innermost.
  • Neither awaits a returned Promise — an async method's emit/onError react once the synchronous call returns (or synchronously throws), not once the promise settles or rejects.
  • Applies to instance methods only; decorating a static method is not guarded at compile time or runtime.

Asserting a blueprint's shape

entityHas — and its compile-time twin EntityHas — answer one question about a class: does this blueprint carry these keys, backed by these classes? It exists to gate code on a shape rather than on a name, most often a port method or a generic helper that only makes sense for aggregates carrying a particular key.

const postProperties = blueprint({
  title: StringVO,
  authorId: PostAuthorId,       // class PostAuthorId extends UuidVO {}
  tags: arrayOf(PostTag),
  type: optionalOf(PostType),
  price: Money,                 // a DomainRecord
}).done();

class Post extends entityOf(postProperties, "post") {}

entityHas(Post, { authorId: UuidVO });          // true  — a subclass satisfies its parent
entityHas(Post, { price: Money });              // true  — a record key, like any other
entityHas(Post, { tags: arrayOf(PostTag) });    // true  — a fresh wrapper still matches
entityHas(Post, { type: optionalOf(PostType) });// true
entityHas(Post, { type: PostType });            // false — the key holds an optionalOf
entityHas(Post, { title: SlugVO });             // false — different schema
entityHas(Post, { publishedAt: UuidVO });       // false — no such key

// The type resolves to the boolean literal, so it composes with a conditional
type WithAuthorLookup = EntityHas<typeof Post, { authorId: typeof UuidVO }> extends true
  ? { findByAuthorId(id: string): Promise<Post | null> }
  : {};
  • All four blueprint kinds are answerable — a value-object, a nested entity, a nested record and a multiplicity wrapper around any of those three. The expected shape is written in the same vocabulary the blueprint is written in.
  • A subclass satisfies its parent, in every kind and inside a wrapper too: arrayOf(VipTag) satisfies arrayOf(Tag). The check is structural, so a domain-vocabulary alias that adds nothing of its own is indistinguishable from the class it aliases — which is what makes the aliasing pattern work here rather than a loophole.
  • Multiplicity is part of the shape. { tags: PostTag } does not match tags: arrayOf(PostTag), and optionalOf does not match nullableOf. Write the wrapper the blueprint writes.
  • The wrapper you pass is read, never used — only its wraps and wrapperKind statics. Writing arrayOf(PostTag) inline in the argument is therefore safe, unlike in a blueprint, where the usual rule holds: call a class-returning factory once, at module scope. Two separate calls to a value-object factory (customRecordVO() twice) are one type and two objects, so the type says true where the runtime says false — the one place the two halves cannot agree.
  • An empty expected shape is true, vacuously. Nothing constructs an instance: the blueprint is read through the same Object.create probe fromJSON uses.

Reshaping onto a target shape

entityHas answers is this key backed by this class? and hands back a boolean. reshapeTo asks the neighbouring question — can this instance be cut down to this shape? — and hands back the payload. It checks the source against the target, serializes it, and drops everything the target did not ask for.

import { reshapeShape, reshapeTo } from "@roastery/beans/domain/entity/helpers";

class AuthorCard extends entityOf({ name: StringVO }, "author-card") {}
class TagCard extends entityOf({ slug: SlugVO }, "tag-card") {}

const cardShape = reshapeShape({ title: StringVO, author: AuthorCard, tags: arrayOf(TagCard) });

reshapeTo(cardShape, post);
// {
//   id, createdAt,                       // identity rides along
//   title: "A Post",
//   author: { id, createdAt, name: "Ada" },      // bio and email cut
//   tags: [{ id, createdAt, slug: "one" }, …],   // cut item by item
// }

PostCard.fromJSON(reshapeTo(cardShape, post)); // the intended use

reshapeShape is the identity function blueprint(...).done() is — it exists for const inference and to name the concept. A target is not a blueprint: it never reaches entityOf/recordOf, it carries no rules, and a key may nest another target instead of naming a class. That last part is what saves declaring a throwaway subclass per level:

const nameOnly = reshapeShape({ name: StringVO });

const cardShape = reshapeShape({
  title: StringVO,
  author: nameOnly,       // source: `author: Author`             -> one object
  contributors: nameOnly, // source: `contributors: arrayOf(Author)` -> an array
  editor: nameOnly,       // source: `editor: optionalOf(Author)` -> the object or undefined
});

A class states multiplicity; a nested target does not. A class in the target still has to match the source's multiplicity exactly. A nested target says nothing about it and adopts the source's — and says nothing about identity either, so that too comes from the source: a nested entity contributes it, a nested record has none to give. A nested target against a key holding a value object throws: there is no aggregate to cut.

  • The instance is never touched. The return is a fresh DTO — what toJSON() would have produced, minus the keys outside the target blueprint.
  • The cut is recursive. A nested entity, a nested record and every item of a wrapper are narrowed too, each against the class or nested target declared for that key.
  • Nested classes are matched structurally; value-objects nominally. AuthorCard need share nothing with the Author the source declares — that is the whole point. At the value-object leaf the rule is entityHas's, shared with it verbatim: the declared class or a subclass of it. So two separate customRecordVO() calls do not match — mint the class once, at module scope, and reference it from both sides.
  • Identity rides along when the source is an Entity, at the root and at every nested level, which is what makes the result feed another entity's fromJSON. A DomainRecord has none to give and contributes none. The return type says so too — ReshapedTo reads it off the source's own toJSON.
  • Multiplicity is part of the shape for a class target, and for the same reason: optionalOf(TagCard) does not accept a key holding an arrayOf. A nested target is the deliberate exception — it declares no multiplicity and inherits the source's.
  • A mismatch throws InvalidPropertyException, its property carrying the dotted path to the offending key ("author.twitter", "tags[].headline"), before anything is projected.
  • Rules do not participate, and nothing is redacted. default/derive act on construction input, not on an instance already built, so a default cannot stand in for a key the source lacks. The cut is taken from toJSON(), not toSafeJSON() — redacting would break the round trip that makes the payload hydratable.

Comparing entities

equals answers identity: same class, same id, state irrelevant. sameStateAs answers state: one comparison per blueprint key, with id, createdAt and updatedAt left out of it.

const stored = await posts.findById(post.id);

stored.rename("another title");

post.equals(stored);      // true  — same identity, different state
post.sameStateAs(stored); // false — the title moved

That split is what deepEquals(a.toJSON(), b.toJSON()) could never give you: toJSON() carries the timestamps, so it answers neither question. deepEquals is still exported and still right for what it is — structural equality over a DTO.

Key rules:

  • The type check is the class itself, by exact prototype — not instanceof. That keeps the relation symmetric: a DraftPost extends Post sharing an id is not equal to a Post in either direction. The corollary is the rule every blueprint already follows — call entityOf (and every other class-returning factory) once, at module scope. Two calls with identical arguments mint two classes, and instances of them are never equal.
  • Adoption accepts a subclass; equality refuses one. Handing a nested key a DraftPost extends Post adopts that instance without a word — isBuiltInstance tests instanceof, the same ruler entityHas and reshapeTo use — and from then on it compares equal to no plain Post. Both rulers are right for their own question; this is the one place they point opposite ways.
  • Every key answers with its own pillar's rule. sameStateAs delegates per key: a value-object compares by value, a nested record by its own properties, a wrapper item by item — and a nested entity by its id.
  • So renaming post.author does not change post.sameStateAs(other). What the post holds is the author's identity, and that did not move. Ask post.author itself when the question is about the author's state.
  • It is not a serialization comparison, deliberately. Through toJSON() it would fold in the identity fields of every nested entity; through toSafeJSON() it would compare redaction placeholders and call two different secrets the same. It compares the real values, and reveals nothing — the answer is one boolean.
  • equals and sameStateAs are reserved blueprint keys — here and, for equals, in DomainRecord too. Declaring either throws PropertyNameCollisionException, like schema, toJSON and id. The check is key in prototype, so a name is reserved only in the pillar that declares the member: a Command has no equals and reserves neither.

DomainRecord

An Entity minus identity. No id, no createdAt, no updatedAt, no repository port derived from it — a record is never a row. Everything else an entity has, it has: a schema derived from the blueprint, strict hydration, demo(), blueprint rules, redaction, nesting, and mutation through set/setMany.

It exists for the composite domain values that deserve behaviour. Without it, a Money is a customObjectVO that validates its shape and can do nothing else; with it, Money has verbs and the ubiquitous language survives into the type.

import { recordOf } from "@roastery/beans/domain/record";
import { IntegerVO, StringVO } from "@roastery/beans/domain/collections/value-objects";

const moneyProperties = { amount: IntegerVO, currency: StringVO };

class Money extends recordOf(moneyProperties, "money") {
  public add(cents: number): boolean {
    return this.set("amount", this.amount + cents); // protected — only the verbs mutate
  }

  public isFree(): boolean {
    return this.amount === 0;
  }
}

const price = new Money({ amount: 1000, currency: "BRL" });

price.amount;       // 1000 — typed accessor
price.add(500);     // true, it changed
price.toJSON();     // { amount: 1500, currency: "BRL" } — no identity fields
Money.demo();       // built from the declared defaults

The hand-written form works too, and is the one to reach for when the definition is computed rather than stated — extends DomainRecord<typeof moneyProperties> plus defineRecord() and the interface Money extends RecordAccessorsOf<typeof moneyProperties> {} merge, exactly mirroring Entity.

As a blueprint key

A record can be a key of an entity, a command or another record, and its own blueprint accepts value-objects, entities and records alike.

class Post extends entityOf({ title: StringVO, price: Money }, "post") {}

const post = new Post({ title: "Beans", price: { amount: 1000, currency: "BRL" } });

post.price;            // the Money instance — reads chain into its verbs
post.price.add(500);   // works
post.price.isFree();   // false

Key rules:

  • defineRecord must be a prototype method, never a class field, and must be pure — the same trap and the same InvalidEntityDefinitionException guard as defineEntity/defineMeta.
  • set/setMany are protected. Nothing outside the class may mutate a record; only the verbs it declares. setMany is atomic (validate all, build all, then assign), and its returned boolean is the only signal that something changed — there is no updatedAt to compare.
  • A blueprint key may not be id, createdAt or updatedAt. A record does not have identity and may not fake it; the attempt throws PropertyNameCollisionException.
  • toJSON() never redacts (it is the persistence contract and must round-trip through fromJSON); toSafeJSON(), toString() and the inspect hook do — the same asymmetry Entity has, not Command's.
  • A record raises no domain events. There is no [Events] slot and no raiseEvent: an event belongs to an aggregate root, and a record has no identity to report as its aggregateId. It does forward pullDomainEvents() into the entities it nests, so their buffers are never stranded behind it; with { deep: false } it always returns [].
  • No [Storage], no destroy(). Both exist for something with a lifecycle of its own; a record's lifecycle is its owner's.
  • The lifecycle and method decorators do not apply. All five end in raiseEvent. onUpdate is the tempting one — a record does have setMany — and it will fail at call time.
  • equals compares by value, key by key — a record has no identity, so it has only the one question an entity splits into two. Each key answers with its own pillar's rule, which means a nested entity compares by its id. See Comparing entities; equals is a reserved blueprint key here too.
  • Mutating a nested record does not stamp the parent's updatedAt. post.price.add(500) changes the record in place; post.set("price", raw) is what stamps. Same as a nested entity, but far more visible here, since a record exists to have verbs.

Multiplicity wrappers

arrayOf, optionalOf and nullableOf take a blueprint class — a value-object, an entity or a record — and return another blueprint class holding many of it, optionally one, or one-or-null. They change the multiplicity of a key and nothing else.

They exist so multiplicity stops leaking into the ubiquitous language. Without them, "a post has many tags" has to be spelled as a TagListVO or a PostTags record — names that are not domain concepts, only variations on one.

import { arrayOf, nullableOf, optionalOf } from "@roastery/beans/domain/wrapper/helpers";
// also: import { arrayOf } from "@roastery/beans/way";

const postProperties = blueprint({
  title: StringVO,
  tags: arrayOf(PostTag),      // many
  author: optionalOf(Author),  // one, or nothing
  editor: nullableOf(Author),  // one, or an explicit null
}).done();

class Post extends entityOf(postProperties, "post") {}

const post = new Post({
  title: "Beans",
  tags: [{ name: "Alan Reis" }], // the inner blueprint's own rules run per item
  editor: null,                  // required: null is stated, never omitted
});                              // `author` is omittable

post.tags;                  // readonly PostTag[] — the instances themselves
post.tags[0].slug;          // "alan-reis" — the item's `derive` rule ran
post.tags[0].rename("Bob"); // the item's verbs stay reachable
post.author;                // undefined
post.editor;                // null

Construction relaxes item by item exactly as the unwrapped key would: a wrapped entity's identity stays optional-all-or-nothing per item, and its ruled and optionalVO-backed keys stay omittable per item. InputValueOf is the same type either way, so this holds at every depth.

Key rules:

  • Reads are unwrapped, and there is no .add(). post.tags is the list itself, not a collection object with verbs — a wrapper states a multiplicity, it does not become a domain concept. Appending therefore replaces the whole list through set, and the existing items go back as they are: a value that is already an instance of the wrapped class is adopted, not rebuilt.
    post.set("tags", [...post.tags, { name: "new" }]);   // built items adopted, raw item built
    post.set("tags", [...post.tags, new PostTag({ name: "new" })]); // same, built ahead of time
    Adoption is what preserves each item's id, its state and any events it had buffered. A serialized item (tag.toJSON()) still rebuilds, and omitting an item's identity there mints a new one. Same contract set already has on a nested entity key, only more visible in a list. The test is instanceof, so a subclass of the wrapped class is adopted too — and then compares equal to nothing, since equals takes the exact prototype. The price of adoption is aliasing: one instance can now sit in two parents, and mutating it shows in both.
  • optionalOf and nullableOf are not interchangeable. null never extends undefined: an optionalOf key is omittable and drops out of the schema's required, a nullableOf key stays required and must be stated. The usual undefined (request body) versus null (database column) split — the same one optionalVO and nullableVO draw one level down.
  • A drain reaches into the contents. post.pullDomainEvents() walks every item, so an entity inside an arrayOf is never stranded. { deep: false } returns only the owner's own events.
  • demo() yields an empty container[], undefined or null. A fixture with items is written by passing them.
  • The derived schema carries the multiplicity: t.Array(inner), or t.Union([inner, t.Undefined()]) / t.Union([inner, t.Null()]). fromJSON therefore still demands a complete payload per item, identity included.
  • A wrapped key derives no repository method. A list is not a predicate, and an optional entity is the nested-entity case with an extra state, so findByTags and findByAuthor are compile errors. Filter by a scalar the aggregate owns.
  • Uniqueness inside a list is not checked, and is not the list's business. unique is an invariant of a set of rows, declared on the inner class and enforced by whoever implements that class's repository port. arrayOf(PostTag) will happily hold two tags with the same slug.
  • Call the factories at module scope, once. Each call mints a fresh class and a fresh schema, like every other class factory here.
  • A wrapper does not wrap a wrapper. arrayOf(arrayOf(Tag)) is not part of the vocabula