From cea29a2e753ac637c6549fd40e6b881344a9f0d0 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 7 Aug 2026 03:07:16 +0200 Subject: [PATCH 1/2] refactor!: make a factory a function instead of an object with create --- .changeset/factory-returns-a-function.md | 26 +++++++++++++++++ CLAUDE.md | 3 +- README.md | 32 ++++++++++---------- packages/entity/README.md | 8 ++--- packages/entity/src/crud.spec.ts | 37 ++++++++++++------------ packages/entity/src/entity.test-d.ts | 6 ++-- packages/entity/src/entity.ts | 21 +++++--------- packages/entity/src/types.ts | 21 +++++++++----- 8 files changed, 90 insertions(+), 64 deletions(-) create mode 100644 .changeset/factory-returns-a-function.md diff --git a/.changeset/factory-returns-a-function.md b/.changeset/factory-returns-a-function.md new file mode 100644 index 0000000..32049c8 --- /dev/null +++ b/.changeset/factory-returns-a-function.md @@ -0,0 +1,26 @@ +--- +"@btravstack/entity": minor +--- + +**BREAKING**: a factory is a function, not an object with `.create`. + +```ts +// before +const orgs = Organization.factory({ + id: () => ids.next(), + createdAt: () => clock.now(), +}); +orgs.create({ slug, name }); + +// after +const createOrg = Organization.factory({ + id: () => ids.next(), + createdAt: () => clock.now(), +}); +createOrg({ slug, name }); +``` + +Nothing but `create` ever consumed the generators, so the object around it was +ceremony. `factoryAsync` changes the same way. + +Migration: drop `.create`. diff --git a/CLAUDE.md b/CLAUDE.md index 0a37d12..00d5d17 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,7 +113,8 @@ design — `contract.spec.ts` pins that both ways. `private`/`protected` constructors were measured to break the declaration form (TS2675) and the statics (TS2684) respectively. - **No I/O.** The package reads no clock and generates no id. `create` lives on - a factory (`Entity.factory(generators)` / `factoryAsync`), bound at the + a factory (`Entity.factory(generators)` / `factoryAsync`) — a function you + call with the caller's fields — bound at the composition root; generators are functions, called once per `create`. A rejecting async generator is a Defect, not an `InvalidEntity`. - `zod`, `unthrown` and `@unthrown/standard-schema` are peer dependencies to diff --git a/README.md b/README.md index 073b884..cb6ec73 100644 --- a/README.md +++ b/README.md @@ -96,18 +96,16 @@ class Organization extends Entity("Organization")( // Bind the effect sources once, at the composition root. The entity itself // never reads a clock or generates an id — see "No I/O" below. -const orgs = Organization.factory({ +const createOrg = Organization.factory({ id: () => ids.next(), createdAt: () => clock.now(), }); // A create use case: the caller supplies only request fields. -const org = orgs - .create({ - slug: "acme" as z.infer, - name: "Acme" as z.infer, - }) - .getOrThrow(); +const org = createOrg({ + slug: "acme" as z.infer, + name: "Acme" as z.infer, +}).getOrThrow(); org.slug; // "acme" — typed, read-only org.update({ name: "Acme Inc" as z.infer }); // a NEW entity; Result @@ -215,11 +213,11 @@ Organization.optional(); // ✗ does not exist ## The three entry points -| Entry point | Input | Use | -| ------------------------------------ | ------------------------------- | ------------------------------------------------------------------ | -| `Entity.factory(gens).create(input)` | caller fields only | a create use case | -| `entity.update(patch)` | a partial of the mutable fields | an update use case | -| `Entity.make(data)` | everything `input` describes | a row, a folded event stream, an untrusted import, a nested entity | +| Entry point | Input | Use | +| ----------------------------- | ------------------------------- | ------------------------------------------------------------------ | +| `Entity.factory(gens)(input)` | caller fields only | a create use case | +| `entity.update(patch)` | a partial of the mutable fields | an update use case | +| `Entity.make(data)` | everything `input` describes | a row, a folded event stream, an untrusted import, a nested entity | `create` is the one reached through a factory, because it is the only one that needs values the domain generates rather than receives. @@ -228,13 +226,13 @@ The types and the schemas are derived from the same declarations, so the rules are compile-time facts: ```ts -const orgs = Organization.factory({ +const createOrg = Organization.factory({ id: () => ids.next(), createdAt: () => clock.now(), }); -orgs.create({ slug, name }); // ✓ -orgs.create({ slug, name, id }); // ✗ id is generated +createOrg({ slug, name }); // ✓ +createOrg({ slug, name, id }); // ✗ id is generated org.update({ name }); // ✓ Result org.update({ id }); // ✗ id is immutable @@ -723,11 +721,11 @@ generator that _rejects_ surfaces as a `Defect`: infrastructure failing is not the same as bad domain input. ```ts -const orgs = Organization.factoryAsync({ +const createOrgAsync = Organization.factoryAsync({ id: () => ids.nextFromSequence(), createdAt: () => clock.now(), }); -(await orgs.create({ slug, name })).getOrThrow(); +(await createOrg({ slug, name })).getOrThrow(); ``` ## Peer dependencies diff --git a/packages/entity/README.md b/packages/entity/README.md index d97d64e..51207c6 100644 --- a/packages/entity/README.md +++ b/packages/entity/README.md @@ -22,12 +22,12 @@ class Organization extends Entity("Organization")( ) {} // effect sources bound once, at the composition root -const orgs = Organization.factory({ +const createOrg = Organization.factory({ id: () => ids.next(), createdAt: () => clock.now(), }); -const org = orgs.create({ slug, name }).getOrThrow(); +const org = createOrg({ slug, name }).getOrThrow(); org.update({ name: newName }); // a NEW entity; immutable fields rejected at compile time Organization.make(row); // row mappers and event folds @@ -71,8 +71,8 @@ class name it labels, ahead of the field map. | `updateInput` | `ZodObject` | update request — `output` minus `immutable`, partial | | _the class itself_ | zod schema | parses to a class instance; valid as a field and anywhere zod takes a schema | | `make(state)` | method | already-stored state → entity, for row mappers and event folds | -| `factory(gens)` | method | binds the generated fields' sources → `{ create(input) }` | -| `factoryAsync(gens)` | method | same, for promise-returning generators → `{ create(input): AsyncResult }` | +| `factory(gens)` | method | binds the generated fields' sources → `(input) => Result` | +| `factoryAsync(gens)` | method | same, for promise-returning generators → `(input) => AsyncResult` | **Contracts compose the four `ZodObject`s (`input`, `output`, `createInput`, `updateInput`); domain code composes the class itself.** All four `ZodObject`s diff --git a/packages/entity/src/crud.spec.ts b/packages/entity/src/crud.spec.ts index a6e2a62..27518eb 100644 --- a/packages/entity/src/crud.spec.ts +++ b/packages/entity/src/crud.spec.ts @@ -23,27 +23,28 @@ const input = { name: "Acme", trialEndsAt: "2026-09-01T09:00:00Z", } as never; -const orgs = Organization.factory({ +const createOrg = Organization.factory({ id: () => "0199b1f4-1b1e-7000-8000-000000000000" as never, createdAt: () => "2026-08-06T09:00:00Z" as never, }); test("create applies the generated values", () => { - const org = orgs.create(input).getOrThrow(); + const org = createOrg(input).getOrThrow(); expect(org.id).toBe("0199b1f4-1b1e-7000-8000-000000000000"); expect(org.createdAt).toBe("2026-08-06T09:00:00Z"); expect(org.slug).toBe("acme"); }); test("create ignores a generated field smuggled in by a caller", () => { - const org = orgs - .create({ ...(input as object), id: "0199b1f4-1b1e-7000-8000-999999999999" } as never) - .getOrThrow(); + const org = createOrg({ + ...(input as object), + id: "0199b1f4-1b1e-7000-8000-999999999999", + } as never).getOrThrow(); expect(org.id).toBe("0199b1f4-1b1e-7000-8000-000000000000"); }); test("create enforces invariants", () => { - const bad = orgs.create({ ...(input as object), trialEndsAt: "2026-01-01T09:00:00Z" } as never); + const bad = createOrg({ ...(input as object), trialEndsAt: "2026-01-01T09:00:00Z" } as never); expect(bad.isErr()).toBe(true); }); @@ -61,7 +62,7 @@ test("updateInput is partial and omits the immutable fields", () => { }); test("update returns a new instance and leaves the original untouched", () => { - const org = orgs.create(input).getOrThrow(); + const org = createOrg(input).getOrThrow(); const renamed = org.update({ name: "Renamed" as never }).getOrThrow(); expect(renamed).not.toBe(org); expect(renamed.name).toBe("Renamed"); @@ -70,13 +71,13 @@ test("update returns a new instance and leaves the original untouched", () => { }); test("update ignores an immutable field smuggled in at runtime", () => { - const org = orgs.create(input).getOrThrow(); + const org = createOrg(input).getOrThrow(); const updated = org.update({ slug: "other" } as never).getOrThrow(); expect(updated.slug).toBe("acme"); }); test("update re-runs invariants", () => { - const org = orgs.create(input).getOrThrow(); + const org = createOrg(input).getOrThrow(); const issues = org.update({ trialEndsAt: "2026-01-01T09:00:00Z" as never }).match({ ok: () => [] as readonly string[], errCases: (m) => m.with(P.tag("InvalidEntity"), (e) => e.issues.map((i) => i.message)), @@ -93,32 +94,32 @@ test("an entity with no generated or immutable options still exposes both schema test("a generator runs once per create, not once per factory", () => { let n = 0; - const counted = Organization.factory({ + const countedCreate = Organization.factory({ id: () => `0199b1f4-1b1e-7000-8000-00000000000${(n += 1)}` as never, createdAt: () => "2026-08-06T09:00:00Z" as never, }); - const a = counted.create(input).getOrThrow(); - const b = counted.create(input).getOrThrow(); + const a = countedCreate(input).getOrThrow(); + const b = countedCreate(input).getOrThrow(); expect(a.id).not.toBe(b.id); expect(n).toBe(2); }); test("an async factory awaits its generators", async () => { - const asyncOrgs = Organization.factoryAsync({ + const createOrgAsync = Organization.factoryAsync({ id: () => Promise.resolve("0199b1f4-1b1e-7000-8000-000000000000" as never), createdAt: () => Promise.resolve("2026-08-06T09:00:00Z" as never), }); - const org = (await asyncOrgs.create(input)).getOrThrow(); + const org = (await createOrgAsync(input)).getOrThrow(); expect(org.id).toBe("0199b1f4-1b1e-7000-8000-000000000000"); }); test("an async factory still reports invariant failures as InvalidEntity", async () => { - const asyncOrgs = Organization.factoryAsync({ + const createOrgAsync = Organization.factoryAsync({ id: () => Promise.resolve("0199b1f4-1b1e-7000-8000-000000000000" as never), createdAt: () => Promise.resolve("2026-08-06T09:00:00Z" as never), }); const outcome = ( - await asyncOrgs.create({ ...(input as object), trialEndsAt: "2026-01-01T09:00:00Z" } as never) + await createOrgAsync({ ...(input as object), trialEndsAt: "2026-01-01T09:00:00Z" } as never) ).match({ ok: () => "WRONGLY ACCEPTED", errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), @@ -128,11 +129,11 @@ test("an async factory still reports invariant failures as InvalidEntity", async }); test("a rejecting generator is a defect, not an InvalidEntity", async () => { - const broken = Organization.factoryAsync({ + const createBroken = Organization.factoryAsync({ id: () => Promise.reject(new Error("id source unreachable")), createdAt: () => Promise.resolve("2026-08-06T09:00:00Z" as never), }); - const outcome = (await broken.create(input)).match({ + const outcome = (await createBroken(input)).match({ ok: () => "WRONGLY ACCEPTED", errCases: (m) => m.with(P.tag("InvalidEntity"), () => "invalid"), defect: () => "defect", diff --git a/packages/entity/src/entity.test-d.ts b/packages/entity/src/entity.test-d.ts index 1727d23..cf2d5f0 100644 --- a/packages/entity/src/entity.test-d.ts +++ b/packages/entity/src/entity.test-d.ts @@ -142,10 +142,10 @@ test("create rejects a generated field and update rejects an immutable one", () { generated: ["id", "createdAt"], immutable: ["id", "createdAt"] }, ) {} - const orgs = Org.factory({ id: () => "x" as never, createdAt: () => "t" as never }); + const createOrg = Org.factory({ id: () => "x" as never, createdAt: () => "t" as never }); // @ts-expect-error `id` is generated by the domain, not supplied by the caller - orgs.create({ slug: "s" as never, id: "x" as never }); - orgs.create({ slug: "s" as never }); + createOrg({ slug: "s" as never, id: "x" as never }); + createOrg({ slug: "s" as never }); const org = Org.make({}).getOrThrow(); // @ts-expect-error `id` is immutable diff --git a/packages/entity/src/entity.ts b/packages/entity/src/entity.ts index 8b062a6..a8047b4 100644 --- a/packages/entity/src/entity.ts +++ b/packages/entity/src/entity.ts @@ -321,11 +321,8 @@ export function Entity(tag: Tag) { generators: Generators, ): EntityFactory { const Ctor = this as unknown as { make: (state: unknown) => Result }; - return { - create: (input) => - // generated spreads last, so a caller cannot override a domain-owned field - Ctor.make({ ...(input as object), ...callAll(generators) }), - }; + // generated spreads last, so a caller cannot override a domain-owned field + return (input) => Ctor.make({ ...(input as object), ...callAll(generators) }); } static factoryAsync( @@ -333,14 +330,12 @@ export function Entity(tag: Tag) { generators: AsyncGenerators, ): AsyncEntityFactory { const Ctor = this as unknown as { make: (state: unknown) => Result }; - return { - create: (input) => - // a generator that rejects is infrastructure failing, not bad domain - // input, so it stays a Defect rather than becoming an InvalidEntity - fromPromise(resolveAll(generators), (cause, defect) => defect(cause)).flatMap( - (generated) => Ctor.make({ ...(input as object), ...generated }), - ), - }; + // a generator that rejects is infrastructure failing, not bad domain + // input, so it stays a Defect rather than becoming an InvalidEntity + return (input) => + fromPromise(resolveAll(generators), (cause, defect) => defect(cause)).flatMap( + (generated) => Ctor.make({ ...(input as object), ...generated }), + ); } /** a partial of the mutable fields → a NEW entity */ diff --git a/packages/entity/src/types.ts b/packages/entity/src/types.ts index bdd0c4d..e8b8930 100644 --- a/packages/entity/src/types.ts +++ b/packages/entity/src/types.ts @@ -345,12 +345,17 @@ export type AsyncGenerators = [K in keyof GeneratedOf]: () => PromiseLike[K]>; }; -/** An entity bound to its effect sources. `create` is the only member: nothing - * else consumes generators, and `make`/`decode` stay on the class. */ -export type EntityFactory = { - create(input: CreateInputOf): Result; -}; +/** + * An entity bound to its effect sources: call it with the caller's fields. + * + * A plain function rather than an object with one method — nothing else + * consumes generators, so `.create` was ceremony around the only thing a + * factory does. `make` stays on the class. + */ +export type EntityFactory = ( + input: CreateInputOf, +) => Result; -export type AsyncEntityFactory = { - create(input: CreateInputOf): AsyncResult; -}; +export type AsyncEntityFactory = ( + input: CreateInputOf, +) => AsyncResult; From e8e55542a3a9a6da34ace6fbd1c701efad728dc0 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Fri, 7 Aug 2026 03:16:24 +0200 Subject: [PATCH 2/2] docs: call the async factory in the async example --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cb6ec73..c138dd8 100644 --- a/README.md +++ b/README.md @@ -725,7 +725,7 @@ const createOrgAsync = Organization.factoryAsync({ id: () => ids.nextFromSequence(), createdAt: () => clock.now(), }); -(await createOrg({ slug, name })).getOrThrow(); +(await createOrgAsync({ slug, name })).getOrThrow(); ``` ## Peer dependencies