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 1 commit
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
1 change: 1 addition & 0 deletions docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const GUIDE_SIDEBAR = [
{ text: "Evolve an entity", link: "/how-to/evolve-an-entity" },
{ text: "Model an aggregate", link: "/how-to/model-an-aggregate" },
{ text: "Number without gaps", link: "/how-to/number-without-gaps" },
{ text: "Enforce a uniqueness rule", link: "/how-to/enforce-uniqueness" },
{ text: "Test domain logic", link: "/how-to/test-domain-logic" },
],
},
Expand Down
11 changes: 11 additions & 0 deletions docs/examples/billing-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,14 @@ changes shape — which is the point of the entity knowing nothing about
persistence in the first place.

Related how-to: [Persist and rehydrate](/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](/how-to/enforce-uniqueness).
209 changes: 209 additions & 0 deletions docs/how-to/enforce-uniqueness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
---
title: Enforce a uniqueness rule
description: Place a rule like "this slug is not taken" where it can actually hold, with a preflight lookup for feedback, a unique constraint for the guarantee, and a typed conflict error between them.
---

# Enforce a uniqueness rule

**Problem:** a rule needs to look at data the entity does not hold. "No other
organization has this slug" is the usual one; "the customer referenced here
exists" and "this account is under its quota" are the same shape. Invariants
cannot express it, and you need to know where it goes instead.

