Skip to content
This repository was archived by the owner on Oct 7, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/aggregate-requires-identity.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/how-to/model-an-event-driven-aggregate.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions docs/reference/declaration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/typedoc.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"Flags",
"GeneratedKeys",
"Generators",
"IdentityKeys",
"ImmutableKeys",
"InputOf",
"InspectionSrc",
Expand Down
13 changes: 13 additions & 0 deletions packages/entity/src/aggregate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
7 changes: 7 additions & 0 deletions packages/entity/src/aggregate.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
});
27 changes: 26 additions & 1 deletion packages/entity/src/aggregate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -12,6 +13,7 @@ import type {
EventNamed,
Events,
Fields,
IdentityKeys,
InputOf,
RecordOf,
Schemas,
Expand Down Expand Up @@ -47,7 +49,17 @@ type Maker = { make: (state: unknown) => Result<object, InvalidEntity> };
export const createAggregate =
(buildEntity: BuildEntityClass) =>
<Tag extends string>(tag: Tag) =>
<S extends Fields>(fields: S & OnlyNominal<S>) =>
<S extends Fields>(
// 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<S> &
([IdentityKeys<S>] extends [never]
? { readonly __anAggregateRootNeedsAnIdentityField: never }
: unknown),
) =>
<
Ev extends Events,
O extends z.output<Ev>["type"],
Expand All @@ -67,6 +79,19 @@ export const createAggregate =
readonly computed?: { [K in keyof A]: ComputedField<A[K], InputOf<S>> };
readonly invariants?: readonly Invariant<InputOf<S>>[];
}): AggregateStatic<Tag, S, A, Ev, O> => {
// 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<string, unknown> & {
readonly prototype: Record<string, unknown>;
Expand Down
Loading