From 2370c4df05b34e2312a64aca16800f59e8937106 Mon Sep 17 00:00:00 2001 From: Benoit Travers Date: Tue, 6 Oct 2026 14:52:31 +0200 Subject: [PATCH] feat!: require an identity field on every aggregate An aggregate root is what other aggregates reference and what a repository loads, so `Entity.aggregate` now rejects a field map with no `identity` field: a compile error (an inline rejection literal) plus a declaration-time defect, the redeclaration precedent. `sameIdentityAs` is therefore always available on an aggregate. `Entity` keeps identity optional, since it also models aggregate parts and value-like records. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/aggregate-requires-identity.md | 12 +++++++++ CLAUDE.md | 5 +++- .../how-to/model-an-event-driven-aggregate.md | 4 ++- docs/reference/declaration.md | 6 +++++ docs/typedoc.json | 1 + packages/entity/src/aggregate.spec.ts | 13 +++++++++ packages/entity/src/aggregate.test-d.ts | 7 +++++ packages/entity/src/aggregate.ts | 27 ++++++++++++++++++- 8 files changed, 72 insertions(+), 3 deletions(-) create mode 100644 .changeset/aggregate-requires-identity.md diff --git a/.changeset/aggregate-requires-identity.md b/.changeset/aggregate-requires-identity.md new file mode 100644 index 0000000..afe40eb --- /dev/null +++ b/.changeset/aggregate-requires-identity.md @@ -0,0 +1,12 @@ +--- +"@btravstack/entity": minor +--- + +**Breaking:** `Entity.aggregate` now requires at least one field flagged +`identity: true`. An aggregate root is what other aggregates reference and what +a repository loads, so a field map without one is a compile error, and a +declaration that gets past the types throws while it runs. `sameIdentityAs` is +therefore always available on an aggregate. + +To migrate, flag the aggregate's id: `id: Entity.field(SomeId, { identity: true })`. +`Entity` is unchanged: identity stays optional there. diff --git a/CLAUDE.md b/CLAUDE.md index 3c58ca4..a6a4f35 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,10 @@ what they own: map still being inferred and every fresh literal widened (measured: `NoInfer`, a constrained return parameter and an intersection-free signature all widened). `AggregateStatic` has no `_zod` in its type, so an aggregate cannot - be nested as a field. + be nested as a field. An aggregate **must** flag an `identity` field: a + compile error on the field map (an inline rejection literal, not a named + alias, for TypeDoc) plus a declaration-time defect, the redeclaration + precedent. `Entity` keeps identity optional on purpose. - **`freeze.ts`** — `deepFreeze`, the runtime half of immutability. Freezes and recurses into arrays and plain objects, freezes `Date` as a leaf, and deliberately leaves `Map`/`Set`/class instances alone. Which _fields_ to skip diff --git a/docs/how-to/model-an-event-driven-aggregate.md b/docs/how-to/model-an-event-driven-aggregate.md index 2b282d3..7748941 100644 --- a/docs/how-to/model-an-event-driven-aggregate.md +++ b/docs/how-to/model-an-event-driven-aggregate.md @@ -105,7 +105,9 @@ handler returning `status: "ACTIVE"` is checked against the enum, and a missing field is a compile error rather than a surprise at runtime. `invariants`, `computed` and the `identity` and `immutable` flags work as on any -entity. What an aggregate does not have is `update`, the factories, +entity, with one difference: an aggregate must flag an `identity` field, since +a root is what others reference and what a repository loads. Leaving it out +does not compile, and `sameIdentityAs` is therefore always available. What an aggregate does not have is `update`, the factories, `createInput` and `updateInput`. ## Decide with emit diff --git a/docs/reference/declaration.md b/docs/reference/declaration.md index e9a7c55..10611ff 100644 --- a/docs/reference/declaration.md +++ b/docs/reference/declaration.md @@ -445,6 +445,12 @@ Fields and options are separate calls so the fields are fixed before the handlers are checked: a handler's returned literals and fields are checked exactly, where with both in one call they widened. +The fields must flag at least one `identity` field. An aggregate root is what +other aggregates reference and what a repository loads, so a field map without +one is a compile error, and a declaration that gets past the types throws +while it runs. (An `Entity` keeps identity optional: it also models parts of an +aggregate and value-like records that have none.) + The class has `make`, `inspect`, `start` and `replay` as statics, and `toJSON`, `sameIdentityAs` and `emit` on its instances. It has no `update`, no factories, no `createInput` and no `updateInput`, and it cannot be nested as another diff --git a/docs/typedoc.json b/docs/typedoc.json index 428fdb3..81b8b9c 100644 --- a/docs/typedoc.json +++ b/docs/typedoc.json @@ -32,6 +32,7 @@ "Flags", "GeneratedKeys", "Generators", + "IdentityKeys", "ImmutableKeys", "InputOf", "InspectionSrc", diff --git a/packages/entity/src/aggregate.spec.ts b/packages/entity/src/aggregate.spec.ts index ec12949..790f903 100644 --- a/packages/entity/src/aggregate.spec.ts +++ b/packages/entity/src/aggregate.spec.ts @@ -204,3 +204,16 @@ test("a stream that breaks today's invariant is an InvalidEntity, like make", () ]); expect(issues).toEqual([[[], "TOO_MANY_ITEMS"]]); }); + +test("an aggregate without an identity field is refused while the declaration runs", () => { + const declare = Entity.aggregate("Anonymous") as unknown as ( + fields: object, + ) => (options: object) => unknown; + expect(() => + declare({ status: z.enum(["open"]) })({ + events: CartEvent, + opens: { CartOpened: () => ({ status: "open" }) }, + evolve: { ItemAdded: (r: object) => r, CartCheckedOut: (r: object) => r }, + }), + ).toThrow(/Anonymous: an aggregate root needs an identity/u); +}); diff --git a/packages/entity/src/aggregate.test-d.ts b/packages/entity/src/aggregate.test-d.ts index 60fa192..4463a3e 100644 --- a/packages/entity/src/aggregate.test-d.ts +++ b/packages/entity/src/aggregate.test-d.ts @@ -94,3 +94,10 @@ Entity.aggregate("Incomplete")({ id: Entity.field(Id, { identity: true }), statu opens: { Opened: (e) => ({ id: e.id }) }, evolve: { Renamed: (r) => r, Closed: (r) => r }, }); + +// @ts-expect-error an aggregate root has an identity: it is how others reference it and how it is loaded +Entity.aggregate("Anonymous")({ label: Label })({ + events: Event, + opens: { Opened: () => ({ label: "x" }) }, + evolve: { Renamed: (r) => r, Closed: (r) => r }, +}); diff --git a/packages/entity/src/aggregate.ts b/packages/entity/src/aggregate.ts index 6a61328..75b3415 100644 --- a/packages/entity/src/aggregate.ts +++ b/packages/entity/src/aggregate.ts @@ -4,6 +4,7 @@ import type { z } from "zod"; import type { ComputedField } from "./computed.js"; import { InvalidEntity } from "./errors.js"; +import { isFieldSpec } from "./field.js"; import type { Invariant } from "./invariant.js"; import { keysOf, renderIssue } from "./issues.js"; import type { OnlyNominal } from "./shape.js"; @@ -12,6 +13,7 @@ import type { EventNamed, Events, Fields, + IdentityKeys, InputOf, RecordOf, Schemas, @@ -47,7 +49,17 @@ type Maker = { make: (state: unknown) => Result }; export const createAggregate = (buildEntity: BuildEntityClass) => (tag: Tag) => - (fields: S & OnlyNominal) => + ( + // An aggregate root has an identity: it is what other aggregates reference + // and what a repository loads by. Without one, the field map is rejected + // here — an inline literal, so TypeDoc has no internal name to report — + // and `sameIdentityAs` is always callable on an aggregate. + fields: S & + OnlyNominal & + ([IdentityKeys] extends [never] + ? { readonly __anAggregateRootNeedsAnIdentityField: never } + : unknown), + ) => < Ev extends Events, O extends z.output["type"], @@ -67,6 +79,19 @@ export const createAggregate = readonly computed?: { [K in keyof A]: ComputedField> }; readonly invariants?: readonly Invariant>[]; }): AggregateStatic => { + // The same rule at runtime, for a caller the types did not reach — the + // precedent is a root's redeclared field: a compile error, and a defect + // while the declaration is on the stack. + if ( + !Object.values(fields as Fields).some( + (v) => isFieldSpec(v) && (v.flags as { identity?: true }).identity === true, + ) + ) { + // oxlint-disable-next-line unthrown/no-throw + throw new Error( + `${tag}: an aggregate root needs an identity — flag at least one field \`identity: true\`.`, + ); + } const { events, opens, evolve, ...entityOptions } = options; const Base = buildEntity(tag)(fields as Fields, entityOptions) as Record & { readonly prototype: Record;