Skip to content
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
62 changes: 55 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,9 @@ Organization.make({ ...row, name: "" }).match({
| Schema member | Type | For |
| ------------- | ----------- | ------------------------------------------------------------------------ |
| `input` | `ZodObject` | everything `make()` accepts |
| `output` | `ZodObject` | stored state and response body |
| `output` | `ZodObject` | stored state; pick a response from it by allowlist |
| `createInput` | `ZodObject` | create request — `input` minus the `generated` fields |
| `updateInput` | `ZodObject` | update request — `output` minus the `immutable` fields, partial |
| `updateInput` | `ZodObject` | update request — `input` minus the `immutable` fields, partial |
| _the class_ | zod schema | parses to an instance; valid as a field, and anywhere zod takes a schema |

| Entry point | Takes | For |
Expand All @@ -134,10 +134,12 @@ Organization.make({ ...row, name: "" }).match({
| `entity.update(patch)` | a partial of the mutable fields | an update use case |
| `entity.toJSON()` | — | the stored data, for a write or a response |

| Field flag | `Entity.field(schema, …)` | Meaning |
| ----------- | ------------------------- | ------------------------------------------------ |
| `generated` | `{ generated: true }` | the domain supplies this field, never the caller |
| `immutable` | `{ immutable: true }` | it never changes after creation |
| Field flag | `Entity.field(schema, …)` | Meaning |
| ----------- | ------------------------- | ------------------------------------------------- |
| `generated` | `{ generated: true }` | the domain supplies this field, never the caller |
| `immutable` | `{ immutable: true }` | it never changes after creation |
| `identity` | `{ identity: true }` | part of what `sameIdentityAs` compares; immutable |
| `unbranded` | `{ unbranded: true }` | this one descriptive leaf needs no brand |

| Option | Meaning |
| ------------ | ----------------------------------------------------------------------- |
Expand Down Expand Up @@ -182,14 +184,60 @@ at the declaration, because a class's instance type cannot be a union at all
(`TS2509`).
([Why](https://btravstack.github.io/entity/explanation/unions-and-roots).)

## Aggregates

An aggregate root changes only through events. `Entity.aggregate` declares the
fields, then the events and one handler per event. It has no `update()`: every
command checks its business rules and returns a sealed decision.

```ts
class Subscription extends Entity.aggregate("Subscription")({
id: Entity.field(SubscriptionId, { identity: true }), // a root needs an identity
seats: Seats,
status: z.enum(["ACTIVE", "CANCELLED"]),
})({
events: SubscriptionEvent, // a zod discriminated union on `type`
opens: {
SubscriptionStarted: (e) => ({
id: e.subscriptionId,
seats: e.seats,
status: "ACTIVE",
}),
},
evolve: {
// one handler per event, or it does not compile
SeatsChanged: (r, e) => ({ ...r, seats: e.seats }),
SubscriptionCancelled: (r) => ({ ...r, status: "CANCELLED" }),
},
}) {
changeSeats(seats: number) {
if (this.status === "CANCELLED") return Err(new SubscriptionIsCancelled());
return this.emit({ type: "SeatsChanged", seats }); // fold, verify once, decide
}
}

const decision = subscription.changeSeats(5).getOrThrow();
decision.events; // every event since the load
decision.expectedVersion; // the version the store must still be at
repository.save(decision); // a state row and an outbox, or an event stream
```

Only `emit` and `start` build a decision, so a repository is only ever handed
events that were folded and checked against every invariant. Load with
`make(row, { version })` or `replay(stream)`; the same aggregate persists as
state or as events without touching its declaration. Use `Entity` for
everything inside the boundary, and for simple models where a public `update()`
costs nothing. See [Model an event-driven
aggregate](https://btravstack.github.io/entity/how-to/model-an-event-driven-aggregate).

## Documentation

**[btravstack.github.io/entity](https://btravstack.github.io/entity/)** — built
with VitePress from [`docs/`](./docs), and organised by the four
[Diátaxis](https://diataxis.fr/) modes:

- **[Tutorial](https://btravstack.github.io/entity/tutorial/getting-started)** — from nothing to a working entity, one step at a time.
- **How-to guides** — [expose an HTTP contract](https://btravstack.github.io/entity/how-to/http-contract) · [persist and rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate) · [model an aggregate](https://btravstack.github.io/entity/how-to/model-an-aggregate) · [test domain logic](https://btravstack.github.io/entity/how-to/test-domain-logic)
- **How-to guides** — [expose an HTTP contract](https://btravstack.github.io/entity/how-to/http-contract) · [persist and rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate) · [model an aggregate](https://btravstack.github.io/entity/how-to/model-an-aggregate) · [model an event-driven aggregate](https://btravstack.github.io/entity/how-to/model-an-event-driven-aggregate) · [test domain logic](https://btravstack.github.io/entity/how-to/test-domain-logic)
- **[Reference](https://btravstack.github.io/entity/reference/declaration)** — every member, option and type, with signatures. Plus the [generated API reference](https://btravstack.github.io/entity/api/).
- **[Guarantees and compatibility](https://btravstack.github.io/entity/reference/guarantees)** — before you adopt: what is enforced at compile time and at runtime, what is deliberately left to you, and the supported Node, TypeScript and zod versions. Then the same model [compared with plain zod and Effect `Schema.Class`](https://btravstack.github.io/entity/explanation/compared).
- **[Explanation](https://btravstack.github.io/entity/explanation/why-entity)** — why it is built this way: sealed construction, what immutability covers, no I/O, why an entity is final and a union has no class form.
Expand Down
67 changes: 57 additions & 10 deletions packages/entity/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,21 +58,22 @@ const loaded = Organization.make(row).getOrThrow(); // rows, imports, event fold
const renamed = loaded.update({ name: next }).getOrThrow(); // a NEW entity
```

| Schema member | For |
| ------------- | ------------------------------------------------------------------------------ |
| `input` | everything `make()` accepts |
| `output` | stored state, internal fields included: pick a response from it, by allowlist |
| `createInput` | what the domain lets a create set — `input` minus the `generated` fields |
| `updateInput` | what the domain lets change — `output` minus `immutable` and computed, partial |
| _the class_ | parses to an instance; valid as a field |
| Schema member | For |
| ------------- | ----------------------------------------------------------------------------- |
| `input` | everything `make()` accepts |
| `output` | stored state, internal fields included: pick a response from it, by allowlist |
| `createInput` | what the domain lets a create set — `input` minus the `generated` fields |
| `updateInput` | what the domain lets change — `input` minus the `immutable` fields, partial |
| _the class_ | parses to an instance; valid as a field |

The four are building blocks, not a public API. A route picks from them by
allowlist, so an internal field stays internal and a new one stays out until
someone adds it: see [Expose an HTTP
contract](https://btravstack.github.io/entity/how-to/http-contract).

`generated` and `immutable` are **flags on the field**, written with
`Entity.field(schema, flags)`; a field carrying neither is a bare schema.
`generated`, `immutable`, `identity` and `unbranded` are **flags on the
field**, written with `Entity.field(schema, flags)`; a field carrying none is a
bare schema.
`computed` and `invariants` are the two declaration options.

An entity is **final**. Fields and behaviour shared by several entities go on a
Expand Down Expand Up @@ -107,6 +108,52 @@ A variant is a real instance of its root, so `instanceof` narrows to it, and
the union at a base-class position is `TS2507` at the declaration, because a
class's instance type cannot be a union at all (`TS2509`).

## Aggregates

An aggregate root changes only through events. `Entity.aggregate` declares the
fields, then the events and one handler per event. It has no `update()`: every
command checks its business rules and returns a sealed decision.

```ts
class Subscription extends Entity.aggregate("Subscription")({
id: Entity.field(SubscriptionId, { identity: true }), // a root needs an identity
seats: Seats,
status: z.enum(["ACTIVE", "CANCELLED"]),
})({
events: SubscriptionEvent, // a zod discriminated union on `type`
opens: {
SubscriptionStarted: (e) => ({
id: e.subscriptionId,
seats: e.seats,
status: "ACTIVE",
}),
},
evolve: {
// one handler per event, or it does not compile
SeatsChanged: (r, e) => ({ ...r, seats: e.seats }),
SubscriptionCancelled: (r) => ({ ...r, status: "CANCELLED" }),
},
}) {
changeSeats(seats: number) {
if (this.status === "CANCELLED") return Err(new SubscriptionIsCancelled());
return this.emit({ type: "SeatsChanged", seats }); // fold, verify once, decide
}
}

const decision = subscription.changeSeats(5).getOrThrow();
decision.events; // every event since the load
decision.expectedVersion; // the version the store must still be at
repository.save(decision); // a state row and an outbox, or an event stream
```

Only `emit` and `start` build a decision, so a repository is only ever handed
events that were folded and checked against every invariant. Load with
`make(row, { version })` or `replay(stream)`; the same aggregate persists as
state or as events without touching its declaration. Use `Entity` for
everything inside the boundary, and for simple models where a public `update()`
costs nothing. See [Model an event-driven
aggregate](https://btravstack.github.io/entity/how-to/model-an-event-driven-aggregate).

## Documentation

**[btravstack.github.io/entity](https://btravstack.github.io/entity/)**
Expand All @@ -116,7 +163,7 @@ class's instance type cannot be a union at all (`TS2509`).
- [Getting started](https://btravstack.github.io/entity/tutorial/getting-started) — from nothing to a working entity
- [Reference](https://btravstack.github.io/entity/reference/declaration) — every member, option and type
- [Explanation](https://btravstack.github.io/entity/explanation/why-entity) — why it is built this way
- How-to: [HTTP contract](https://btravstack.github.io/entity/how-to/http-contract) · [persist and rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate) · [model an aggregate](https://btravstack.github.io/entity/how-to/model-an-aggregate) · [test domain logic](https://btravstack.github.io/entity/how-to/test-domain-logic)
- How-to: [HTTP contract](https://btravstack.github.io/entity/how-to/http-contract) · [persist and rehydrate](https://btravstack.github.io/entity/how-to/persist-and-rehydrate) · [model an aggregate](https://btravstack.github.io/entity/how-to/model-an-aggregate) · [model an event-driven aggregate](https://btravstack.github.io/entity/how-to/model-an-event-driven-aggregate) · [test domain logic](https://btravstack.github.io/entity/how-to/test-domain-logic)

## License

Expand Down
Loading