> Snippets below assume these imports:
>
> ```ts
> import { Entity } from "@btravstack/entity";
> import { fromPromise, fromSafePromise, P, TaggedError } from "unthrown";
> ```
>
> The runnable version is
> [`examples/billing-persistence/src/uniqueness.ts`](https://github.com/btravstack/entity/blob/main/examples/billing-persistence/src/uniqueness.ts),
> with its spec beside it.

## Sort the rule into the right layer

Three kinds of rule are easy to blur together. Each lives in a different place,
and only one of them survives two requests arriving at once.

| Rule | Example | Lives in | Holds under concurrency |
| ------------------------ | ------------------------------- | -------------------------------------------- | ----------------------------- |
| **State invariant** | a name is at most 80 characters | the entity's `invariants` | yes, it reads only the entity |
| **Command precondition** | no organization has this slug | the use case, before it writes | no |
| **Database constraint** | `unique (slug)` | the store, enforced atomically at write time | yes |

The test is whether the entity's own fields are enough to decide the rule. If
they are, it is a state invariant: synchronous, local, and checked on every
construction path. If the answer depends on what else is stored, it is
open-world, and it belongs to the use case and the store.

Ecto draws the same line between a changeset's
[validations and constraints](https://hexdocs.pm/ecto/Ecto.Changeset.html#module-validations-and-constraints):
validations run before the database is touched, constraints are reported by the
database and mapped back after the write fails.

## Keep the invariant closed-world

Leave the entity's declaration exactly as it would be without the uniqueness
rule:

```ts
export class Organization extends Entity("Organization")(
{
id: Entity.field(OrganizationId, { generated: true, immutable: true }),
slug: Entity.field(Slug, { immutable: true }),
name: DisplayName,
createdAt: Entity.field(Instant, { generated: true, immutable: true }),
},
{
invariants: [
Entity.invariant(
(d) => d.name.length <= 80,
"name must be at most 80 characters",
),
],
},
) {}
```

There is no async predicate to reach for, and no repository to pass in. That is
deliberate: `make` would stop being a pure function of its input, and a row
read back from storage would re-run a lookup that has nothing to do with
whether the row is well-formed. See [No I/O, by design](/explanation/no-io).

## Add the unique constraint first

The constraint is the rule. Everything else on this page is about reporting it
well:

```sql
alter table organization add constraint organization_slug_key unique (slug);
```

A unique index checks and writes in one atomic step, so no second writer can
slip in between. The example has no database, so its in-memory store plays the
index's part the same way: the check and the write share one synchronous block,
and a duplicate rejects the insert the way a driver would.

```ts
insert(organization: Organization): Promise<void> {
Comment thread
btravers marked this conversation as resolved.
Outdated
if (this.#bySlug.has(organization.slug)) {
return Promise.reject(new UniqueViolation("organization_slug_key"));
}
this.#bySlug.set(organization.slug, organization.toJSON());
return Promise.resolve();
}
```

In production a real database unique index replaces that method, and nothing
above the store changes.

## Model the conflict as its own error

A taken slug is an expected outcome with a stable meaning, so give it a stable
type:

```ts
export class SlugTaken extends TaggedError("SlugTaken")<{ slug: Slug }> {
Comment thread
btravers marked this conversation as resolved.
Outdated
override message = `slug "${this.slug}" is already taken`;
}
```

It is not an `InvalidEntity`. The organization is valid; it lost a race for a
name. Folding the two together would hand the caller one error for "your input
is malformed" and "your input is fine but someone got there first", which are a
422 and a 409, and are fixed in different ways.

## Check, then create

The use case runs the three layers in order:

```ts
export const registerOrganization =
(store: OrganizationStore) =>
(input: Entity.CreateInput<typeof Organization>) =>
createOrganization(input) // 1. state invariants, synchronous
.toAsync()
.flatMap((organization) =>
fromSafePromise(store.slugIsTaken(organization.slug)) // 2. preflight
.ensure(
(taken) => !taken,
() => new SlugTaken({ slug: organization.slug }),
)
.map(() => organization),
)
.flatMap((organization) =>
fromPromise(store.insert(organization), (cause, defect) =>
cause instanceof UniqueViolation &&
cause.constraint === "organization_slug_key" // 3. the authority
? new SlugTaken({ slug: organization.slug })
: defect(cause),
).map(() => organization),
);
```

The result is `AsyncResult<Organization, InvalidEntity | SlugTaken>`.

The preflight lookup is there for feedback. It catches the common case before
any write, and it is the place to put a friendlier message or a suggested
alternative. It cannot establish uniqueness, so never let it be the only check.
Dropping it is a valid choice; dropping the constraint is not.

`fromSafePromise` says every rejection from the lookup is a defect: a store
that cannot answer the question is not a conflict.

## Qualify the violation, and only that

The insert's `qualify` callback is the triage. It turns exactly one failure
into `SlugTaken`: a unique violation **on this index**. Postgres reports
SQLSTATE `23505` with the constraint's name; match on the name too, or a
violation of some other unique index on the same table becomes a misleading
"slug taken".

Every other rejection goes to `defect`. A refused connection, a timeout, or a
violation you did not anticipate is infrastructure failing, and it should page
someone rather than render as a 409. If you use Prisma,
[`@unthrown/prisma`](https://btravstack.github.io/unthrown/) already separates
`UniqueConstraintViolation` from `DriverError`; the same rule applies to which
one you map.

## Watch the race lose

Two requests for the same slug, arriving together:

1. Both build a valid `Organization`.
2. Both look up the slug, and both see it free.
3. Both insert. The store accepts the first and rejects the second with a
unique violation.
4. The second request's `qualify` maps that violation to `SlugTaken`.

The loser gets the same error the preflight would have given it, so the caller
never needs to know which layer caught the conflict. The example's spec
reproduces this deterministically by holding both inserts until both lookups
have answered, then asserting that both lookups saw a free slug and exactly one
write succeeded.

## Answer each channel at the edge

The three outcomes stay distinguishable all the way out:

```ts
registerOrganization(store)(input).match({
ok: () => 201,
errCases: (m) =>
m
.with(P.tag("InvalidEntity"), () => 422)
.with(P.tag("SlugTaken"), () => 409),
defect: () => 503,
});
```

If your API renders every failure in one response shape, do that here, at the
edge, by mapping `SlugTaken` into it. Converting it into an `InvalidEntity`
first to borrow its `issues` would erase the distinction the matcher depends on.
Stable machine-readable codes for invariant failures are tracked in
[issue #70](https://github.com/btravstack/entity/issues/70).

Related: [Persist and rehydrate](/how-to/persist-and-rehydrate) for the
repository this store sits beside, and
[Expose an HTTP contract](/how-to/http-contract) for the edge.
19 changes: 19 additions & 0 deletions examples/billing-persistence/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,22 @@ number was allocated, and the next invoice still taking that number.

See also the how-to: [Number without
gaps](https://btravstack.github.io/entity/how-to/number-without-gaps).

## Uniqueness

`uniqueness.ts` is a rule no invariant can express: "this slug is not taken" is
a fact about every _other_ organization. The use case checks it with a
preflight lookup for feedback, and the store enforces it at write time, the way
a database unique index does.

```ts
await registerOrganization(store)(input); // AsyncResult<Organization, InvalidEntity | SlugTaken>
```

The spec races two creates for the same slug: both pass the lookup, the store
rejects one insert, and that violation is mapped to the same `SlugTaken` the
lookup returns. A store that is down stays a Defect, so a conflict (409) never
looks like an outage (503).

See also the how-to: [Enforce a uniqueness
rule](https://btravstack.github.io/entity/how-to/enforce-uniqueness).
88 changes: 88 additions & 0 deletions examples/billing-persistence/src/uniqueness.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Entity } from "@btravstack/entity";
import { DisplayName, Slug } from "@btravstack/entity-example-billing-domain";
import type { Organization } from "@btravstack/entity-example-billing-domain";
import { P, type AsyncResult } from "unthrown";
import { expect, test } from "vitest";

import {
InMemoryOrganizationStore,
registerOrganization,
type OrganizationStore,
type SlugTaken,
} from "./uniqueness.js";

const acme = (name = "Acme SA") => ({ slug: Slug.parse("acme"), name: DisplayName.parse(name) });

/** The HTTP edge: three channels, three different answers. */
const status = (result: AsyncResult<Organization, Entity.InvalidEntity | SlugTaken>) =>
result.match({
ok: () => 201,
errCases: (m) => m.with(P.tag("InvalidEntity"), () => 422).with(P.tag("SlugTaken"), () => 409),
defect: () => 503,
});

/**
* Holds every insert until both requests have done their lookup — the
* interleaving a loaded server produces by accident, produced on purpose so
* the test does not depend on scheduling luck.
*/
const racing = (store: OrganizationStore) => {
const answers: boolean[] = [];
let resolve = () => {};
const bothLooked = new Promise<void>((r) => (resolve = r));

const raced: OrganizationStore = {
slugIsTaken: async (slug) => {
const taken = await store.slugIsTaken(slug);
answers.push(taken);
if (answers.length === 2) resolve();
return taken;
},
insert: async (organization) => {
await bothLooked;
return store.insert(organization);
},
};
return { raced, answers };
};

test("a second registration of the same slug fails the preflight", async () => {
const register = registerOrganization(new InMemoryOrganizationStore());

expect(await status(register(acme()))).toBe(201);
expect(await status(register(acme("Acme SAS")))).toBe(409);
});

test("two concurrent creates both pass the preflight; the unique constraint rejects one", async () => {
const { raced, answers } = racing(new InMemoryOrganizationStore());
const register = registerOrganization(raced);

const outcomes = await Promise.all([
status(register(acme())),
status(register(acme("Acme SAS"))),
]);

// Both lookups saw a free slug: the preflight cannot establish uniqueness.
expect(answers).toEqual([false, false]);
// The store can, because it checks and writes in one step. The loser gets
// the same 409 the preflight would have given it, never a 503.
expect(outcomes.sort()).toEqual([201, 409]);
});

test("an invariant failure never reaches the store", async () => {
const untouched: OrganizationStore = {
slugIsTaken: () => Promise.reject(new Error("must not be called")),
insert: () => Promise.reject(new Error("must not be called")),
};

expect(await status(registerOrganization(untouched)(acme("A".repeat(81))))).toBe(422);
});

test("an unavailable store is a defect, not a conflict", async () => {
const down: OrganizationStore = {
slugIsTaken: () => Promise.resolve(false),
insert: () => Promise.reject(new Error("connection refused")),
};

expect(await status(registerOrganization(down)(acme()))).toBe(503);
});
Loading
Loading