# constraints

> The per-member rules the database enforces — Required, Unique, MaxLength/MinLength, Min/Max, Pattern, and the storage-shaping Immutable, Precision and MaxBytes. They are checked when the row is written — every member on a create, the members it changes on an update — so a bad value cannot reach the database from any code path.

<!-- id: entity-constraints · area: entity · stability: stable · html: https://osysharp.com/reference/entity/constraints/ -->

## Summary        {#summary}
Constraints are per-member rules enforced **when the row is written**. They are not form validation you can forget to
call: a row that breaks one cannot reach the database, whatever code path tried to write it — a function, an import,
a test, an API call.

## Signature      {#signature}
```osy title="the per-member rules, and where each one goes" syntax
[Unique(<Member>, <Member>)]                      // …or over a COMBINATION, declared on the entity
entity <Name> {
  [Required]                 <Type>   <Member>;   // must be present
  [Unique]                   string   <Member>;   // no two rows may share a value
  [MaxLength(<n>)]           string   <Member>;   // bounded text
  [Min(<n>), Max(<n>)]       int      <Member>;   // an inclusive numeric range
  [Pattern("<regex>")]       string   <Member>;   // must match
}
```

Every constraint takes an optional **message** as its last argument — what the user is told when the rule refuses
them:

```osy title="giving a constraint the message the user is told" syntax
  [Pattern("<regex>", "<message>")]  string  <Member>;
  [MaxLength(<n>, "<message>")]      string  <Member>;
  [Required("<message>")]            <Type>  <Member>;
  [Unique("<message>")]              string  <Member>;   // shown when the value collides with another row
```

…including the **entity-level composite** form, where the message goes last, after the members:

```osy title="a message on a combination — the members first, the sentence last" syntax
[Unique(<Member>, <Member>, "<message>")]
entity <Name> { … }
```

`[Unique]` is the one whose default wording helps least — a collision otherwise says only that the entity's property
must be unique — so its message is worth writing: `[Unique("That slug is already taken.")]`. The sentence is what a
refused write says wherever the duplicate is caught: by the check before the commit, or by the unique index itself — which
is what catches it for a write a workflow or a scheduled job makes.

## Description    {#description}

### Which constraint attributes are there?   {#the-constraints}

| Attribute | Rule | On a null value |
|---|---|---|
| `[Required]` | the member must be present when the row is written | **this is the one that rejects null** |
| `[Unique]` | no two rows may hold the same value | passes — nulls do not collide |
| `[MaxLength(n)]` · `[MinLength(n)]` | text is at most / at least `n` characters | passes |
| `[Min(n)]` · `[Max(n)]` | an **inclusive** numeric range | passes |
| `[Range(min, max)]` | the same inclusive range in one attribute — C#'s spelling of `[Min(min), Max(max)]` | passes |
| `[Pattern("…")]` | text matches the regular expression | passes |
| `[Immutable]` | may be set on create, but **never updated** | passes (it governs updates, not presence) |
| `[Precision(p, s)]` | a decimal stored with `p` total digits and `s` after the point | passes |
| `[MaxBytes(n)]` | the value's stored size is at most `n` bytes | passes |

Read that last column carefully: **every constraint except `[Required]` lets null through.** That is the correct
behaviour — "if there is a value, it must look like this" is a different rule from "there must be a value" — but it
surprises people. If a code must be present *and* well-formed, say both:

```osy title="present AND well-formed needs both" test app=entity-constraints
entity Product {
  [Required, Pattern("^[A-Z]{3}$")] string Code;   // must exist, and must be three capitals
  [Pattern("^[A-Z]{3}$")] string AltCode;          // may be absent; if present, must match
}
```

**Give a `[Pattern]` a message.** Every other constraint refuses in words a person can act on — *"Email is required"*,
*"Name is too long"*. A pattern refuses with the regular expression, which explains nothing to the person reading it:

```osy title="say what the shape means" test app=entity-constraints-message
entity Product {
  [Required, Pattern("^[A-Z]{3}$", "must be three capital letters")] string Code;
}
```

### What a person is told when a value breaks one   {#the-message}
A broken bound is refused in a sentence about the FIELD, using its `[Label]` when it has one and its name in words
otherwise — at the field in the browser, before anything is sent, and in the server's answer if a write gets that far:

| declared | the person reads |
|---|---|
| `[Max(2100)] int YearFounded;` | *Year founded must be at most 2100.* |
| `[Label("Vineyard area"), Min(0.5)] decimal Hectares;` | *Vineyard area must be at least 0.5.* |
| `[MinLength(3)] string Code;` | *Code must be at least 3 characters long.* |

A message you write yourself (`[Max(2100, "Give a year before 2101.")]`) replaces the sentence. What the row actually held
(`Winery.YearFounded held 3000`) is kept for the developer — in the server's log and the error's detail — and is not
put in front of the person.

