Osy#betaRun it on Osyrin: osyrin.com

Reference / Entity

constraints

[Required] · [Unique] · [MaxLength(n)] · [MinLength(n)] · [Min(n)] · [Max(n)] · [Range(min, max)] · [Pattern(regex)] · [Immutable] · [Precision(p, s)] · [MaxBytes(n)]

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.

stable7 examples compiled by CIentitymodelvalidation

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#

[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:

  [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:

[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#

Which constraint attributes are there?#

AttributeRuleOn a null value
[Required]the member must be present when the row is writtenthis is the one that rejects null
[Unique]no two rows may hold the same valuepasses — nulls do not collide
[MaxLength(n)] · [MinLength(n)]text is at most / at least n characterspasses
[Min(n)] · [Max(n)]an inclusive numeric rangepasses
[Range(min, max)]the same inclusive range in one attribute — C#'s spelling of [Min(min), Max(max)]passes
[Pattern("…")]text matches the regular expressionpasses
[Immutable]may be set on create, but never updatedpasses (it governs updates, not presence)
[Precision(p, s)]a decimal stored with p total digits and s after the pointpasses
[MaxBytes(n)]the value's stored size is at most n bytespasses

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:

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:

entity Product {
  [Required, Pattern("^[A-Z]{3}$", "must be three capital letters")] string Code;
}

What a person is told when a value breaks one#

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:

declaredthe 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?#

Attributes stack, in one bracket or several — whichever reads better:

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] works on a reference too, and it is how you say "this child cannot exist without its parent":

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] 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 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:

[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 swappingjob.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:

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:

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 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#

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.
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#

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.

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, 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?#

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.

See also#

Related

entity members

The typed members an entity holds — text, numbers, dates, booleans, Guids, enums and references. A member's type…

invariant

A row-level rule spanning several members, checked when the row is written. Use it when a constraint on one member is…

relations

One entity points at another by declaring it as a member — that is the foreign key. The parent reads its children back…

entity

Declares a persisted type — a table of rows the app stores, queries and secures. Every entity gets an Id from the…