Skip to content

Persistence ​

examples/billing-persistence — an entity out to a row, and a row back to an entity.

sh
pnpm --filter @btravstack/entity-example-billing-persistence test

The round trip ​

ts
save(organization: Organization): void {
  this.#rows.set(organization.id, organization.toJSON());
}

toJSON() is the only projection the package offers, and it is the stored shape. No mapper to keep in sync, and _tag never reaches a row — it is a non-enumerable instance property, so it survives neither JSON.stringify nor a spread. The spec asserts that explicitly, because it is the sort of thing that starts leaking quietly.

ts
byId(id): Result<Organization, InvalidEntity | OrganizationNotFound> {
  const row = this.#rows.get(id);
  if (row === undefined) return Err(new OrganizationNotFound());
  return Organization.make(row);
}

make() validates on the way in. Rows outlive models — a column dropped two migrations ago is still sitting in production — so the boundary where old data becomes a live object is exactly where a check belongs.

What comes back is a real instance, behaviour included, not a bag of data. The spec checks that by calling a getter on a rehydrated entity.

Two errors, not one ​

A missing row and a corrupt row are different facts: the first is a 404, the second is data worth paging someone about. Folding them into one error discards the only thing that separates them.

The library defines no NotFound on purpose — whether an absent row is exceptional is a repository's decision, not an entity's — so the example models it with unthrown's TaggedError and discriminates the two with an exhaustive matcher:

ts
loaded.match({
  ok: (organization) => organization,
  errCases: (m) =>
    m
      .with(P.tag("InvalidEntity"), () => 422)
      .with(P.tag("OrganizationNotFound"), () => 404),
  defect: () => 500,
});

Because the matcher is exhaustive, the day this repository grows a third error those call sites stop compiling until someone decides what to do about it.

There is no try/catch anywhere in the file.

Swapping the store ​

The store is a Map. Replace it with a driver and nothing else in the file changes shape — which is the point of the entity knowing nothing about persistence in the first place.

Related how-to: Persist and rehydrate.

Uniqueness ​

uniqueness.ts registers an organization under a slug no other organization holds. The preflight lookup gives early feedback; the store's write-time check stands in for a database unique index and is the only part that holds under concurrency. The spec races two creates past the lookup and asserts that exactly one write lands, with the loser receiving SlugTaken rather than a defect.

Related how-to: Enforce a uniqueness rule.

One aggregate, two persistence styles ​

src/subscriptions.ts stores the billing domain's Subscription aggregate two ways behind one port. StateBasedSubscriptions keeps the state's toJSON() with a version and writes the decision's events to an outbox in the same step; it loads with make. EventSourcedSubscriptions appends the events to a stream if it is still at the version the command read; it loads with replay.

The spec runs the same scenarios against both: a round trip, two saves from one version (one wins, one is a ConcurrentModification), and a refused command that saves nothing. A last test feeds both the same decisions and checks they load the same state. See Model an event-driven aggregate.

Released under the MIT License.