### Can I stack several on one member?   {#combining}
Attributes stack, in one bracket or several — whichever reads better:

```osy title="the full constraint set" test app=entity-constraints
entity Coupon {
  [Required, Unique, MaxLength(20)] string Code;   // present, one of a kind, bounded
  [Min(1), Max(100)] int PercentOff;               // inclusive: 1 and 100 both pass
  [Range(0, int.MaxValue)] int TimesUsed;          // a bound may be a numeric constant
  [MaxLength(500)] string Notes;                   // optional, but bounded when given
}
```

### `[Required]` on a reference forbids an orphan   {#required-reference}
`[Required]` works on a [reference](https://osysharp.com/reference/entity/relations/) too, and it is how you say "this child cannot exist without
its parent":

```osy title="a child that cannot be orphaned" test app=entity-constraints
entity Order {
  [Required] string Code;
}

entity LineItem {
  [Required] Order Order;   // a line with no order is a violation, not a stray row
  decimal Amount;
}
```

### `[Unique]` is enforced by the database   {#unique}
`[Unique]` is a real unique index, not a check-then-insert in application code. That distinction matters under
concurrency: two requests racing to claim the same coupon code cannot both win, because the second one is rejected by
the database rather than by a check that already passed.

**It holds whatever the writer may read.** A duplicate is found even when it sits in a row the writer's read rule
hides, or in a column a `deny read` masks for them — uniqueness is a fact about the stored data, not about one
caller's view of it. The refusal arrives at the write as an ordinary `ValidationException` that a
[`catch`](https://osysharp.com/reference/function/try-catch/) can handle, and it names the field and nothing else: never the other row, its id or any
of its other values. (A masked column still reads as its default — `null`, or a value type's zero — in the writer's own queries — see
[[security-entity-security#field-mask-in-queries]].)

⛔ **AND A SWAP BETWEEN TWO EXISTING ROWS IS REFUSED — the whole commit rolls back, silently.** This is the one
interaction an ordered list actually performs, and it is the opposite of what most people assume, so it is worth
stating plainly:

```osy syntax title="this does not work, and nothing on screen says why"
[Unique] int Position;
// …then, in an action:
int mine = job.Position;
job.Position  = above.Position;   // ← both rows now hold the same value for an instant
above.Position = mine;
UnitOfWork.Commit();              // ← the index refuses; NOTHING is written
```

A unique INDEX is checked per statement, not at commit, so the intermediate state where two rows share a value is
rejected even though the state you were committing is legal. **Measured**: the two rows come back unchanged, and
before 2026-08-29 the refusal reached nobody — no error, no banner, no fault in the test. It now surfaces in a
failing test as `action 'MoveUp' failed: Constraint violation: 'Job.Position' must be unique`, but the write still
does not land.

**So for a position column, do one of these:**

| | |
|---|---|
| **leave it un-`[Unique]`** | the ordinary answer. A rank has no meaning in a gap or a repeat beyond "which comes first", and nothing else depends on it being distinct. |
| **write the midpoint instead of swapping** | `job.Position = (above.Position + below.Position) / 2m` over a `decimal` — one row changes, so no two rows ever collide. This also stops a move rewriting the whole tail. |

⚠ **`[Unique]` is not deferrable today, and that is why.** Foreign keys are (they are emitted as deferrable
constraints); `[Unique]` is emitted as a `CREATE UNIQUE INDEX`, and Postgres cannot defer an index. Making it
deferrable would make the swap above work as written — it is a real option and it is not built.

**Over a COMBINATION of members, write it on the entity** — `[Unique(A, B)]` above the declaration, naming two or
more members. It is the same real unique index, over the pair — **and it takes the same optional message, written
last**: `[Unique(A, B, "…")]`. That is one form, not two, and it is the one to reach for. Without the message the
person is shown the index's own words; with it, they are told what they did:

```osy title="one membership per person per channel, and what a second one is told" test app=entity-unique-composite
entity Channel {
  [Required, MaxLength(60)] string Name;
}

[Unique(Channel, Person, "They are already in this channel.")]
entity Membership {
  [Required] Channel Channel;
  [Required] User Person;
}

[Principal] entity User {
  [Required, MaxLength(200)] string Email;
}
```

This is the constraint a **join table** wants, and reaching for a `Membership.Any(…)` guard instead is the mistake the
paragraph above describes: two requests can both read "not a member" before either writes, and only the index is
enforced where that race is. Keep the guard as well if you want a quiet no-op on a double-click — but it is the
convenience, not the rule.

**Which field does the violation land on?** For a composite one, a field whose name is **the members joined with
`", "`, in declaration order** — `Channel, Person` for the `Membership` above. Not either member on its own: the rule
is about the combination, so what is refused is the combination, named as one thing. That is what a test aims at, and
it is the one string you need:

```osy syntax title="aiming a test at the pair, not at either member"
Assert.Violation("Channel, Person");                                // the pair collided
Assert.Violation("Channel, Person", "already in this channel");     // …and this is what it says
```

A single-member `[Unique]` is the ordinary case — the field is just the member. Either way the violation is raised
by the **server**, at the save, because "is this taken?" is a question about other rows; see
[[testing-ui#composite-unique]] for when a test may assert it.

**Taking a `[Unique]` (or an `[Index]`) off is an ordinary recompile.** The next compile — in place or a new version —
drops the index from the database, so two rows may share the value from then on. Nothing is lost: an index holds no
data, and declaring it again recreates it — once no two rows share the value.

⚠ A unique constraint is over a **table**, and one table holds a whole [inheritance](https://osysharp.com/reference/entity/inheritance/) hierarchy —
so one declared on a base spans every type derived from it. That is usually what you want, and the compiler says so
either way (it warns, naming every type covered).

### Write-once, decimal precision, and size bounds   {#storage-shaping}
Four constraints shape a value beyond "is it valid":

- **`[Immutable]`** is a rule about *updates*, not presence: the member may be set when the row is created and then
  **never changed**. It is how you say "an order's placed-date is written once" — an attempt to update it is refused,
  from any code path. Pair it with `[Required]` when the value must also be present from the start.
- **`[MinLength]`** is the floor to `[MaxLength]`'s ceiling: `[MinLength(n)]` requires text of at least `n` characters.
- **`[Precision]`** pins how a `decimal` is stored: `[Precision(p, s)]` gives `p` total significant digits, `s` of them
  after the decimal point. `[Precision(10, 2)]` is the shape of money — up to eight digits before the point, two after.
- **`[MaxBytes]`** bounds the stored size of a value in bytes: `[MaxBytes(n)]` caps a large field — a `Json` document or
  rich text — where the limit you care about is storage, not character count.

```osy title="immutable, precision, and a byte bound" test app=entity-constraints
entity Invoice {
  [Required, Immutable] DateTime IssuedAt;      // set once, at creation; never edited afterwards
  [Precision(10, 2)] decimal Amount;            // money: 8 digits before the point, 2 after
  [MinLength(3), MaxLength(20)] string Number;  // a bounded reference code
  [MaxBytes(1048576)] Json Payload;             // at most 1 MB of stored JSON
}
```

### An update is judged on the members it changes   {#on-update}
A **create** is judged on every constraint of the row. An **update** is judged on the members it **assigns**: a
`[Required]`, `[Min]`/`[Max]`/`[Range]`, `[MinLength]`/`[MaxLength]`, `[Pattern]`, `[Unique]` or `[Immutable]` on a
member the update does not change is not checked again, and its column is not read for it. The stored value was
checked when it was written, and the update leaves it as it was.

```osy title="a later rule does not lock the rest of the row" test app=entity-constraints-on-update
entity Listing {
  [Required, MaxLength(80)] string Title;
  [Min(1)] int Stock;
  [MaxLength(400)] string? Note;
}

void Restock(Listing listing, int count) {
  listing.Stock = listing.Stock + count;   // judged: Stock is assigned
}

void Annotate(Listing listing, string note) {
  listing.Note = note;                     // Title and Stock are not judged: this update leaves them as they are
}
```

So a row whose `Stock` is `0` — stored before `[Min(1)]` was declared — can still have its `Note` changed. Setting
`Stock` itself to `0` is refused, and so is creating a listing with it.

Two kinds of rule are judged on **every** write, whatever it assigns, because they read more than one member: an
[invariant](https://osysharp.com/reference/entity/invariants/), and a rule with a `when` guard.

**What keeps stored rows inside a rule you add later is the deploy.** A deploy that adds or tightens a `[Min]`,
`[Max]`, `[MinLength]` or `[Pattern]` counts the stored rows that break it, and is refused when any do, naming the rule
and the count — so correct those rows in the same deploy, with a migration function. See
[[project-renaming-and-removing#tightening-a-rule]]. A newly `[Required]` member and a shorter `[MaxLength]` were
already refused until the migration says what the stored rows become.

### The rule spans several members — what then?   {#beyond-one-member}
A constraint speaks about **one member**. When the rule spans several — "shelf days must be under 30, but only for
perishable items" — you want an [invariant](https://osysharp.com/reference/entity/invariants/).

## See also       {#see-also}
- [invariant](https://osysharp.com/reference/entity/invariants/) — rules that span several members of the row
- [entity members](https://osysharp.com/reference/entity/properties/) — the members these constrain
- [relations](https://osysharp.com/reference/entity/relations/) — `[Required]` on a reference
