@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
Maintainers
Keywords
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.
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 builtThat 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 baseExtending 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
repositorypillar ships the contract and nothing else:RepositoryOfand theICan*capability types are 100% type-only — no factory, no symbol, no runtime, not one byte emitted.toJSON/fromJSONare still the persistence boundary, and the adapter on the other side is still yours to write. The one implementation that does ship isinMemoryRepositoryOf, and it lives behind@roastery/beans/testingprecisely so it can't be mistaken for one. - No DI container.
commandsonly 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.
raiseEventbuffers,pullDomainEventsdrains,collectDomainEventshands the array to aCommand'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.
Commandis 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, atomicset/setManywith automaticupdatedAtstamping, typed accessors, nested aggregates, a transient[Storage]slot, a domain-event buffer, anddestroy().id(UUID v7),createdAtandupdatedAtcome built in. - DomainRecord (domain) — An
Entityminus identity: noid/createdAt/updatedAt, never a row, but everything else — blueprint-derived schema, strict hydration,demo(), blueprint rules, redaction, andprotectedset/setMany. For the composite domain values that deserve verbs (Money,Address,DateRange) instead of being flattened into acustomObjectVO. 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
Entityraises, plusdefineDomainEvent(name, payload?)(a factory building an event class from its name, and optionally the payload it carries — a shape,JsonorSafeJson), 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 —emitonce it has run to completion,onErrorwhen 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 writeshandle()instead. - Repository (domain) — Type-only ports derived from an entity's blueprint:
RepositoryOf<typeof User, Spec>builds the contract an adapter implements, out of granularICan*capabilities a use case asks for in itsDeps.findByEmailexists only because the entity declaresemail. 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 ofCommandsubclasses by their declared dependencies, entirely at compile time, and hands back a ready-to-run bound function per command viaget()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 typescriptImporting 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/terroirLocal 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/beansThe 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 CommandResult — events 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 defaultKey rules:
defineEntitymust be a prototype method, never a class field — the base invokes it during construction, before field initializers run. It must also be pure:fromJSONreads the blueprint through a probe without running any constructor.- Identity is optional in the payload, all-or-nothing. Omit
id/createdAt/updatedAtentirely for a fresh identity, or provideidandcreatedAttogether (withupdatedAtstill 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 throwsPropertyNameCollisionExceptioncarrying 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. UsefromJSONfor payloads of untrusted origin. - Aggregates nest. A blueprint value may be another
Entitysubclass: accessors return the nested instance (so reads chain),toJSON/fromJSON/schemarecurse, andset("author", raw)rebuilds the nested entity from its raw payload. Blueprint cycles are detected and reported asCyclicEntityDefinitionException. - The schema is derived, not declared.
post.schemais a TypeBox object built from the blueprint (identity fields included), compiled once per class and emitted withadditionalProperties: falseat 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: falseblueprint(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 all — subtitle?: 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
Rulessymbol 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("' srcreturns 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 raisedThe 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 aggregateId — raiseEvent 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(), nevertoSafeJSON(), so the payload stays hydratable — which means a key markedsensitiveand 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 marksensitiveis still absent from a shape nobody added it to. SafeJsonredacts, and so does not round-trip.Target.fromJSON(payload)will not rebuild whattoSafeJSONmasked. That form is for consumption and audit, not hydration.- Only the shape form gets
fromJSON.JsonandSafeJsoncarry 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 cut —
aggregateIdalready carries it. A nested aggregate keeps its own, which is what lets the payload feed that aggregate'sfromJSONone level down.Json/SafeJsonkeep 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 whythis.raiseEvent(OrderShipped)still works withoutnew. - 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.tsDestroying 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 destroyedLifecycle 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 fromJSONonCreatefires on a fresh construction — noid/createdAtin the payload, including.demo().onUpdatefires whenset/setManyactually changes something — it reads thebooleanthose return, so two real mutations in the same millisecond fire twice; a no-opsetto the same value does not fire it at all.onDeletefires the first timedestroy()is called; a repeated call does not fire it again.- Each decorator takes a
DomainEventsubclass reference whose constructor takes onlyaggregateId— the same bare-class formraiseEventalready accepts withoutnew. 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
onCreatefires 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 everyCommandResult, 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-throwsemitfires once the method body has returned — never if it throws; there is notry/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.emitdoes not publish.emitis also the one member ofIEventEmitter, one layer up, where it does mean "publish onto the bus". The decorator only raises into the entity's own buffer, which leaves throughpullDomainEvents— same word, different layer, different contract.onErrorfires only if the method body throws — it wraps the call in atry/catch, raises the event, then always re-throws the original error. It never swallows the failure; the event is a side channel only. A differentonErrorfrom a registry's own (see Commands), which isolates a throwing reaction by swallowing it — this one wraps anEntitymethod and never swallows.onErroraccepts 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 throughfromClass— 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.emitandonErroron the same method are mutually exclusive per call, by construction: a clean run reaches onlyemit's raise, a throw reaches onlyonError's. Two stackedemits both fire, the one written closest to the method first — TC39 applies method decorators bottom-up, so it wraps innermost.- Neither
awaits a returnedPromise— anasyncmethod'semit/onErrorreact once the synchronous call returns (or synchronously throws), not once the promise settles or rejects. - Applies to instance methods only; decorating a
staticmethod 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)satisfiesarrayOf(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 matchtags: arrayOf(PostTag), andoptionalOfdoes not matchnullableOf. Write the wrapper the blueprint writes. - The wrapper you pass is read, never used — only its
wrapsandwrapperKindstatics. WritingarrayOf(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 saystruewhere the runtime saysfalse— 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 sameObject.createprobefromJSONuses.
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 usereshapeShape 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.
AuthorCardneed share nothing with theAuthorthe source declares — that is the whole point. At the value-object leaf the rule isentityHas's, shared with it verbatim: the declared class or a subclass of it. So two separatecustomRecordVO()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'sfromJSON. ADomainRecordhas none to give and contributes none. The return type says so too —ReshapedToreads it off the source's owntoJSON. - Multiplicity is part of the shape for a class target, and for the same reason:
optionalOf(TagCard)does not accept a key holding anarrayOf. A nested target is the deliberate exception — it declares no multiplicity and inherits the source's. - A mismatch throws
InvalidPropertyException, itspropertycarrying the dotted path to the offending key ("author.twitter","tags[].headline"), before anything is projected. - Rules do not participate, and nothing is redacted.
default/deriveact on construction input, not on an instance already built, so adefaultcannot stand in for a key the source lacks. The cut is taken fromtoJSON(), nottoSafeJSON()— 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 movedThat 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: aDraftPost extends Postsharing anidis not equal to aPostin either direction. The corollary is the rule every blueprint already follows — callentityOf(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 Postadopts that instance without a word —isBuiltInstancetestsinstanceof, the same rulerentityHasandreshapeTouse — and from then on it compares equal to no plainPost. 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.
sameStateAsdelegates 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 itsid. - So renaming
post.authordoes not changepost.sameStateAs(other). What the post holds is the author's identity, and that did not move. Askpost.authoritself 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; throughtoSafeJSON()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. equalsandsameStateAsare reserved blueprint keys — here and, forequals, inDomainRecordtoo. Declaring either throwsPropertyNameCollisionException, likeschema,toJSONandid. The check iskey in prototype, so a name is reserved only in the pillar that declares the member: aCommandhas noequalsand 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 defaultsThe 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(); // falseKey rules:
defineRecordmust be a prototype method, never a class field, and must be pure — the same trap and the sameInvalidEntityDefinitionExceptionguard asdefineEntity/defineMeta.set/setManyareprotected. Nothing outside the class may mutate a record; only the verbs it declares.setManyis atomic (validate all, build all, then assign), and its returnedbooleanis the only signal that something changed — there is noupdatedAtto compare.- A blueprint key may not be
id,createdAtorupdatedAt. A record does not have identity and may not fake it; the attempt throwsPropertyNameCollisionException. toJSON()never redacts (it is the persistence contract and must round-trip throughfromJSON);toSafeJSON(),toString()and the inspect hook do — the same asymmetryEntityhas, notCommand's.- A record raises no domain events. There is no
[Events]slot and noraiseEvent: an event belongs to an aggregate root, and a record has no identity to report as itsaggregateId. It does forwardpullDomainEvents()into the entities it nests, so their buffers are never stranded behind it; with{ deep: false }it always returns[]. - No
[Storage], nodestroy(). 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.onUpdateis the tempting one — a record does havesetMany— and it will fail at call time. equalscompares 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 itsid. See Comparing entities;equalsis 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; // nullConstruction 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.tagsis 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 throughset, and the existing items go back as they are: a value that is already an instance of the wrapped class is adopted, not rebuilt.
Adoption is what preserves each item'spost.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 timeid, 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 contractsetalready has on a nested entity key, only more visible in a list. The test isinstanceof, so a subclass of the wrapped class is adopted too — and then compares equal to nothing, sinceequalstakes the exact prototype. The price of adoption is aliasing: one instance can now sit in two parents, and mutating it shows in both. optionalOfandnullableOfare not interchangeable.nullnever extendsundefined: anoptionalOfkey is omittable and drops out of the schema'srequired, anullableOfkey stays required and must be stated. The usualundefined(request body) versusnull(database column) split — the same oneoptionalVOandnullableVOdraw one level down.- A drain reaches into the contents.
post.pullDomainEvents()walks every item, so an entity inside anarrayOfis never stranded.{ deep: false }returns only the owner's own events. demo()yields an empty container —[],undefinedornull. A fixture with items is written by passing them.- The derived schema carries the multiplicity:
t.Array(inner), ort.Union([inner, t.Undefined()])/t.Union([inner, t.Null()]).fromJSONtherefore 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
findByTagsandfindByAuthorare compile errors. Filter by a scalar the aggregate owns. - Uniqueness inside a list is not checked, and is not the list's business.
uniqueis 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
